@lmzhen/dsh-evolution-core 0.26.26 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/lib/composition/index.js +3 -0
- package/lib/composition-D8Y3-ROd.js +604 -0
- package/lib/ensure-MEYMj9jn.js +222 -0
- package/lib/errors-Br2-uUSK.js +57 -0
- package/lib/http-routes-BP-S3KV-.js +149 -0
- package/lib/http-routes.js +2 -0
- package/lib/index.js +66 -850
- package/lib/restrictor.js +37 -0
- package/lib/subagent/index.js +186 -0
- package/lib/types/citations.js +192 -0
- package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
- package/lib/types/composition/composer.js +681 -0
- package/lib/types/composition/ensure.d.ts +10 -0
- package/lib/types/composition/ensure.js +95 -0
- package/lib/types/composition/index.d.ts +5 -0
- package/lib/types/composition/index.js +7 -0
- package/lib/types/composition/preset-ids.d.ts +17 -0
- package/lib/types/composition/preset-ids.js +21 -0
- package/lib/types/composition/restricted-preset.d.ts +36 -0
- package/lib/types/composition/restricted-preset.js +42 -0
- package/lib/types/constants.js +223 -0
- package/lib/types/cost.js +67 -0
- package/lib/types/curator.js +346 -0
- package/lib/types/diagnostics.d.ts +26 -0
- package/lib/types/diagnostics.js +23 -0
- package/lib/types/document-body.js +91 -0
- package/lib/types/drift-signals.js +319 -0
- package/lib/types/env.js +25 -0
- package/lib/types/errors.d.ts +4 -1
- package/lib/types/errors.js +59 -0
- package/lib/types/events.js +16 -0
- package/lib/types/evidence.js +65 -0
- package/lib/types/evolution-events.js +479 -0
- package/lib/types/fold-untrusted.js +44 -0
- package/lib/types/frontmatter.js +623 -0
- package/lib/types/fuzzy-match.js +135 -0
- package/lib/types/gates.js +51 -0
- package/lib/types/guards.js +27 -0
- package/lib/types/health-baseline.js +63 -0
- package/lib/types/http-routes.d.ts +67 -35
- package/lib/types/http-routes.js +135 -0
- package/lib/types/index.d.ts +7 -2
- package/lib/types/index.js +101 -0
- package/lib/types/instance-scope.js +69 -0
- package/lib/types/io.js +675 -0
- package/lib/types/learn-prompt.js +42 -0
- package/lib/types/limits.js +63 -0
- package/lib/types/live.js +107 -0
- package/lib/types/long-runs.js +96 -0
- package/lib/types/memory-store.d.ts +0 -16
- package/lib/types/memory-store.js +606 -0
- package/lib/types/mutations.js +95 -0
- package/lib/types/namespace-migration.js +156 -0
- package/lib/types/numeric.js +47 -0
- package/lib/types/opt-in.js +101 -0
- package/lib/types/package-identity.js +47 -0
- package/lib/types/params.js +328 -0
- package/lib/types/payload.js +36 -0
- package/lib/types/platform-line.generated.d.ts +10 -0
- package/lib/types/platform-line.generated.js +12 -0
- package/lib/types/platform-services.d.ts +2 -2
- package/lib/types/platform-services.js +52 -0
- package/lib/types/probe.js +122 -0
- package/lib/types/prompts.js +409 -0
- package/lib/types/quality.js +308 -0
- package/lib/types/reachability.js +122 -0
- package/lib/types/redact.js +257 -0
- package/lib/types/reference-rewrite.js +144 -0
- package/lib/types/reports.js +110 -0
- package/lib/types/restrictor.d.ts +11 -0
- package/lib/types/restrictor.js +48 -0
- package/lib/types/review-channel.js +59 -0
- package/lib/types/run-registry.js +345 -0
- package/lib/types/scope.js +7 -0
- package/lib/types/serial.js +17 -0
- package/lib/types/session-fold.d.ts +15 -0
- package/lib/types/session-fold.js +28 -0
- package/lib/types/session-projection.js +295 -0
- package/lib/types/signals.js +209 -0
- package/lib/types/skill-discovery.js +66 -0
- package/lib/types/skill-health.js +134 -0
- package/lib/types/skill-history.js +745 -0
- package/lib/types/skill-lint.js +112 -0
- package/lib/types/skill-reads.js +67 -0
- package/lib/types/skill-row-cells.js +123 -0
- package/lib/types/skill-store.js +3528 -0
- package/lib/types/state-store.js +60 -0
- package/lib/types/store-ledger.js +117 -0
- package/lib/types/subagent/channel.d.ts +4 -0
- package/lib/types/subagent/channel.js +131 -0
- package/lib/types/subagent/index.d.ts +4 -0
- package/lib/types/subagent/index.js +5 -0
- package/lib/types/subagent/readiness.d.ts +6 -0
- package/lib/types/subagent/readiness.js +35 -0
- package/lib/types/subagent/types.d.ts +58 -0
- package/lib/types/subagent/types.js +2 -0
- package/lib/types/threats.js +338 -0
- package/lib/types/tool-dispatch.d.ts +5 -0
- package/lib/types/tool-dispatch.js +535 -0
- package/lib/types/tree-write.js +40 -0
- package/lib/types/unit-members.js +121 -0
- package/lib/types/usage.js +474 -0
- package/lib/types/write-authority.js +23 -0
- package/lib/types/write-gates.js +344 -0
- package/lib/types/write-inventory.js +158 -0
- package/package.json +39 -11
|
@@ -0,0 +1,3528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill library management for the self-evolution plugin.
|
|
3
|
+
*
|
|
4
|
+
* Skills live under `$DSH_HOME/skills` (`~/.dsh/skills` by default), matching
|
|
5
|
+
* the default dsh skill-filesystem user root. The plugin only manages skills
|
|
6
|
+
* it created unless a `.hermes-managed` marker opts a skill in. Archival is a
|
|
7
|
+
* move to `.archive/` — never a hard delete.
|
|
8
|
+
*
|
|
9
|
+
* ## Concurrency discipline (OPT-09, 2026-09) — read before adding a mutator
|
|
10
|
+
*
|
|
11
|
+
* Three primitives, three distinct jobs (they compose, they do not replace
|
|
12
|
+
* each other):
|
|
13
|
+
*
|
|
14
|
+
* 1. **In-process serial queue** (`this.serial`, makeSerialQueue) — orders the
|
|
15
|
+
* read→plan→commit phases of one skill's mutation against OTHER mutators
|
|
16
|
+
* in this process. Used by: create/update/patch/setPinned/restructure/
|
|
17
|
+
* writeSupportFile/removeSupportFile and (whole-mutation) consolidate.
|
|
18
|
+
* NON-reentrant: a callback must never call a public method that wraps
|
|
19
|
+
* itself in `this.serial` (archive/restoreFromArchive deliberately do not).
|
|
20
|
+
* 2. **Per-directory write lock** (io.ts LOCK_*) — cross-process mutual
|
|
21
|
+
* exclusion plus in-process crash ownership (tickets, takeover). Checked
|
|
22
|
+
* with `supportDirsUnlistable` before any destructive move (archive/restore/
|
|
23
|
+
* snapshot); held inside transactIo by byte writers.
|
|
24
|
+
* 3. **CAS baseline (`expected:`)** — any read whose bytes feed a later write
|
|
25
|
+
* must either live inside the serial section that commits the write, or
|
|
26
|
+
* carry its plan-time bytes as `expected` so the commit fails closed on
|
|
27
|
+
* drift (V8-11 / V24-01). A read outside the serial section WITHOUT a
|
|
28
|
+
* baseline is a lost-update bug; this file's history is the test suite.
|
|
29
|
+
*
|
|
30
|
+
* Known residuals (deliberate, documented at their sites): the archive commit
|
|
31
|
+
* re-check narrows but does not close the pin race (OPT-06); snapshotAll
|
|
32
|
+
* re-probes after its copies so a mid-copy writer demotes to `skipped`
|
|
33
|
+
* (OPT-07); the movers' probe→rename window is owned by the io.ts protocol.
|
|
34
|
+
*/
|
|
35
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
36
|
+
import { authorityFrom } from "./write-authority.js";
|
|
37
|
+
import { resolveCitations } from "./citations.js";
|
|
38
|
+
import { scanContentThreats } from "./threats.js";
|
|
39
|
+
import { isReviewChannelSession } from "./review-channel.js";
|
|
40
|
+
import { isCommittedWarning, nodeEvolutionIo, transactIo } from "./io.js";
|
|
41
|
+
import { isAbsent, isPresent, isUnknown, probeAbsent, probeList, probeMtime, probePresent, probeReason, probeText, probeUnknown } from "./probe.js";
|
|
42
|
+
import { evolutionRoot } from "./state-store.js";
|
|
43
|
+
import { DEFAULT_ARCHIVE_RETENTION_POLICY, DEFAULT_CITATION_POLICY, DEFAULT_REFERENCE_REWRITE_POLICY, DEFAULT_SKILL_LIMITS, DEFAULT_SUPPORT_FILE_CHAR_POLICY } from "./limits.js";
|
|
44
|
+
import { applyReferenceRewrite, describeReferenceRewrite, planReferenceRewrite, planRehoming } from "./reference-rewrite.js";
|
|
45
|
+
import { exceedsContentLimit, frontmatterBlock, normalizeFrontmatter, parseFrontmatter, shrinksOverLimit, validateFrontmatter } from "./frontmatter.js";
|
|
46
|
+
import { FUZZY_MAX_PATTERN_CHARS, FUZZY_MAX_WORK, trimPatternBoundaries, fuzzyPatch } from "./fuzzy-match.js";
|
|
47
|
+
import { makeSerialQueue } from "./serial.js";
|
|
48
|
+
import { h2Sections } from "./document-body.js";
|
|
49
|
+
import { diffFindings } from "./skill-lint.js";
|
|
50
|
+
import { contentHash, loadMutations, recordMutation } from "./mutations.js";
|
|
51
|
+
import { contentRetentionFeedback, HISTORY_DIR, loadVersionContent, loadVersions, recordVersions } from "./skill-history.js";
|
|
52
|
+
import { suppressedFile, usageFile } from "./usage.js";
|
|
53
|
+
import { assessStructureHealth, DEFAULT_HEALTH_THRESHOLDS, lintBody } from "./skill-health.js";
|
|
54
|
+
import { CONTENT_SPLIT_HINT, DEFAULT_SKILL_VERSION_KEEP, SKILL_NAME_RE, SUPPORT_DIRS } from "./constants.js";
|
|
55
|
+
/**
|
|
56
|
+
* The CLASS boundary default authority (G1/1.1, Stage 2).
|
|
57
|
+
*
|
|
58
|
+
* The FACTORY (newSkillLibrary) requires an explicit authority and the v35 A6 register pins every
|
|
59
|
+
* production construction, so a production caller cannot omit one. The class is the INTERNAL
|
|
60
|
+
* boundary: arch rule N7 forbids constructing it directly in production, while ~200 test fixtures do
|
|
61
|
+
* exactly that and ARE the operator, so an omitted authority reads as foreground here - named and
|
|
62
|
+
* stated instead of silent.
|
|
63
|
+
*/
|
|
64
|
+
const CLASS_DEFAULT_AUTHORITY = authorityFrom({ origin: 'foreground' });
|
|
65
|
+
/** 0.3.16 (S1.13, T-6): the pointer-line prefix written into a body when a
|
|
66
|
+
* section is moved to references/ — single literal, both restructure and
|
|
67
|
+
* append-mode consolidation emit the same discoverability line. */
|
|
68
|
+
const POINTER_LINE_PREFIX = '> 详见 references/';
|
|
69
|
+
/** V3: the pure LINK LIST that travels with a pointer line. A moved section's
|
|
70
|
+
* citations move one hop deeper, so without this line the body stops mentioning
|
|
71
|
+
* those files at all and `pointer_missing` (body-level by design) reports them as
|
|
72
|
+
* unpointed — measured on the live skill, moving 验证手法 took `missing` from 0 to
|
|
73
|
+
* 8 until the list was emitted. Design §2.3 explicitly allows link-list lines in
|
|
74
|
+
* the body, and a list of paths carries no content claim. */
|
|
75
|
+
const POINTER_REFS_PREFIX = '> 本节引用:';
|
|
76
|
+
/** Cap keeps the line readable; an ellipsis marks a truncated list (visible, never
|
|
77
|
+
* silently short). */
|
|
78
|
+
const POINTER_REFS_CAP = 12;
|
|
79
|
+
/**
|
|
80
|
+
* The body lines one moved section collapses to: the pointer plus the link list of
|
|
81
|
+
* the support files it cited (excluding the file it moved into).
|
|
82
|
+
* @param rel - the support path the section moved into.
|
|
83
|
+
* @param sectionText - the moved section's text.
|
|
84
|
+
* @returns one or two body lines.
|
|
85
|
+
*/
|
|
86
|
+
function pointerBlock(rel, sectionText) {
|
|
87
|
+
const cited = [...new Set(resolveCitations({ content: sectionText, file: 'SKILL.md', files: [] })
|
|
88
|
+
.refs.filter(ref => ref.kind === 'citation' && ref.target !== null)
|
|
89
|
+
.map(ref => ref.target))]
|
|
90
|
+
.filter(target => target !== rel)
|
|
91
|
+
.sort();
|
|
92
|
+
const lines = [`${POINTER_LINE_PREFIX}${rel.split('/').at(-1)}`];
|
|
93
|
+
if (cited.length > 0) {
|
|
94
|
+
const shown = cited.slice(0, POINTER_REFS_CAP);
|
|
95
|
+
lines.push(`${POINTER_REFS_PREFIX}${shown.join(' · ')}${cited.length > shown.length ? ' …' : ''}`);
|
|
96
|
+
}
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
/** Collision tail for the refusal plan note (empty when nothing collided). */
|
|
100
|
+
function collisionsNote(collisions) {
|
|
101
|
+
return collisions.length === 0 ? '' : ` collisions: ${collisions.slice(0, 5).join(', ')}`;
|
|
102
|
+
}
|
|
103
|
+
// G3a: process-local sequence so two tree changes in one millisecond still get distinct unit ids.
|
|
104
|
+
let nextUnitSeq = 0;
|
|
105
|
+
/**
|
|
106
|
+
* Evaluate a stage-time anchor against the bytes a locked read observed.
|
|
107
|
+
* @param anchor - the caller's anchor, or `undefined` for an unanchored write.
|
|
108
|
+
* @param current - the bytes the write lock read (`null` = the target is absent).
|
|
109
|
+
* @returns `match` when the write may proceed, otherwise the refusal verdict.
|
|
110
|
+
*/
|
|
111
|
+
export function anchorVerdict(anchor, current) {
|
|
112
|
+
if (anchor === undefined)
|
|
113
|
+
return 'match';
|
|
114
|
+
if ('absent' in anchor)
|
|
115
|
+
return current === null ? 'match' : 'drift';
|
|
116
|
+
if (current === null)
|
|
117
|
+
return 'missing';
|
|
118
|
+
return contentHash(current) === anchor.sha256 ? 'match' : 'drift';
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Build the refusal for a skill write whose anchor did not hold. The wording is
|
|
122
|
+
* the library's own; a caller with staged-replay wording (the skill tool, the
|
|
123
|
+
* review plan) re-words it from {@link SkillActionResult.anchor}.
|
|
124
|
+
* @param name - the skill name the refusal names.
|
|
125
|
+
* @param verdict - the non-matching verdict.
|
|
126
|
+
* @returns the refusal result (nothing was written).
|
|
127
|
+
*/
|
|
128
|
+
function anchorRefusal(name, verdict) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
stale: true,
|
|
132
|
+
anchor: verdict,
|
|
133
|
+
message: verdict === 'missing'
|
|
134
|
+
? `技能「${name}」不存在。`
|
|
135
|
+
: `技能 "${name}" 在读取之后被改动过;为避免覆盖更新的内容,本次写入被拒绝。`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Build the refusal for a support-file write/remove whose anchor did not hold.
|
|
140
|
+
* @param name - the owning skill name.
|
|
141
|
+
* @param filePath - the support-file path inside the skill.
|
|
142
|
+
* @param verdict - the non-matching verdict.
|
|
143
|
+
* @returns the refusal result (nothing was written or removed).
|
|
144
|
+
*/
|
|
145
|
+
/**
|
|
146
|
+
* Refusal for a target the locked read could not verify at all (EISDIR, an
|
|
147
|
+
* unreadable file). A staged replay reports "could not be verified" instead of
|
|
148
|
+
* propagating an exception: nothing was read, so nothing can have been written.
|
|
149
|
+
* @param name - the owning skill name.
|
|
150
|
+
* @param filePath - the support-file path, or `null` for the skill body.
|
|
151
|
+
* @returns the refusal result.
|
|
152
|
+
*/
|
|
153
|
+
function anchorUnverifiable(name, filePath) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
stale: true,
|
|
157
|
+
anchor: 'drift',
|
|
158
|
+
message: filePath === null
|
|
159
|
+
? `无法读取技能 "${name}" 以校验暂存内容。`
|
|
160
|
+
: `无法读取技能 "${name}" 的支撑文件 "${filePath}" 以校验暂存内容。`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function anchorRefusalFile(name, filePath, verdict) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
stale: true,
|
|
167
|
+
anchor: verdict,
|
|
168
|
+
message: verdict === 'missing'
|
|
169
|
+
? `文件「${filePath}」不在技能「${name}」里。`
|
|
170
|
+
: `技能 "${name}" 的支撑文件 "${filePath}" 在读取之后被改动过;为避免覆盖更新的内容,本次写入被拒绝。`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* v43 S2-14 (FLOW2-1/flow-3): the target could not be READ at all — EISDIR, an
|
|
175
|
+
* unreadable file, a failing backend. `io.readText` returns null only for a
|
|
176
|
+
* genuinely missing path and throws for everything else, so a raw errno used to
|
|
177
|
+
* escape `update` / `patch` / `write_file` to the model while the remove path had
|
|
178
|
+
* classified the same condition since A-5 (v15). Structured refusal, nothing
|
|
179
|
+
* written.
|
|
180
|
+
*
|
|
181
|
+
* @param label - `name` or `name/filePath`, as the caller's surface names it.
|
|
182
|
+
* @param error - the read failure.
|
|
183
|
+
* @returns the refusal result.
|
|
184
|
+
*/
|
|
185
|
+
function unreadableTarget(label, error) {
|
|
186
|
+
const code = error?.code;
|
|
187
|
+
const detail = code === 'EISDIR'
|
|
188
|
+
? 'the path is a DIRECTORY, not a file'
|
|
189
|
+
: error instanceof Error ? error.message : String(error);
|
|
190
|
+
return { ok: false, message: `读不出「${label}」:${detail}。没有写入任何内容。` };
|
|
191
|
+
}
|
|
192
|
+
/** Upper bound of moves per restructure proposal (validator and core agree). */
|
|
193
|
+
export const MAX_RESTRUCTURE_MOVES = 5;
|
|
194
|
+
/** Restructure targets are plain markdown files under references/ — no
|
|
195
|
+
* subdirectories, no other support kind. V8-10 (0.3.47): the regex-level
|
|
196
|
+
* `(?!.*\.\.)` keeps the restructure-created set EXACTLY the set
|
|
197
|
+
* validateSupportPath can reopen — a `references/my..notes.md` target (double
|
|
198
|
+
* dots) used to pass here while every later patch/write/remove on it was
|
|
199
|
+
* refused as traversal (an orphan file the user could not touch). */
|
|
200
|
+
export const RESTRUCTURE_TARGET_RE = /^references\/[a-z0-9](?!.*\.\.)[a-z0-9._-]*\.md$/;
|
|
201
|
+
/** F-20 (v18): the character rule shared by support-file names and snapshot
|
|
202
|
+
* `extras/` entry names. The two exported names used to carry the same literal
|
|
203
|
+
* independently; both now derive from this one. */
|
|
204
|
+
const SUPPORT_ENTRY_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
205
|
+
/** Extra file name carried inside a snapshot's `extras/` directory. */
|
|
206
|
+
export const SNAPSHOT_EXTRA_NAME_RE = SUPPORT_ENTRY_NAME_RE;
|
|
207
|
+
export function skillsRoot(env = process.env) {
|
|
208
|
+
// V9-05 (0.3.51): single resolver — evolutionRoot() holds the ONLY
|
|
209
|
+
// DSH_HOME empty/whitespace fallback; the old bare `||` here resolved
|
|
210
|
+
// `DSH_HOME=" "` to a CWD-relative " /skills" sidecar.
|
|
211
|
+
return join(evolutionRoot(env), 'skills');
|
|
212
|
+
}
|
|
213
|
+
/** 0.3.18 (S4.1, E-30): the ONE root resolution for every member that reads
|
|
214
|
+
* the skills tree — tool-skill-manage / evolution-skill-catalog / skill-usage
|
|
215
|
+
* / evolution-learning-graph used to each resolve `config.root || skillsRoot()`
|
|
216
|
+
* (and the graph ignored config entirely). Empty/whitespace config falls
|
|
217
|
+
* through to the default; callers pass their raw Config. The optional field is
|
|
218
|
+
* declared `| undefined` so a config object whose root field is explicitly
|
|
219
|
+
* `string | undefined` still assignable under exactOptionalPropertyTypes.
|
|
220
|
+
* P2-31 core half (S2.2 batch, PLAN 2026-09-16): an explicit non-empty root
|
|
221
|
+
* is `resolve()`d — the same normalization `evolutionRoot` applies and the
|
|
222
|
+
* same one upstream skill-filesystem applies to `customSkillDirs`
|
|
223
|
+
* (`(config.customSkillDirs ?? []).map(root => resolve(root))`). The former
|
|
224
|
+
* verbatim return left a RELATIVE config root CWD-relative, so the family's
|
|
225
|
+
* skill tree moved with the host process's launch directory while every
|
|
226
|
+
* absolute consumer (watchers, the platform catalog) resolved it — a
|
|
227
|
+
* split-brain tree; a trailing slash or `..` segment likewise landed
|
|
228
|
+
* unnormalized. A clean absolute root is byte-identical (resolve is a no-op). */
|
|
229
|
+
export function resolveSkillsRoot(config = {}) {
|
|
230
|
+
const explicit = (config.root ?? '').trim();
|
|
231
|
+
return explicit ? resolve(explicit) : skillsRoot();
|
|
232
|
+
}
|
|
233
|
+
/** E-7 (v18) → V27 G2.4 (M-08): every family row reads ONE root key. `root` is
|
|
234
|
+
* canonical; the `skillsRoot` alias was honoured for one minor version and its
|
|
235
|
+
* window closed at 0.3.65 — it is now two releases past expiry, so this
|
|
236
|
+
* resolver no longer reads it at all. A deployment that still sets the alias
|
|
237
|
+
* must fail LOUDLY at load (see {@link assertSkillsRootAliasRetired}): silently
|
|
238
|
+
* ignoring a config key leaves the deployment pointing at a root nobody reads,
|
|
239
|
+
* which is the worst form of compatibility.
|
|
240
|
+
* @param config - the raw plugin config.
|
|
241
|
+
* @returns the effective root (empty when the key is unset or blank).
|
|
242
|
+
*/
|
|
243
|
+
export function resolveRootConfig(config = {}) {
|
|
244
|
+
return { root: (config.root ?? '').trim() };
|
|
245
|
+
}
|
|
246
|
+
/** V27 G2.4 (M-08): the retirement gate for the expired `skillsRoot` alias.
|
|
247
|
+
* Called at each plugin's load boundary (before the root is resolved), it turns
|
|
248
|
+
* a stale key into an explicit load error naming the replacement — the
|
|
249
|
+
* fail-loud form the plan requires instead of a silent no-op.
|
|
250
|
+
* @param config - the raw plugin config (the alias field stays DECLARED in each
|
|
251
|
+
* schema so the loader can hand it here instead of dropping it).
|
|
252
|
+
*/
|
|
253
|
+
export function assertSkillsRootAliasRetired(config = {}) {
|
|
254
|
+
if ((config.skillsRoot ?? '').trim() !== '') {
|
|
255
|
+
throw new Error('evolution: config "skillsRoot" was removed after 0.3.65 — rename the key to "root" (the alias is no longer honoured)');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Map a requesting session onto the two origin surfaces (rc.44 plan M2-2.3):
|
|
260
|
+
* the APPROVAL surface treats every delegated subagent as the autonomous
|
|
261
|
+
* review channel, while the LIBRARY surface keeps the Hermes distinction -
|
|
262
|
+
* the review fork is 'background_review' (the pinned guard blocks its
|
|
263
|
+
* writes) and any other subagent is 'subagent' (agent-authored, not
|
|
264
|
+
* review-channel). `isReview` marks the caller as the background review
|
|
265
|
+
* pipeline itself. Single source: the two tools and the review executor all
|
|
266
|
+
* read this table instead of re-deriving it.
|
|
267
|
+
*/
|
|
268
|
+
export function resolveOrigins(headerOrigin, isReview = false) {
|
|
269
|
+
if (isReview)
|
|
270
|
+
return { approval: 'background_review', library: 'background_review' };
|
|
271
|
+
if (headerOrigin === 'subagent')
|
|
272
|
+
return { approval: 'background_review', library: 'subagent' };
|
|
273
|
+
return { approval: 'foreground', library: 'foreground' };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* S1-E8 (0.3.80): ONE exec→origins resolution for the two write tools — reads
|
|
277
|
+
* the session header origin AND the v37 S2.2 review-channel session mark, so a
|
|
278
|
+
* tool cannot forget the mark half (tool-memory shipped without it, which
|
|
279
|
+
* mislabeled every inject-mode review memory write as `foreground` and let it
|
|
280
|
+
* bypass staging under `stageForeground: false`).
|
|
281
|
+
* Single source: both tools call this instead of re-deriving the pair.
|
|
282
|
+
*/
|
|
283
|
+
export function resolveExecOrigins(exec) {
|
|
284
|
+
const session = exec?.agent?.session;
|
|
285
|
+
return resolveOrigins(session?.header?.origin, isReviewChannelSession(typeof session?.id === 'string' ? session.id : undefined));
|
|
286
|
+
}
|
|
287
|
+
function skillDir(root, name) {
|
|
288
|
+
return join(root, name);
|
|
289
|
+
}
|
|
290
|
+
/** Dot-prefixed on-disk marker name. SINGLE source: `list()` matches directory
|
|
291
|
+
* entries against this name, and path builders must never hardcode a marker
|
|
292
|
+
* literal (N-1: the rc.49 exists()-probe convergence dropped the dot,
|
|
293
|
+
* poisoning every protectedBy/managed report). Exported for cross-package
|
|
294
|
+
* consumers that must probe markers without re-deriving the name (curator's
|
|
295
|
+
* archive-copy bundled probe, 0.3.26 V4-02). */
|
|
296
|
+
export function markerEntryName(marker) {
|
|
297
|
+
return `.${marker}`;
|
|
298
|
+
}
|
|
299
|
+
/** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
|
|
300
|
+
const ARCHIVE_RETENTION_DAYS = 365;
|
|
301
|
+
function markerPath(dir, marker) {
|
|
302
|
+
return join(dir, markerEntryName(marker));
|
|
303
|
+
}
|
|
304
|
+
function isCommittedOnly(error) {
|
|
305
|
+
return isCommittedWarning(error);
|
|
306
|
+
}
|
|
307
|
+
/** v28 G2.5 (CORE-SK-03): every pre-clear refusal in restoreSnapshotIntoRoot
|
|
308
|
+
* says "refus…" (manifest / traversal / live-writer gates) or "is incomplete"
|
|
309
|
+
* (completeness gate). Anything else thrown from that method means the
|
|
310
|
+
* destructive clear already happened and a rollback is load-bearing. */
|
|
311
|
+
function isPreClearRefusal(message) {
|
|
312
|
+
return message.includes('refus') || message.includes('is incomplete');
|
|
313
|
+
}
|
|
314
|
+
async function listNames(root, io) {
|
|
315
|
+
const entries = await io.list(root);
|
|
316
|
+
const names = [];
|
|
317
|
+
for (const entry of entries) {
|
|
318
|
+
if (entry.startsWith('.'))
|
|
319
|
+
continue;
|
|
320
|
+
if (await io.exists(join(root, entry, 'SKILL.md')))
|
|
321
|
+
names.push(entry);
|
|
322
|
+
}
|
|
323
|
+
return names.sort();
|
|
324
|
+
}
|
|
325
|
+
/** C-18: support file names are restricted to the
|
|
326
|
+
* RESTRUCTURE_TARGET_RE character class (leading `[a-z0-9]`, then
|
|
327
|
+
* `[a-z0-9._-]`) — drive-colon / odd-character / uppercase names can no
|
|
328
|
+
* longer reach the filesystem through writeSupportFile / patch /
|
|
329
|
+
* removeSupportFile. */
|
|
330
|
+
const SUPPORT_FILE_NAME_RE = SUPPORT_ENTRY_NAME_RE;
|
|
331
|
+
/** C-18: win32 reserves these stems with ANY extension (`nul.md` hits the
|
|
332
|
+
* NUL device), and they are fully inside the charset above — so the reserved
|
|
333
|
+
* set is checked on the first-dot prefix as well; the charset close alone
|
|
334
|
+
* cannot refuse them. Exported single source: `badName` (skill directories,
|
|
335
|
+
* P2-11/v15) and `validateSupportPath` (support-file stems, C-18) both
|
|
336
|
+
* consume this one set — a third copy would drift. */
|
|
337
|
+
const WIN32_RESERVED_DEVICE_NAMES = new Set([
|
|
338
|
+
'con', 'prn', 'aux', 'nul',
|
|
339
|
+
'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
|
|
340
|
+
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
|
|
341
|
+
]);
|
|
342
|
+
/** A1-6 (v18): the regex alone admits `references/nul.md` (a Windows device
|
|
343
|
+
* stem), which the support-file layer refuses. Restructure must use the same
|
|
344
|
+
* rule, or it creates an orphan the later patch/write/remove paths refuse.
|
|
345
|
+
* Single source shared with the plan validator. */
|
|
346
|
+
export function validateRestructureTarget(filePath) {
|
|
347
|
+
if (!RESTRUCTURE_TARGET_RE.test(filePath))
|
|
348
|
+
return `toFile must be references/<topic>.md (got "${filePath}").`;
|
|
349
|
+
const file = filePath.slice(filePath.lastIndexOf('/') + 1);
|
|
350
|
+
const stem = file.split('.')[0]?.toLowerCase() ?? '';
|
|
351
|
+
if (WIN32_RESERVED_DEVICE_NAMES.has(stem))
|
|
352
|
+
return `toFile "${filePath}" uses a Windows reserved device name.`;
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
function validateSupportPath(filePath) {
|
|
356
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
357
|
+
if (normalized.includes('..'))
|
|
358
|
+
return '不允许路径穿越。';
|
|
359
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
360
|
+
if (parts.length === 0 || !SUPPORT_DIRS.includes(parts[0])) {
|
|
361
|
+
return `file_path must be under one of: ${SUPPORT_DIRS.join(', ')}.`;
|
|
362
|
+
}
|
|
363
|
+
if (parts.length < 2)
|
|
364
|
+
return '请给出文件名,不能只给目录。';
|
|
365
|
+
for (const part of parts.slice(1)) {
|
|
366
|
+
if (!SUPPORT_FILE_NAME_RE.test(part)) {
|
|
367
|
+
return `不支持的文件名「${part}」:请只用小写字母、数字、点、连字符和下划线(首字符须为字母或数字)。`;
|
|
368
|
+
}
|
|
369
|
+
// G2 S4: the `.lock` suffix is no longer reserved. It was reserved because a user file named
|
|
370
|
+
// `*.lock` was indistinguishable from the io writer lock; that protocol is retired (writes go
|
|
371
|
+
// through the provider's versioned writes), so the name is a user file name like any other.
|
|
372
|
+
// A1-1 (v18): `.corrupt` and `.tmp` are the io layer's protocol artifacts
|
|
373
|
+
// (quarantine copy / durable-write tmp). A user support file with either
|
|
374
|
+
// suffix was eligible for sweepStaleTmps deletion; reserve them too.
|
|
375
|
+
if (part.toLowerCase().endsWith('.corrupt') || part.toLowerCase().endsWith('.tmp')) {
|
|
376
|
+
return `不支持的文件名「${part}」:.corrupt/.tmp 后缀由状态与 IO 协议保留。`;
|
|
377
|
+
}
|
|
378
|
+
const stem = part.split('.')[0]?.toLowerCase() ?? '';
|
|
379
|
+
if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) {
|
|
380
|
+
return `不支持的文件名「${part}」:这是 Windows 保留设备名。`;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Support-directory references in a markdown body (009 kernel): `references/…`,
|
|
387
|
+
* `templates/…`, `scripts/…`, `assets/…` relative links — any extension and
|
|
388
|
+
* nested paths (v7 audit P3-1: `.md`-only matching missed `scripts/run.sh` and
|
|
389
|
+
* `references/sub/x.md`, both of which dangle just like a `.md` link once the
|
|
390
|
+
* source package is archived). Pure — who checks them and what the verdict is
|
|
391
|
+
* belongs to the caller's context (a moved/appended body whose references
|
|
392
|
+
* travel with an archived source is a dangling link; a restructure pointer is
|
|
393
|
+
* a fresh link to a file written in the same plan). `..` traversal is a
|
|
394
|
+
* different defect class (path validation owns it) and is not a support link.
|
|
395
|
+
*/
|
|
396
|
+
function supportRefs(content) {
|
|
397
|
+
const refs = [];
|
|
398
|
+
for (const match of content.matchAll(/\b(?:references|templates|scripts|assets)\/[A-Za-z0-9._/-]+/g)) {
|
|
399
|
+
const ref = match[0];
|
|
400
|
+
if (ref.includes('..'))
|
|
401
|
+
continue;
|
|
402
|
+
refs.push(ref);
|
|
403
|
+
}
|
|
404
|
+
return refs;
|
|
405
|
+
}
|
|
406
|
+
function planRestructureSections(body, moves) {
|
|
407
|
+
// G3: the anchor scan has ONE home — document-body's `h2Sections`. A candidate list built from
|
|
408
|
+
// that function can never name a heading this planner refuses (missing / ambiguous), and the H2
|
|
409
|
+
// boundary rule (a section spans to the next H2 or EOF) lives there too. The four rejections
|
|
410
|
+
// below are the store's contract and keep their exact wording.
|
|
411
|
+
const lines = body.split('\n');
|
|
412
|
+
const sections = h2Sections(body);
|
|
413
|
+
const spans = [];
|
|
414
|
+
for (const move of moves) {
|
|
415
|
+
const wanted = move.heading.trim();
|
|
416
|
+
const matching = sections.filter(section => section.title === wanted);
|
|
417
|
+
if (matching.length === 0)
|
|
418
|
+
return { error: `no "## ${wanted}" heading in the body` };
|
|
419
|
+
if (matching.length > 1)
|
|
420
|
+
return { error: `heading "## ${wanted}" appears ${matching.length} times (ambiguous anchor)` };
|
|
421
|
+
const section = matching[0];
|
|
422
|
+
if (section === undefined)
|
|
423
|
+
return { error: `no "## ${wanted}" heading in the body` };
|
|
424
|
+
const { start, end } = section;
|
|
425
|
+
if (end === start + 1)
|
|
426
|
+
return { error: `heading "## ${wanted}" has an empty section` };
|
|
427
|
+
if (spans.some(span => start === span.start))
|
|
428
|
+
return { error: `heading "## ${wanted}" is moved twice` };
|
|
429
|
+
spans.push({
|
|
430
|
+
start,
|
|
431
|
+
end,
|
|
432
|
+
rel: move.toFile,
|
|
433
|
+
heading: wanted,
|
|
434
|
+
text: lines.slice(start, end).join('\n'),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
// Rebuild the body line-wise: a moved section collapses to its pointer line.
|
|
438
|
+
const byStart = new Map(spans.map(span => [span.start, span]));
|
|
439
|
+
const rebuilt = [];
|
|
440
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
441
|
+
const span = byStart.get(i);
|
|
442
|
+
if (span) {
|
|
443
|
+
rebuilt.push(...pointerBlock(span.rel, span.text));
|
|
444
|
+
i = span.end - 1;
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
rebuilt.push(lines[i] ?? '');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return { body: rebuilt.join('\n'), sections: spans.map(({ rel, heading, text }) => ({ rel, heading, text })) };
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* WHY MODULE SCOPE (S4.4): the serial queue must be shared ACROSS SkillLibrary instances. Six services
|
|
454
|
+
* each build their own store over the SAME skill root (tool face / curator face / review face), and a
|
|
455
|
+
* per-instance queue - which is what `this.serial = makeSerialQueue()` was - let two of them run
|
|
456
|
+
* read->plan->commit against one tree at the same time. The mutex cannot live on an instance because
|
|
457
|
+
* instances share nothing (S2-02, G1/1.4); it cannot be a file lock either, because the point is to
|
|
458
|
+
* order IN-PROCESS phases before the io transact layer sees them. Keyed by root: two roots are
|
|
459
|
+
* independent trees and must not wait on each other.
|
|
460
|
+
*/
|
|
461
|
+
const ROOT_SERIAL = new Map();
|
|
462
|
+
/** The ONE queue for a skill root, created on first use (module scope, see above).
|
|
463
|
+
* @param root - the resolved skill root; the key, so two spellings of one directory share a queue.
|
|
464
|
+
* @returns the queue every instance over this root serializes through.
|
|
465
|
+
*/
|
|
466
|
+
function serialForRoot(root) {
|
|
467
|
+
const key = resolve(root);
|
|
468
|
+
const existing = ROOT_SERIAL.get(key);
|
|
469
|
+
if (existing !== undefined)
|
|
470
|
+
return existing;
|
|
471
|
+
const created = makeSerialQueue();
|
|
472
|
+
ROOT_SERIAL.set(key, created);
|
|
473
|
+
return created;
|
|
474
|
+
}
|
|
475
|
+
export class SkillLibrary {
|
|
476
|
+
root;
|
|
477
|
+
limits;
|
|
478
|
+
io;
|
|
479
|
+
onMutation;
|
|
480
|
+
/** 0.3.21 (F-208) + V4-20: cross-process RMW transactor. An explicitly
|
|
481
|
+
* injected value wins; otherwise the IO backend's own `transact` is bound
|
|
482
|
+
* when it provides one (cross-process atomicity on by default for any
|
|
483
|
+
* transact-capable backend), and a backend without `transact` leaves this
|
|
484
|
+
* undefined (each write falls back to the plain read→task→write path). */
|
|
485
|
+
transact;
|
|
486
|
+
/** 0.3.21 (F-208): in-process serialize queue so two concurrent mutators on
|
|
487
|
+
* one skill never interleave their read-modify-write (the cross-process layer
|
|
488
|
+
* is the IO backend's transact lock; this chain is the second layer). */
|
|
489
|
+
serial;
|
|
490
|
+
/** V10-03 (P2-18): see the constructor's threatExemptLabels. Empty by
|
|
491
|
+
* default — the strict ANY-hit-blocks policy is unchanged. */
|
|
492
|
+
threatExemptLabels;
|
|
493
|
+
/** G1/1.1: default authority; a call may override (origin is per-invocation). */
|
|
494
|
+
/** G1/1.1/Stage 2: the ONE default every mutator reads. Per-call overrides still win. */
|
|
495
|
+
authority;
|
|
496
|
+
/** skill-history.ts: the content history is best-effort, so a failure must not be SILENT —
|
|
497
|
+
* but it is a deployment condition, not a per-write event, so it warns once per instance. */
|
|
498
|
+
historyWarned = false;
|
|
499
|
+
constructor(root = skillsRoot(), io = nodeEvolutionIo(), limits = DEFAULT_SKILL_LIMITS, onMutation, transact, threatExemptLabels, authority = CLASS_DEFAULT_AUTHORITY) {
|
|
500
|
+
this.root = root;
|
|
501
|
+
this.io = io;
|
|
502
|
+
this.limits = limits;
|
|
503
|
+
this.onMutation = onMutation;
|
|
504
|
+
this.threatExemptLabels = threatExemptLabels ?? [];
|
|
505
|
+
this.authority = authority;
|
|
506
|
+
// V4-20: bind the IO backend's own transact by default. Explicit injection
|
|
507
|
+
// stays first; a backend WITHOUT transact keeps the old plain read→write
|
|
508
|
+
// (the in-process serial chain is the second layer). The wrapper adapts the
|
|
509
|
+
// backend's `(path, task)` instance signature to the standard `(io, path,
|
|
510
|
+
// task)` form the rest of the core expects, guarding the optional method
|
|
511
|
+
// rather than asserting it.
|
|
512
|
+
this.transact = transact ?? (io.transact
|
|
513
|
+
? (ioLike, path, task) => {
|
|
514
|
+
const t = ioLike.transact;
|
|
515
|
+
return t ? t(path, task) : transactIo(ioLike, path, task);
|
|
516
|
+
}
|
|
517
|
+
: undefined);
|
|
518
|
+
// G1/1.4 (S2-02): the queue is per ROOT and shared by every instance (see ROOT_SERIAL above); a
|
|
519
|
+
// per-instance queue ordered nothing across the six services that write this tree.
|
|
520
|
+
this.serial = serialForRoot(this.root);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Run one single-file read-modify-write for a mutator. When `transact` was
|
|
524
|
+
* injected the read and the write run inside it (cross-process atomicity);
|
|
525
|
+
* otherwise a plain read → task → write sequence runs (the process-level
|
|
526
|
+
* `serial` chain is the second layer). `task` receives the current content
|
|
527
|
+
* (null when missing) and returns a {@link SingleWriteOutcome}. Audit and the
|
|
528
|
+
* mutation event are issued ONLY when a write actually lands, so a no-op
|
|
529
|
+
* never inflates the mutation-maturity counter.
|
|
530
|
+
*/
|
|
531
|
+
async runSingleWrite(path, task, readFailure) {
|
|
532
|
+
// v28 G1.1 (EVO-IO-02): `ghostDir` rides the outcome instead of a closure
|
|
533
|
+
// flag — it is set when the task saw a missing body and chose not to
|
|
534
|
+
// write, the ghost-directory precondition decided on the clean
|
|
535
|
+
// `SingleWriteOutcome` type inside `run`.
|
|
536
|
+
let outcome;
|
|
537
|
+
// v35 C9: a staged write must report an unverifiable target as a refusal, not
|
|
538
|
+
// as an exception. `progress.entered` distinguishes a failure of the LOCKED
|
|
539
|
+
// READ (the task never ran, so nothing was verified and nothing was written)
|
|
540
|
+
// from a failure of the write itself. It is a holder object rather than a
|
|
541
|
+
// boolean because the assignment happens inside the transact callback.
|
|
542
|
+
const progress = { entered: false };
|
|
543
|
+
const run = async (current) => {
|
|
544
|
+
progress.entered = true;
|
|
545
|
+
const o = await task(current ?? null);
|
|
546
|
+
outcome = { ...o, ghostDir: current === null && o.write === null };
|
|
547
|
+
// write: null = "leave the file untouched" — return the current bytes so
|
|
548
|
+
// an existing file is preserved and a missing one stays missing (M-4).
|
|
549
|
+
return o.write ?? (current ?? null);
|
|
550
|
+
};
|
|
551
|
+
let durabilityWarning = '';
|
|
552
|
+
// v28 G2.1 (EVO-IO-05): the module-level `isCommittedOnly` is the single
|
|
553
|
+
// source of this predicate (V27 G1.3's own consolidation); the local
|
|
554
|
+
// re-binding here was a fifth copy waiting to drift.
|
|
555
|
+
const committedOnly = isCommittedOnly;
|
|
556
|
+
if (this.transact) {
|
|
557
|
+
try {
|
|
558
|
+
await this.transact(this.io, path, run);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
// A1-15 (v18): the rename landed and only the parent-directory fsync
|
|
562
|
+
// failed. The bytes are visible, so this is NOT a failed write: keep the
|
|
563
|
+
// audit/event below and report the durability warning instead of
|
|
564
|
+
// letting a caller roll back (or retry) a write that already happened.
|
|
565
|
+
if (!progress.entered && readFailure !== undefined)
|
|
566
|
+
return readFailure(error);
|
|
567
|
+
if (!committedOnly(error))
|
|
568
|
+
throw error;
|
|
569
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
let current;
|
|
574
|
+
try {
|
|
575
|
+
current = await this.io.readText(path);
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
if (readFailure !== undefined)
|
|
579
|
+
return readFailure(error);
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
const next = await run(current);
|
|
583
|
+
if (next !== null && next !== current) {
|
|
584
|
+
try {
|
|
585
|
+
await this.io.writeText(path, next);
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
if (!committedOnly(error))
|
|
589
|
+
throw error;
|
|
590
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const o = outcome;
|
|
595
|
+
// V6-19 (0.3.37): a transact backend that violates the contract (never
|
|
596
|
+
// invokes the task — a value-imported transact promised inside guarantees)
|
|
597
|
+
// leaves `outcome` undefined; a structured error beats the TypeError the
|
|
598
|
+
// unconditional dereference used to raise.
|
|
599
|
+
if (o === undefined || typeof o !== 'object' || !Object.prototype.hasOwnProperty.call(o, 'write')) {
|
|
600
|
+
return { ok: false, message: '内部错误:写事务没有调用任务;未执行任何写入' };
|
|
601
|
+
}
|
|
602
|
+
// v28 G1.1 (EVO-IO-02): nothing was written and the body was missing —
|
|
603
|
+
// the io seam's mkdir-before-lock may have resurrected the directory a
|
|
604
|
+
// concurrent archive just moved away (the update/patch shape of the v22
|
|
605
|
+
// LOCK-4 race; setPinnedCore/createCore carry their own compensating
|
|
606
|
+
// cleanups). Without this, a SKILL.md-less ghost dir blocks
|
|
607
|
+
// restoreFromArchive forever ("carries no SKILL.md; remove or repair it").
|
|
608
|
+
if (o.ghostDir === true)
|
|
609
|
+
await this.cleanupGhostDir(path);
|
|
610
|
+
let result = o.result;
|
|
611
|
+
if (o.write !== null && o.audit) {
|
|
612
|
+
await this.audit(o.audit.skillName, o.audit.action, o.audit.before, o.audit.after, o.audit.summary, o.audit.path);
|
|
613
|
+
// Design §4 item 3: the retention line, said HERE because this is the only place that still
|
|
614
|
+
// holds both bodies (the write's own in-lock read) — a caller would have to re-read the file
|
|
615
|
+
// and race the writers this library serializes. Feedback only: it never changes the ok flag.
|
|
616
|
+
const retention = contentRetentionFeedback({
|
|
617
|
+
name: o.audit.skillName,
|
|
618
|
+
action: o.audit.action,
|
|
619
|
+
before: o.audit.before,
|
|
620
|
+
after: o.audit.after,
|
|
621
|
+
});
|
|
622
|
+
if (retention !== null && result.ok)
|
|
623
|
+
result = { ...result, message: `${result.message}\n${retention}` };
|
|
624
|
+
}
|
|
625
|
+
if (o.write !== null && o.event)
|
|
626
|
+
this.notifyMutation(o.event);
|
|
627
|
+
// A1-15 (v18): audit and the mutation event ran even when only the fsync
|
|
628
|
+
// failed — say so in the result instead of pretending the write failed.
|
|
629
|
+
return durabilityWarning === '' || !result.ok
|
|
630
|
+
? result
|
|
631
|
+
: { ...result, message: `${result.message} (警告:写入已落盘,但目录 fsync 失败 —— 持久性未确认:${durabilityWarning})` };
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* v28 G1.1 (EVO-IO-02): shared compensating cleanup for a locked write that
|
|
635
|
+
* found no SKILL.md — remove the possibly-resurrected directory ONLY when it
|
|
636
|
+
* holds nothing but this path's own write-lock file. The BR-5 rule from
|
|
637
|
+
* setPinnedCore/createCore applies unchanged: a concurrent mover can land a
|
|
638
|
+
* full directory between the list probe and the remove, so ANY content at all (support files, a
|
|
639
|
+
* fresh restore) must never be recursed away. Best-effort: a failed cleanup leaves the "not found" result
|
|
640
|
+
* unchanged (the operator-facing ghost-dir refusal is fail-loud already).
|
|
641
|
+
*/
|
|
642
|
+
async cleanupGhostDir(skillFilePath) {
|
|
643
|
+
const dir = dirname(skillFilePath);
|
|
644
|
+
try {
|
|
645
|
+
const leftovers = await this.io.list(dir);
|
|
646
|
+
// G2 S4: the old rule also tolerated this path's own write-lock file; that protocol is retired,
|
|
647
|
+
// so "empty" is the whole test.
|
|
648
|
+
if (leftovers.length > 0)
|
|
649
|
+
return;
|
|
650
|
+
await this.io.remove(dir);
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
// Leave the ghost; restore/archive report it with repair instructions.
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/** Notify the mutation observer after a successful write; observers must never fail the mutation. */
|
|
657
|
+
notifyMutation(event) {
|
|
658
|
+
try {
|
|
659
|
+
this.onMutation?.(event);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
// Observers (catalog invalidation) are advisory; a throwing listener must
|
|
663
|
+
// not surface after the mutation already landed.
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/** V10-03 (P2-18): ScanOptions shared by every write-path threat check —
|
|
667
|
+
* the constructor's exempt labels, empty by default (behavior unchanged). */
|
|
668
|
+
threatScanOptions() {
|
|
669
|
+
return this.threatExemptLabels.length > 0 ? { excludeLabels: this.threatExemptLabels } : {};
|
|
670
|
+
}
|
|
671
|
+
/** V10-03 (P2-18): the strict-scan write gate. A block message names the hit
|
|
672
|
+
* label (scanContentThreats already embeds it) plus the self-heal hint, so a
|
|
673
|
+
* false-positive rewrite direction is actionable instead of a dead end. */
|
|
674
|
+
contentThreatBlock(content) {
|
|
675
|
+
// A2-16 (v18): scanContentThreats already appends its exemption hint;
|
|
676
|
+
// a store-side append duplicated the sentence (the old THREAT_EXEMPT_HINT
|
|
677
|
+
// export was removed in v21 — see threats.ts THREAT_EXEMPTION_HINT).
|
|
678
|
+
return scanContentThreats(content, undefined, this.threatScanOptions());
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* v30 REV-03: read a support file's bytes for staleness anchoring (the
|
|
682
|
+
* write/remove replay guard). Same validation as writeSupportFile; a
|
|
683
|
+
* missing file (or a directory squatting on the path) reads as `null`, any
|
|
684
|
+
* other failure RETHROWS — the callers are the staging/replay anchors, and
|
|
685
|
+
* a swallowed error would silently downgrade the anchor to
|
|
686
|
+
* last-writer-wins.
|
|
687
|
+
*/
|
|
688
|
+
/**
|
|
689
|
+
* S4-01 (G0/0.7): the ONE three-state read the staging/replay anchors use.
|
|
690
|
+
*
|
|
691
|
+
* The two absorbing reads below absorb ENOENT/EISDIR and rethrow everything else, and the callers then
|
|
692
|
+
* each wrote their own try/catch to turn a rethrow into a decision - three near-copies of one posture,
|
|
693
|
+
* and the newest of them silently dropped the anchor instead of refusing. A read failure is its own
|
|
694
|
+
* state, so it gets its own name here: `unknown` with the reason, next to `present` and `absent`.
|
|
695
|
+
* Failing closed is then the CALLER decision (they refuse on unknown), not something each call site has
|
|
696
|
+
* to re-derive from a thrown error.
|
|
697
|
+
*/
|
|
698
|
+
async readSkillProbe(rawName) {
|
|
699
|
+
return await this.probeOf(() => this.read(rawName));
|
|
700
|
+
}
|
|
701
|
+
/** The support-file face of {@link readSkillProbe}. */
|
|
702
|
+
async readSupportFileProbe(rawName, filePath) {
|
|
703
|
+
return await this.probeOf(() => this.readSupportFile(rawName, filePath));
|
|
704
|
+
}
|
|
705
|
+
/** One place that turns the absorbing reads into three states. `null` is absent; a throw is unknown. */
|
|
706
|
+
async probeOf(read) {
|
|
707
|
+
try {
|
|
708
|
+
const value = await read();
|
|
709
|
+
return value === null ? probeAbsent() : probePresent(value);
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
return probeUnknown(probeReason(error));
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async readSupportFile(name, filePath) {
|
|
716
|
+
const bad = this.badName(name, { allowReserved: true });
|
|
717
|
+
if (bad)
|
|
718
|
+
throw new Error(bad);
|
|
719
|
+
const validation = validateSupportPath(filePath);
|
|
720
|
+
if (validation)
|
|
721
|
+
throw new Error(validation);
|
|
722
|
+
const dir = this.dirOf(name);
|
|
723
|
+
const target = join(dir, ...filePath.replace(/\\/g, '/').split('/').filter(Boolean));
|
|
724
|
+
try {
|
|
725
|
+
return await this.io.readText(target);
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
// v30 REV-03: 'absent' is a first-class anchored state — a MISSING file
|
|
729
|
+
// reads as null so the staging/replay anchor can pin "did not exist
|
|
730
|
+
// yet". EISDIR (a directory on the path) reads as null too (no bytes).
|
|
731
|
+
// Any other failure (EACCES…) rethrows: the anchor must not silently
|
|
732
|
+
// degrade to last-writer-wins.
|
|
733
|
+
const code = error?.code;
|
|
734
|
+
// v43 S1-4: deliberately NOT core/probe.ts's isMissingPath — EISDIR is
|
|
735
|
+
// "wrong path shape", not "absent", and merging would swallow it here.
|
|
736
|
+
if (code === 'ENOENT' || code === 'EISDIR')
|
|
737
|
+
return null;
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Summarize the skill tree.
|
|
743
|
+
* @param options - `withContent` attaches each skill's whole SKILL.md body to
|
|
744
|
+
* its summary (v35 C11), `withFrontmatter` its parsed frontmatter record
|
|
745
|
+
* (T2-07/A22): the read this listing already performs is the one either would
|
|
746
|
+
* cost again, so a caller pays no second pass over the tree.
|
|
747
|
+
* @returns one summary per readable skill directory.
|
|
748
|
+
*/
|
|
749
|
+
/** T2-V2/A27: the skill names the TREE holds — the same listing rule {@link list} applies (dot
|
|
750
|
+
* entries skipped, a SKILL.md must exist), WITHOUT reading any SKILL.md. A consumer that needs the
|
|
751
|
+
* member set rather than the summaries (the learning graph's node set) pays one directory listing
|
|
752
|
+
* instead of a whole-tree read pass.
|
|
753
|
+
* @returns the sorted skill directory names. */
|
|
754
|
+
async treeNames() {
|
|
755
|
+
return await listNames(this.root, this.io);
|
|
756
|
+
}
|
|
757
|
+
async list(options = {}) {
|
|
758
|
+
const summaries = [];
|
|
759
|
+
// G1/1.7 (S7-01): ONE listing plus ONE read per skill. The loop used to go through `listNames`,
|
|
760
|
+
// which probes `exists(SKILL.md)` for every entry - so every name cost an existence call AND then a
|
|
761
|
+
// read of the same file. Existence IS the read here: a missing SKILL.md answers null and the entry is
|
|
762
|
+
// skipped, exactly as the probe-then-read pair did (a real read failure still throws, unchanged).
|
|
763
|
+
for (const entry of await this.io.list(this.root)) {
|
|
764
|
+
if (entry.startsWith('.'))
|
|
765
|
+
continue;
|
|
766
|
+
const name = entry;
|
|
767
|
+
const dir = this.dirOf(name);
|
|
768
|
+
// v29 LIST-01 + v30 REG-01: ONLY the EISDIR shape is absorbed (a
|
|
769
|
+
// directory squatting on the SKILL.md path — the E-43 form `read()`
|
|
770
|
+
// also absorbs). The v29 cut swallowed every error here: one TRANSIENT
|
|
771
|
+
// EACCES (win32 AV/indexer hold) silently dropped a LIVE skill from
|
|
772
|
+
// `treeNames`, and the curator's E-15 fold — which assumes a missing
|
|
773
|
+
// tree name means "the archive rename already landed" — permanently
|
|
774
|
+
// marked it archived in the usage sidecar with no self-heal. Any other
|
|
775
|
+
// read failure now fails loud exactly like the pre-v29 behavior.
|
|
776
|
+
let md;
|
|
777
|
+
try {
|
|
778
|
+
md = await this.io.readText(join(dir, 'SKILL.md'));
|
|
779
|
+
}
|
|
780
|
+
catch (error) {
|
|
781
|
+
if (error?.code === 'EISDIR')
|
|
782
|
+
continue;
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
785
|
+
// C-14: a 0-byte SKILL.md is "present but corrupt" — listNames
|
|
786
|
+
// already proved the file exists, so the entry stays visible (parseFrontmatter
|
|
787
|
+
// of '' yields no description) instead of being ghost-skipped: scope and
|
|
788
|
+
// curator must still see a directory that may carry protection markers.
|
|
789
|
+
// Only a missing read (null — a lost race against an archive) skips.
|
|
790
|
+
if (md === null)
|
|
791
|
+
continue;
|
|
792
|
+
const parsed = parseFrontmatter(md);
|
|
793
|
+
// One directory listing replaces the per-marker exists() probes (P2-6
|
|
794
|
+
// N+1 convergence); the marker set matches deleteProtection(). Names are
|
|
795
|
+
// matched through markerEntryName() so the scan sees exactly the names
|
|
796
|
+
// markerPath() would probe (N-1).
|
|
797
|
+
// A1-17 (v18): a listing failure is NOT "no markers". The pre-P2-6 code
|
|
798
|
+
// probed each marker with exists(), and losing that distinction made a
|
|
799
|
+
// protected skill read as unprotected. Keep the listing fast path, fall
|
|
800
|
+
// back to the per-marker probes on the error path, and report `null`
|
|
801
|
+
// (unknown) when even the probes fail.
|
|
802
|
+
let entries = null;
|
|
803
|
+
try {
|
|
804
|
+
entries = await this.io.list(dir);
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
entries = null;
|
|
808
|
+
}
|
|
809
|
+
const probeMarker = async (marker) => {
|
|
810
|
+
if (entries !== null)
|
|
811
|
+
return entries.includes(markerEntryName(marker));
|
|
812
|
+
try {
|
|
813
|
+
return await this.io.exists(join(dir, markerEntryName(marker)));
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
const [bundled, hubInstalled, pinned, hermesManaged] = await Promise.all([
|
|
820
|
+
probeMarker('bundled'), probeMarker('hub-installed'), probeMarker('pinned'), probeMarker('hermes-managed'),
|
|
821
|
+
]);
|
|
822
|
+
const protectedBy = bundled === true ? 'bundled' : hubInstalled === true ? 'hub-installed' : pinned === true ? 'pinned' : null;
|
|
823
|
+
// C-17: type-gate the description — a corrupt frontmatter value
|
|
824
|
+
// (e.g. `description: 123` surviving as a number) must not leak into the
|
|
825
|
+
// string-typed summary field.
|
|
826
|
+
const parsedDescription = parsed?.frontmatter.description;
|
|
827
|
+
// E-11 (v18): type-gate like the description — upstream throws on a
|
|
828
|
+
// non-string whenToUse, so only a non-empty string is published.
|
|
829
|
+
const parsedWhenToUse = parsed?.frontmatter.whenToUse;
|
|
830
|
+
summaries.push({
|
|
831
|
+
name,
|
|
832
|
+
description: typeof parsedDescription === 'string' ? parsedDescription : '',
|
|
833
|
+
path: dir,
|
|
834
|
+
protectedBy,
|
|
835
|
+
protectionUnknown: [bundled, hubInstalled, pinned, hermesManaged].some(value => value === null),
|
|
836
|
+
managed: hermesManaged === true,
|
|
837
|
+
...typeof parsedWhenToUse === 'string' && parsedWhenToUse.trim() !== '' ? { whenToUse: parsedWhenToUse } : {},
|
|
838
|
+
...options.withContent === true ? { content: md } : {},
|
|
839
|
+
...options.withFrontmatter === true && parsed !== null ? { frontmatter: parsed.frontmatter } : {},
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
return summaries;
|
|
843
|
+
}
|
|
844
|
+
async read(rawName) {
|
|
845
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
846
|
+
const name = rawName.trim();
|
|
847
|
+
// Defensive: an invalid name must never escape the skills root via join().
|
|
848
|
+
if (this.badName(name) !== null)
|
|
849
|
+
return null;
|
|
850
|
+
// 0.3.16 (E-43): a DIRECTORY squatting on SKILL.md (EISDIR) reads as
|
|
851
|
+
// absent for the library surface — but readText itself keeps throwing so
|
|
852
|
+
// the event-log rotation can still FLAG a malformed archive slot (rc.72
|
|
853
|
+
// G-2). Only this read boundary absorbs it.
|
|
854
|
+
try {
|
|
855
|
+
return await this.io.readText(join(this.dirOf(name), 'SKILL.md'));
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
if (error?.code === 'EISDIR')
|
|
859
|
+
return null;
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
|
|
865
|
+
* Single path-building choke point (rc.42 audit P2-5): every directory path
|
|
866
|
+
|
|
867
|
+
* is built from the TRIMMED name, so a name that passes `badName` (which
|
|
868
|
+
|
|
869
|
+
* trims before validating) can never mint a second, whitespace-padded
|
|
870
|
+
|
|
871
|
+
* directory next to the real one. Callers keep passing raw user input.
|
|
872
|
+
|
|
873
|
+
*/
|
|
874
|
+
dirOf(name) {
|
|
875
|
+
return skillDir(this.root, name.trim());
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Name-format guard for every path-building entry point (P1-1/v14 closed the
|
|
879
|
+
* one gap: `patch`). Write paths, protection probes and support-file
|
|
880
|
+
* enumeration all call it before `dirOf`, so no directory path is ever built
|
|
881
|
+
* from a name that could escape the skills root. `list()` and `snapshotAll()`
|
|
882
|
+
* are the deliberate exceptions — their names come from `listNames()`, i.e.
|
|
883
|
+
* from the tree itself, never from caller input.
|
|
884
|
+
*/
|
|
885
|
+
badName(name, opts = {}) {
|
|
886
|
+
const normalized = name.trim();
|
|
887
|
+
if (!SKILL_NAME_RE.test(normalized) || normalized.length > this.limits.maxNameLength) {
|
|
888
|
+
return `技能名「${normalized}」不合法:请只用小写字母、数字和连字符(不超过 ${this.limits.maxNameLength} 个)。`;
|
|
889
|
+
}
|
|
890
|
+
// P2-11 (v15): win32 reserves these stems for ANY directory component —
|
|
891
|
+
// the same set the support-file layer has refused since C-18 — so a skill
|
|
892
|
+
// directory named `nul`/`com1` would die with a raw system errno instead
|
|
893
|
+
// of a structured refusal. POSIX deployments lose nothing real: these
|
|
894
|
+
// stems are device mnemonics, not useful skill names (fail-closed).
|
|
895
|
+
// P3 (v16): the MOVERS (archive / restoreFromArchive) pass
|
|
896
|
+
// `allowReserved` — refusal here would strand legacy reserved-name skills
|
|
897
|
+
// created before this guard with no API path to archive or recover them.
|
|
898
|
+
if (!opts.allowReserved && WIN32_RESERVED_DEVICE_NAMES.has(normalized)) {
|
|
899
|
+
return `"${normalized}" is a Windows reserved device name and cannot be used as a skill name.`;
|
|
900
|
+
}
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Refusal reason for a write to `rawName`, or null when the write may
|
|
905
|
+
* proceed: a protection marker on the directory, or an invalid name
|
|
906
|
+
* (P1-1/v14 — the name guard lives HERE as well as at every entry point, so
|
|
907
|
+
* no caller can build a path from an unvalidated name).
|
|
908
|
+
*/
|
|
909
|
+
async writeProtection(rawName, origin = this.authority.origin) {
|
|
910
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
911
|
+
const name = rawName.trim();
|
|
912
|
+
const badName = this.badName(name);
|
|
913
|
+
if (badName)
|
|
914
|
+
return badName;
|
|
915
|
+
const dir = this.dirOf(name);
|
|
916
|
+
for (const marker of ['bundled', 'hub-installed']) {
|
|
917
|
+
if (await this.io.exists(markerPath(dir, marker)))
|
|
918
|
+
return marker;
|
|
919
|
+
}
|
|
920
|
+
// Pinned means "user froze this skill": the autonomous REVIEW pipeline may
|
|
921
|
+
// not rewrite it. A delegated subagent write (origin 'subagent') is an
|
|
922
|
+
// agent-authored change, not the review channel — it keeps the Hermes
|
|
923
|
+
// distinction where only the review fork is subject to the background guard.
|
|
924
|
+
if (origin === 'background_review' && await this.io.exists(markerPath(dir, 'pinned')))
|
|
925
|
+
return 'pinned';
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
// C-13 (v10 audit): `allowBundled` accepts an explicit `undefined` (needed
|
|
929
|
+
// under exactOptionalPropertyTypes) so archive() can pass its whole
|
|
930
|
+
// ArchiveOptions through instead of rebuilding the object.
|
|
931
|
+
async deleteProtection(rawName, options = {}) {
|
|
932
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
933
|
+
const name = rawName.trim();
|
|
934
|
+
// P1-1 (v14): invalid names are refused here too (same rationale as
|
|
935
|
+
// writeProtection) — the returned string is the refusal reason.
|
|
936
|
+
// P3 (v17): reserved names pass here — deleteProtection answers MARKER
|
|
937
|
+
// protection, and archive() needs to move a legacy reserved-name skill
|
|
938
|
+
// (the v16 escape hatch) without its own subsequent call being re-blocked.
|
|
939
|
+
const badName = this.badName(name, { allowReserved: true });
|
|
940
|
+
if (badName)
|
|
941
|
+
return badName;
|
|
942
|
+
const dir = this.dirOf(name);
|
|
943
|
+
const markers = options.allowBundled
|
|
944
|
+
? ['hub-installed', 'pinned']
|
|
945
|
+
: ['bundled', 'hub-installed', 'pinned'];
|
|
946
|
+
for (const marker of markers) {
|
|
947
|
+
if (await this.io.exists(markerPath(dir, marker)))
|
|
948
|
+
return marker;
|
|
949
|
+
}
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
async isManaged(rawName) {
|
|
953
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
954
|
+
const name = rawName.trim();
|
|
955
|
+
if (this.badName(name) !== null)
|
|
956
|
+
return false;
|
|
957
|
+
const dir = this.dirOf(name);
|
|
958
|
+
return await this.io.exists(markerPath(dir, 'hermes-managed'));
|
|
959
|
+
}
|
|
960
|
+
/** Whether the skill carries the bundled marker (curator prune-builtins eligibility). */
|
|
961
|
+
async isBundled(rawName) {
|
|
962
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
963
|
+
const name = rawName.trim();
|
|
964
|
+
if (this.badName(name) !== null)
|
|
965
|
+
return false;
|
|
966
|
+
const dir = this.dirOf(name);
|
|
967
|
+
return await this.io.exists(markerPath(dir, 'bundled'));
|
|
968
|
+
}
|
|
969
|
+
/** Whether the skill carries the pinned marker (the marker is the factual source; usage.pinned mirrors it). */
|
|
970
|
+
async isPinned(rawName) {
|
|
971
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
972
|
+
const name = rawName.trim();
|
|
973
|
+
if (this.badName(name) !== null)
|
|
974
|
+
return false;
|
|
975
|
+
const dir = this.dirOf(name);
|
|
976
|
+
return await this.io.exists(markerPath(dir, 'pinned'));
|
|
977
|
+
}
|
|
978
|
+
/** Count non-empty support subdirectories (richness input for quality scoring). */
|
|
979
|
+
/** G4 ⑤-d: the dangling targets THIS write introduced, as a receipt note (empty when none).
|
|
980
|
+
*
|
|
981
|
+
* Uses the SAME resolver as the drift signal, so a receipt and a drift row can never disagree;
|
|
982
|
+
* an absent or unknown listing reports nothing rather than inventing a dead link.
|
|
983
|
+
*/
|
|
984
|
+
async danglingNoteFor(skillName, before, after) {
|
|
985
|
+
try {
|
|
986
|
+
const listing = await this.listSupportFiles(skillName);
|
|
987
|
+
if (!isPresent(listing))
|
|
988
|
+
return '';
|
|
989
|
+
const files = listing.value;
|
|
990
|
+
const owner = 'SKILL.md';
|
|
991
|
+
const seen = new Set(resolveCitations({ content: before, file: owner, files }).dangling.map(ref => ref.target ?? ref.raw));
|
|
992
|
+
const introduced = resolveCitations({ content: after, file: owner, files }).dangling
|
|
993
|
+
.map(ref => ref.target ?? ref.raw)
|
|
994
|
+
.filter(target => !seen.has(target));
|
|
995
|
+
return introduced.length > 0 ? ' [dangling: ' + introduced.join(',') + ']' : '';
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
return '';
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
async countSupportDirs(rawName) {
|
|
1002
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
1003
|
+
const name = rawName.trim();
|
|
1004
|
+
if (this.badName(name) !== null)
|
|
1005
|
+
return 0;
|
|
1006
|
+
const dir = this.dirOf(name);
|
|
1007
|
+
// N14: the read is three-state; the DEGRADATION to "0 groups" happens here,
|
|
1008
|
+
// on a richness input the health report already names, not inside a catch.
|
|
1009
|
+
const listed = await probeList(this.io, dir);
|
|
1010
|
+
if (!isPresent(listed))
|
|
1011
|
+
return 0;
|
|
1012
|
+
let count = 0;
|
|
1013
|
+
for (const subdir of SUPPORT_DIRS) {
|
|
1014
|
+
if (!listed.value.includes(subdir))
|
|
1015
|
+
continue;
|
|
1016
|
+
const files = await probeList(this.io, join(dir, subdir));
|
|
1017
|
+
if (isPresent(files) && files.value.some(file => file !== '.gitkeep'))
|
|
1018
|
+
count += 1;
|
|
1019
|
+
}
|
|
1020
|
+
return count;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Support-file paths (`references/x.md`) under SUPPORT_DIRS for one skill,
|
|
1024
|
+
* as a THREE-state read (N14): a missing skill directory is absent, an
|
|
1025
|
+
* unreadable one is unknown. A partial listing is unknown too — the union
|
|
1026
|
+
* promises the present branch is complete (011 §7 enrichment, probe reads).
|
|
1027
|
+
*/
|
|
1028
|
+
async listSupportFiles(rawName) {
|
|
1029
|
+
// P1-1 (v14): no path is built from an unvalidated name here either.
|
|
1030
|
+
const name = rawName.trim();
|
|
1031
|
+
if (this.badName(name) !== null)
|
|
1032
|
+
return probeAbsent();
|
|
1033
|
+
const dir = this.dirOf(name);
|
|
1034
|
+
const listed = await probeList(this.io, dir);
|
|
1035
|
+
if (!isPresent(listed))
|
|
1036
|
+
return listed;
|
|
1037
|
+
const out = [];
|
|
1038
|
+
for (const subdir of SUPPORT_DIRS) {
|
|
1039
|
+
if (!listed.value.includes(subdir))
|
|
1040
|
+
continue;
|
|
1041
|
+
const files = await probeList(this.io, join(dir, subdir));
|
|
1042
|
+
// A subdir we cannot read makes the WHOLE listing incomplete: answer
|
|
1043
|
+
// unknown rather than a partial list a caller would treat as complete.
|
|
1044
|
+
if (isUnknown(files))
|
|
1045
|
+
return files;
|
|
1046
|
+
if (!isPresent(files))
|
|
1047
|
+
continue;
|
|
1048
|
+
for (const file of files.value) {
|
|
1049
|
+
if (file === '.gitkeep' || file.startsWith('.'))
|
|
1050
|
+
continue;
|
|
1051
|
+
out.push(`${subdir}/${file}`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return probePresent(out);
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* V4 (design §16.6): exact character counts of the support files that can
|
|
1058
|
+
* POSSIBLY exceed the content cap. The pre-filter is BYTE size, and that is
|
|
1059
|
+
* sound rather than approximate: a UTF-16 code unit never costs less than one
|
|
1060
|
+
* UTF-8 byte, so a file whose bytes are within the cap provably cannot exceed
|
|
1061
|
+
* the cap in characters. The returned map is therefore COMPLETE for the
|
|
1062
|
+
* oversize question without reading the small files.
|
|
1063
|
+
* @param rawName - the skill's name.
|
|
1064
|
+
* @returns path -> character count (possibly empty), or null when the listing or
|
|
1065
|
+
* a size probe cannot answer — unknown is never an empty map.
|
|
1066
|
+
*/
|
|
1067
|
+
/** A plan-time read whose bytes become a tree change's CAS baseline (`expected`). A failed read is
|
|
1068
|
+
* NAMED and answers `null`, which is FAIL-CLOSED at commit: the baseline is then "absent", so an
|
|
1069
|
+
* existing file counts as drift and the commit refuses instead of overwriting bytes nobody read
|
|
1070
|
+
* (N36: the failure is not silently "the file is not there").
|
|
1071
|
+
* @param target - the file a tree change is about to write.
|
|
1072
|
+
* @returns its bytes, or `null` for both "absent" and "could not read". */
|
|
1073
|
+
async readForCas(target) {
|
|
1074
|
+
const probe = await probeText(this.io, target);
|
|
1075
|
+
if (isUnknown(probe)) {
|
|
1076
|
+
console.warn(`skill-store: could not read ${target} for the tree change's baseline (${probe.reason}); treating it as absent — an existing file then counts as drift and the commit refuses`);
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
return isPresent(probe) ? probe.value : null;
|
|
1080
|
+
}
|
|
1081
|
+
async supportFileChars(rawName) {
|
|
1082
|
+
const name = rawName.trim();
|
|
1083
|
+
if (this.badName(name) !== null)
|
|
1084
|
+
return null;
|
|
1085
|
+
const listed = await this.listSupportFiles(name);
|
|
1086
|
+
if (!isPresent(listed))
|
|
1087
|
+
return null;
|
|
1088
|
+
const dir = this.dirOf(name);
|
|
1089
|
+
const chars = {};
|
|
1090
|
+
for (const path of listed.value) {
|
|
1091
|
+
const full = join(dir, ...path.split('/').filter(Boolean));
|
|
1092
|
+
// N36: "cannot answer" stays the answer (the contract above), but a backend without the size
|
|
1093
|
+
// probe and a size probe that FAILED are told apart in the log instead of collapsed silently.
|
|
1094
|
+
let bytes = null;
|
|
1095
|
+
if (this.io.size !== undefined) {
|
|
1096
|
+
try {
|
|
1097
|
+
bytes = await this.io.size(full);
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
console.warn(`skill-store: size of ${full} could not be read (${error instanceof Error ? error.message : String(error)}); reporting no support-file char counts`);
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
if (bytes === null)
|
|
1105
|
+
return null;
|
|
1106
|
+
if (bytes <= this.limits.maxSkillContentChars)
|
|
1107
|
+
continue;
|
|
1108
|
+
const contentProbe = await probeText(this.io, full);
|
|
1109
|
+
if (!isPresent(contentProbe)) {
|
|
1110
|
+
if (isUnknown(contentProbe))
|
|
1111
|
+
console.warn(`skill-store: ${full} could not be read (${contentProbe.reason}); reporting no support-file char counts`);
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
const content = contentProbe.value;
|
|
1115
|
+
chars[path] = content.length;
|
|
1116
|
+
}
|
|
1117
|
+
return chars;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Structure-health facts for one skill (rc.73 A1, 008 design): body
|
|
1121
|
+
* chars/density from SKILL.md, support groups from countSupportDirs, plus
|
|
1122
|
+
* optional usage counts (A2 churn dimension) when the caller has them.
|
|
1123
|
+
* Derived, never persisted. CONTRACT: `null` whenever the skill cannot be
|
|
1124
|
+
* read — a missing file AND any read failure (EACCES/EIO/…) both answer
|
|
1125
|
+
* null, so a whole health view degrades one ROW instead of throwing out of
|
|
1126
|
+
* its per-skill loop (A6, audit P2-10: the former code absorbed only
|
|
1127
|
+
* missing/EISDIR and let a transient win32 hold kill the entire view).
|
|
1128
|
+
*/
|
|
1129
|
+
async assessHealth(rawName, thresholds = DEFAULT_HEALTH_THRESHOLDS, counts) {
|
|
1130
|
+
try {
|
|
1131
|
+
const name = rawName.trim();
|
|
1132
|
+
const content = await this.read(name);
|
|
1133
|
+
if (content === null)
|
|
1134
|
+
return null;
|
|
1135
|
+
return assessStructureHealth({
|
|
1136
|
+
skillName: name,
|
|
1137
|
+
bodyChars: content.length,
|
|
1138
|
+
bodyText: content,
|
|
1139
|
+
supportGroups: await this.countSupportDirs(name),
|
|
1140
|
+
patchCount: counts?.patchCount,
|
|
1141
|
+
readCount: counts?.readCount,
|
|
1142
|
+
}, thresholds);
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/** Best-effort audit trail entry; never blocks the mutation.
|
|
1149
|
+
*
|
|
1150
|
+
* Two independent best-effort writes run here, because either one failing must not cost the
|
|
1151
|
+
* other: the CONTENT history (skill-history.ts, so a version can be read back at all) and the
|
|
1152
|
+
* mutation ledger that points at it. The history failure is not silent — a deployment whose
|
|
1153
|
+
* history stopped growing is a degradation worth seeing once (the ledger keeps hashes either
|
|
1154
|
+
* way). */
|
|
1155
|
+
async audit(skillName, action, before, after, summary,
|
|
1156
|
+
// Which support file the bytes belong to, RELATIVE to the skill directory (absent for the body's
|
|
1157
|
+
// own writes): the version entry records it so a face can name the file without guessing.
|
|
1158
|
+
path,
|
|
1159
|
+
// G5: who caused this write (a write face such as `foreground` / `background_review`, or a
|
|
1160
|
+
// migration step). Optional so existing callers keep their behaviour; the ledger stores it when
|
|
1161
|
+
// given, and old records without it stay readable.
|
|
1162
|
+
// G3a: one tree change writes N paths under ONE unit id; the entries it writes share it.
|
|
1163
|
+
unitId, unitHead, origin) {
|
|
1164
|
+
const at = new Date().toISOString();
|
|
1165
|
+
let versions = null;
|
|
1166
|
+
try {
|
|
1167
|
+
const keep = this.limits.versionKeep ?? DEFAULT_SKILL_VERSION_KEEP;
|
|
1168
|
+
// `exactOptionalPropertyTypes` is on: omit the key rather than passing an explicit undefined.
|
|
1169
|
+
const input = {
|
|
1170
|
+
skillName,
|
|
1171
|
+
action,
|
|
1172
|
+
before,
|
|
1173
|
+
after,
|
|
1174
|
+
at,
|
|
1175
|
+
...path === undefined ? {} : { path },
|
|
1176
|
+
...unitId === undefined ? {} : { unitId },
|
|
1177
|
+
...unitHead === undefined ? {} : { unitHead },
|
|
1178
|
+
};
|
|
1179
|
+
versions = await recordVersions(this.root, this.io, input, keep);
|
|
1180
|
+
}
|
|
1181
|
+
catch (error) {
|
|
1182
|
+
if (!this.historyWarned) {
|
|
1183
|
+
this.historyWarned = true;
|
|
1184
|
+
console.warn(`skill-store: content history not recorded for "${skillName}" (${error instanceof Error ? error.message : String(error)}); the mutation itself landed and the audit ledger keeps its hashes`);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
// G4 ⑤: what THIS write introduced (and what it made worse) — never the standing backlog. The
|
|
1188
|
+
// judgement comes from the engine (`lintBody` → the one facts builder); a receipt must not fail
|
|
1189
|
+
// the mutation, so a throw here degrades to no note.
|
|
1190
|
+
let healthNote = '';
|
|
1191
|
+
// G4 ⑤-d: filled below when THIS write turns a reference into a dead link.
|
|
1192
|
+
// G4 ⑤-d: a write that turns a reference into a dead link says so; decided by the same resolver
|
|
1193
|
+
// the drift signal uses, and only for targets THIS write broke.
|
|
1194
|
+
const danglingNote = before === null || after === null || before === after
|
|
1195
|
+
? ''
|
|
1196
|
+
: await this.danglingNoteFor(skillName, before, after);
|
|
1197
|
+
if (before !== null && after !== null && before !== after) {
|
|
1198
|
+
try {
|
|
1199
|
+
const groups = await this.countSupportDirs(skillName);
|
|
1200
|
+
const delta = diffFindings(lintBody(skillName, before, groups), lintBody(skillName, after, groups));
|
|
1201
|
+
const parts = [];
|
|
1202
|
+
if (delta.introduced.length > 0)
|
|
1203
|
+
parts.push('+' + delta.introduced.map(f => f.rule).join(','));
|
|
1204
|
+
if (delta.worsened.length > 0)
|
|
1205
|
+
parts.push('^' + delta.worsened.map(f => f.rule + ' x' + String(f.count)).join(','));
|
|
1206
|
+
if (parts.length > 0)
|
|
1207
|
+
healthNote = ' [health: ' + parts.join(' ') + ']';
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
healthNote = '';
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
try {
|
|
1214
|
+
await recordMutation(this.root, this.io, {
|
|
1215
|
+
skillName,
|
|
1216
|
+
action,
|
|
1217
|
+
...before === null ? {} : { beforeHash: contentHash(before) },
|
|
1218
|
+
...after === null ? {} : { afterHash: contentHash(after) },
|
|
1219
|
+
...versions?.beforeVersion === undefined ? {} : { beforeVersion: versions.beforeVersion },
|
|
1220
|
+
...versions?.afterVersion === undefined ? {} : { afterVersion: versions.afterVersion },
|
|
1221
|
+
summary: summary + healthNote + danglingNote,
|
|
1222
|
+
at,
|
|
1223
|
+
...origin === undefined ? {} : { origin },
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
catch {
|
|
1227
|
+
// Auditing is best-effort; a transient disk failure must not surface
|
|
1228
|
+
// after the mutation already landed.
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
/** Recent mutation audit records (read-only inspection surface). */
|
|
1232
|
+
async listMutations() {
|
|
1233
|
+
return await loadMutations(this.root, this.io);
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Content versions recorded for one skill, oldest first (skill-history.ts, batch A).
|
|
1237
|
+
*
|
|
1238
|
+
* Read-only: the version seam never feeds a judgment — callers use it to LIST and to read content
|
|
1239
|
+
* back. A malformed or unreadable index reads as empty, and a bad name reads as empty too (the
|
|
1240
|
+
* caller is a human command, so "no versions" is the honest answer for a name that cannot exist).
|
|
1241
|
+
* @param rawName - the skill's name.
|
|
1242
|
+
* @returns the versions, oldest first.
|
|
1243
|
+
*/
|
|
1244
|
+
async listVersions(rawName) {
|
|
1245
|
+
const name = rawName.trim();
|
|
1246
|
+
if (this.badName(name) !== null)
|
|
1247
|
+
return [];
|
|
1248
|
+
return await loadVersions(this.root, this.io, name);
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* One version's content, read back from the content-addressed blob store.
|
|
1252
|
+
* @param rawName - the skill's name.
|
|
1253
|
+
* @param v - the version number (see {@link SkillLibrary.listVersions}).
|
|
1254
|
+
* @returns the content, or null when the version does not exist, the name is unusable, or the
|
|
1255
|
+
* blob cannot be read — never an empty string.
|
|
1256
|
+
*/
|
|
1257
|
+
async readVersion(rawName, v) {
|
|
1258
|
+
const name = rawName.trim();
|
|
1259
|
+
if (this.badName(name) !== null)
|
|
1260
|
+
return null;
|
|
1261
|
+
return await loadVersionContent(this.root, this.io, name, v);
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
|
|
1265
|
+
* deletion, from background-review writes, and from the lifecycle — a
|
|
1266
|
+
* protective mutation, so the autonomous pipeline may never call it. The
|
|
1267
|
+
* marker write is the only state change; content is untouched.
|
|
1268
|
+
*/
|
|
1269
|
+
async setPinned(name, pinned, origin = this.authority.origin) {
|
|
1270
|
+
// A1-14 (v18): pin/unpin is a protection mutation and must be ordered with
|
|
1271
|
+
// the other single-file mutators on the in-process serial chain — a
|
|
1272
|
+
// concurrent update could otherwise pass its protection check before the
|
|
1273
|
+
// marker lands and rewrite a skill the user just froze.
|
|
1274
|
+
return await this.serial(() => this.setPinnedCore(name, pinned, origin));
|
|
1275
|
+
}
|
|
1276
|
+
async setPinnedCore(name, pinned, origin) {
|
|
1277
|
+
const normalized = name.trim();
|
|
1278
|
+
// C-12 (v10 audit): the inline badName() copy is gone — every entry guard
|
|
1279
|
+
// reads the single source, so a limits change cannot fork the message.
|
|
1280
|
+
const bad = this.badName(normalized);
|
|
1281
|
+
if (bad)
|
|
1282
|
+
return { ok: false, message: bad };
|
|
1283
|
+
if (origin === 'background_review') {
|
|
1284
|
+
return { ok: false, message: '只有前台(用户或主智能体)可以固定或取消固定技能。' };
|
|
1285
|
+
}
|
|
1286
|
+
let durabilityWarning = '';
|
|
1287
|
+
const dir = this.dirOf(normalized);
|
|
1288
|
+
const marker = markerPath(dir, 'pinned');
|
|
1289
|
+
const existing = await this.io.exists(marker);
|
|
1290
|
+
if (pinned && existing)
|
|
1291
|
+
return { ok: true, message: `技能「${normalized}」已被固定。`, path: dir };
|
|
1292
|
+
if (!pinned && !existing)
|
|
1293
|
+
return { ok: true, message: `技能「${normalized}」未被固定,无需处理。`, path: dir };
|
|
1294
|
+
if (!await this.io.exists(join(dir, 'SKILL.md'))) {
|
|
1295
|
+
return { ok: false, message: `技能「${normalized}」不存在。` };
|
|
1296
|
+
}
|
|
1297
|
+
if (pinned) {
|
|
1298
|
+
try {
|
|
1299
|
+
await this.io.writeText(marker, '');
|
|
1300
|
+
}
|
|
1301
|
+
catch (error) {
|
|
1302
|
+
// P2-2 (v19): the marker landed; only the dir fsync failed. Reporting
|
|
1303
|
+
// a failure here made the retry say "already pinned".
|
|
1304
|
+
if (!isCommittedOnly(error))
|
|
1305
|
+
throw error;
|
|
1306
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
1307
|
+
}
|
|
1308
|
+
// v22 (LOCK-4): the SKILL.md probe above is lock-free and the marker
|
|
1309
|
+
// write itself holds no writer lock, so a concurrent archive moving the
|
|
1310
|
+
// directory in between left writeText's `mkdir(recursive)` holding the
|
|
1311
|
+
// old path open as a SKILL.md-less ghost (restore then refused that
|
|
1312
|
+
// name forever, with no log pointing at the cause). Re-check and undo:
|
|
1313
|
+
// if the body vanished while we wrote, the marker (and the dir it just
|
|
1314
|
+
// recreated) goes away with it and the caller gets a structured
|
|
1315
|
+
// failure instead of a silently pinned ghost.
|
|
1316
|
+
if (!(await this.io.exists(join(dir, 'SKILL.md')))) {
|
|
1317
|
+
await this.io.remove(marker).catch(() => { });
|
|
1318
|
+
// v23 (BR-5): remove the recreated directory only when OUR marker is
|
|
1319
|
+
// its LAST remaining entry — a concurrent restore's single rename can
|
|
1320
|
+
// land a full directory between the exists probe and this point, and
|
|
1321
|
+
// the recursive remove must never take it.
|
|
1322
|
+
// v29 SK-06: routed through the shared `cleanupGhostDir` (probing the
|
|
1323
|
+
// SKILL.md path) so a stale `.lock` left by a failed lock release no
|
|
1324
|
+
// longer defeats the removal and the rule has exactly one owner.
|
|
1325
|
+
await this.cleanupGhostDir(join(dir, 'SKILL.md'));
|
|
1326
|
+
return { ok: false, message: `固定过程中技能「${normalized}」被并发归档;残留标记已移除:等搬运完成后重试。` };
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
await this.io.remove(marker);
|
|
1331
|
+
}
|
|
1332
|
+
await this.audit(normalized, pinned ? 'pin' : 'unpin', null, null, pinned ? 'pinned' : 'unpinned');
|
|
1333
|
+
return {
|
|
1334
|
+
ok: true,
|
|
1335
|
+
message: `${pinned
|
|
1336
|
+
? `技能 "${normalized}" 已固定:免受删除、后台复核与生命周期影响。`
|
|
1337
|
+
: `技能 "${normalized}" 已取消固定。`}${durabilityWarning === '' ? '' : ` (警告:写入已落盘,但目录 fsync 失败 —— 持久性未确认:${durabilityWarning})`}`,
|
|
1338
|
+
path: dir,
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
async create(name, content, origin = this.authority.origin) {
|
|
1342
|
+
const normalized = name.trim();
|
|
1343
|
+
// P2-3 (v11): the exists-probe → write window runs under the in-process
|
|
1344
|
+
// serialize queue (F-208 discipline, like update/patch) — two concurrent
|
|
1345
|
+
// create calls for one name used to both pass exists() and double-write
|
|
1346
|
+
// (double audit, double mutation event).
|
|
1347
|
+
return await this.serial(() => this.createCore(normalized, content, origin));
|
|
1348
|
+
}
|
|
1349
|
+
async createCore(normalized, content, origin) {
|
|
1350
|
+
// C-12 (v10 audit): the inline badName() copy is gone (see setPinned).
|
|
1351
|
+
const bad = this.badName(normalized);
|
|
1352
|
+
if (bad)
|
|
1353
|
+
return { ok: false, message: bad };
|
|
1354
|
+
const validation = validateFrontmatter(content, normalized, this.limits);
|
|
1355
|
+
if (validation)
|
|
1356
|
+
return { ok: false, message: validation };
|
|
1357
|
+
// 0.3.11: frontmatter normalization happens at the write point — the
|
|
1358
|
+
// platform catalog parses strict YAML, so unquoted plain scalars carrying
|
|
1359
|
+
// `: ` (etc.) would silently make the skill invisible to the platform.
|
|
1360
|
+
const norm = normalizeFrontmatter(content);
|
|
1361
|
+
if (norm.issues.length > 0)
|
|
1362
|
+
return { ok: false, message: `技能「${normalized}」的 frontmatter 无法自动修复:${norm.issues[0]}:请用双引号包住取值后重试。` };
|
|
1363
|
+
const finalContent = norm.changed ? norm.content : content;
|
|
1364
|
+
if (norm.changed) {
|
|
1365
|
+
const revalidated = validateFrontmatter(finalContent, normalized, this.limits);
|
|
1366
|
+
if (revalidated)
|
|
1367
|
+
return { ok: false, message: revalidated };
|
|
1368
|
+
}
|
|
1369
|
+
const threat = this.contentThreatBlock(finalContent);
|
|
1370
|
+
if (threat)
|
|
1371
|
+
return { ok: false, message: threat };
|
|
1372
|
+
const dir = this.dirOf(normalized);
|
|
1373
|
+
if (await this.io.exists(join(dir, 'SKILL.md')))
|
|
1374
|
+
return { ok: false, message: `技能「${normalized}」已存在。` };
|
|
1375
|
+
// v21 (D-6): NTFS and macOS default filesystems match names case-
|
|
1376
|
+
// insensitively while the skill namespace is lowercase-only — a hand-
|
|
1377
|
+
// maintained tree carrying `My-Tool/` made create('my-tool') diverge
|
|
1378
|
+
// across platforms (exists-refusal on win32, silently created beside it
|
|
1379
|
+
// on POSIX, vanishing after a git/WSL round-trip). Probe the listing for
|
|
1380
|
+
// a case-variant and refuse with the variant named, so the refusal reads
|
|
1381
|
+
// the same everywhere. Read paths stay platform-native (fail-closed).
|
|
1382
|
+
// v43 audit (FLOW3-2): a FAILED listing must not read as "no case-variant
|
|
1383
|
+
// exists". The catch-all folded every error into an empty list, so a
|
|
1384
|
+
// transient list failure skipped this guard on POSIX and created `my-tool/`
|
|
1385
|
+
// beside `My-Tool/` — the very input win32 refuses — while the probe further
|
|
1386
|
+
// down this file already draws the opposite line (its comment: a failed
|
|
1387
|
+
// enumeration must not mask the error). Fail closed.
|
|
1388
|
+
let rootEntries;
|
|
1389
|
+
try {
|
|
1390
|
+
rootEntries = await this.io.list(this.root);
|
|
1391
|
+
}
|
|
1392
|
+
catch (error) {
|
|
1393
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
1394
|
+
return {
|
|
1395
|
+
ok: false,
|
|
1396
|
+
message: `无法检查技能「${normalized}」是否与大小写变体冲突:列出 ${this.root} 失败(${cause})。拒绝创建 —— 仅大小写不同的第二个目录在跨平台时无法撤销。`,
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
for (const entry of rootEntries) {
|
|
1400
|
+
if (typeof entry === 'string' && entry !== normalized && entry.toLowerCase() === normalized.toLowerCase()) {
|
|
1401
|
+
return { ok: false, message: `技能「${normalized}」与已存在的大小写变体目录「${entry}」冲突(技能名只允许小写):请重命名其中一个。` };
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
// C-15: a pre-existing directory carrying a protection marker is
|
|
1405
|
+
// refused — the same writeProtection() verdict update/patch apply — so
|
|
1406
|
+
// create() can no longer drop a SKILL.md into a bundled/hub-installed
|
|
1407
|
+
// (or, for the review channel, pinned) tree it does not own.
|
|
1408
|
+
const protection = await this.writeProtection(normalized, origin);
|
|
1409
|
+
if (protection)
|
|
1410
|
+
return { ok: false, message: `技能「${normalized}」受保护(${protection})。` };
|
|
1411
|
+
// F-337: hash the bytes that actually land on disk (write uses
|
|
1412
|
+
// trimEnd()+'\n'), so the audit afterHash is replay-identical to the file.
|
|
1413
|
+
const onDisk = finalContent.trimEnd() + '\n';
|
|
1414
|
+
const createPath = join(dir, 'SKILL.md');
|
|
1415
|
+
// P3 (v17): the already-exists refusal moves INSIDE the cross-process
|
|
1416
|
+
// transact — the lock-free probe above could be raced by a concurrent
|
|
1417
|
+
// create on another instance/process, and the later write silently
|
|
1418
|
+
// overwrote the winner's bytes (double audit, double event). In-lock
|
|
1419
|
+
// re-check: current bytes present -> structured refusal, nothing written.
|
|
1420
|
+
let existsAtCommit = false;
|
|
1421
|
+
let taskRan = false;
|
|
1422
|
+
let createDurabilityWarning = '';
|
|
1423
|
+
if (this.transact) {
|
|
1424
|
+
try {
|
|
1425
|
+
await this.transact(this.io, createPath, (current) => {
|
|
1426
|
+
// v43 audit (FLOW3-1): this callback RE-RUNS when the write lock is
|
|
1427
|
+
// lost before the commit (io redoes the whole RMW), and the flags above
|
|
1428
|
+
// are OR-accumulated across attempts. A stale `existsAtCommit` from the
|
|
1429
|
+
// first attempt made a create whose retry DID write report "already
|
|
1430
|
+
// exists". Reset per attempt so the post-loop decision reads the LAST
|
|
1431
|
+
// attempt's observation only.
|
|
1432
|
+
existsAtCommit = false;
|
|
1433
|
+
taskRan = true;
|
|
1434
|
+
if (current !== null) {
|
|
1435
|
+
existsAtCommit = true;
|
|
1436
|
+
return current;
|
|
1437
|
+
}
|
|
1438
|
+
return onDisk;
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
catch (error) {
|
|
1442
|
+
// P2-2 (v19): the SKILL.md landed; only the dir fsync failed.
|
|
1443
|
+
if (!isCommittedOnly(error))
|
|
1444
|
+
throw error;
|
|
1445
|
+
createDurabilityWarning = error instanceof Error ? error.message : String(error);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
else if (await this.io.exists(createPath)) {
|
|
1449
|
+
taskRan = true;
|
|
1450
|
+
existsAtCommit = true;
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
taskRan = true;
|
|
1454
|
+
try {
|
|
1455
|
+
await this.io.writeText(createPath, onDisk);
|
|
1456
|
+
}
|
|
1457
|
+
catch (error) {
|
|
1458
|
+
if (!isCommittedOnly(error))
|
|
1459
|
+
throw error;
|
|
1460
|
+
createDurabilityWarning = error instanceof Error ? error.message : String(error);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
// A1-22 (v18): a contract-violating transact that never invokes the
|
|
1464
|
+
// task used to report success with no file. Mirror runSingleWrite.
|
|
1465
|
+
if (!taskRan)
|
|
1466
|
+
return { ok: false, message: '内部错误:创建事务没有调用任务;未写入任何文件' };
|
|
1467
|
+
if (existsAtCommit)
|
|
1468
|
+
return { ok: false, message: `技能「${normalized}」已存在。` };
|
|
1469
|
+
// Any non-foreground writer (review channel OR delegated subagent) is an
|
|
1470
|
+
// agent-authored skill: mark it managed so the lifecycle owns it.
|
|
1471
|
+
// v22 (LOCK-4): the marker lands only while the body we just wrote is
|
|
1472
|
+
// still on disk — a concurrent archive moving the fresh directory away
|
|
1473
|
+
// would otherwise let writeText's mkdir resurrect the old path as a
|
|
1474
|
+
// SKILL.md-less ghost (same compensating cleanup as setPinnedCore).
|
|
1475
|
+
// R3 (audit of A6): the marker warning keeps its OWN variable and its own
|
|
1476
|
+
// sentence — the former fold into createDurabilityWarning re-framed "the
|
|
1477
|
+
// marker never landed" inside the fsync/durability wrapper, producing a
|
|
1478
|
+
// self-contradicting operator-facing message.
|
|
1479
|
+
let markerWarning = '';
|
|
1480
|
+
if (origin !== 'foreground') {
|
|
1481
|
+
// v29 SK-05: the marker write gets the same committed-only tolerance as
|
|
1482
|
+
// every other write in this file (A1-15) — a post-rename dir-fsync
|
|
1483
|
+
// failure means the MARKER landed; rejecting the create as a plain
|
|
1484
|
+
// failure made the model retry into "already exists" for a skill that
|
|
1485
|
+
// IS on disk and managed.
|
|
1486
|
+
try {
|
|
1487
|
+
await this.io.writeText(markerPath(dir, 'hermes-managed'), '');
|
|
1488
|
+
}
|
|
1489
|
+
catch (error) {
|
|
1490
|
+
// A6 (audit P2-11): the body HAS landed at this point, so a failed
|
|
1491
|
+
// marker write must not rethrow — the former rethrow skipped the
|
|
1492
|
+
// audit/event accounting AND made every retry hit "already exists"
|
|
1493
|
+
// with no repair path, leaving the skill permanently un-managed.
|
|
1494
|
+
// Both failure kinds now ride the normal success path: a
|
|
1495
|
+
// committed-only failure means the marker LANDED (durability
|
|
1496
|
+
// unconfirmed); any other failure means the skill stays un-managed,
|
|
1497
|
+
// which the result text says out loud.
|
|
1498
|
+
if (isCommittedOnly(error)) {
|
|
1499
|
+
markerWarning = `hermes-managed 标记已落盘,但其目录 fsync 失败 —— 持久性未确认:${error instanceof Error ? error.message : String(error)}`;
|
|
1500
|
+
}
|
|
1501
|
+
else {
|
|
1502
|
+
markerWarning = `hermes-managed marker write failed (${error instanceof Error ? error.message : String(error)}); the skill landed but is NOT lifecycle-managed — recreate or pin it manually`;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
if (!(await this.io.exists(createPath))) {
|
|
1506
|
+
await this.io.remove(markerPath(dir, 'hermes-managed')).catch(() => { });
|
|
1507
|
+
// v23 (BR-5) + v29 SK-06: same compensating cleanup as setPinnedCore —
|
|
1508
|
+
// routed through the shared `cleanupGhostDir` so a stale lock file (a
|
|
1509
|
+
// failed lock release) no longer defeats the empty-dir removal.
|
|
1510
|
+
await this.cleanupGhostDir(createPath);
|
|
1511
|
+
return { ok: false, message: `创建过程中技能「${normalized}」被并发归档;残留标记已移除:等搬运完成后重试一次。` };
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
await this.audit(normalized, 'create', null, onDisk, 'created');
|
|
1515
|
+
this.notifyMutation({ action: 'create', name: normalized, skillDir: dir });
|
|
1516
|
+
// Both warnings ride the normal success path (audit/event included) — a
|
|
1517
|
+
// durability warning must never skip the accounting.
|
|
1518
|
+
const warnings = [
|
|
1519
|
+
createDurabilityWarning !== '' ? `写入已落盘,但目录 fsync 失败 —— 持久性未确认:${createDurabilityWarning}` : '',
|
|
1520
|
+
markerWarning,
|
|
1521
|
+
].filter(warning => warning !== '');
|
|
1522
|
+
return {
|
|
1523
|
+
ok: true,
|
|
1524
|
+
message: `已创建技能「${normalized}」。${warnings.length === 0 ? '' : ` (警告:${warnings.join(';')})`}`,
|
|
1525
|
+
path: dir,
|
|
1526
|
+
...(norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}),
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
async update(rawName, content, origin = this.authority.origin, anchor) {
|
|
1530
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
1531
|
+
const name = rawName.trim();
|
|
1532
|
+
// F-208: the whole read→validate→write runs under the in-process serialize
|
|
1533
|
+
// queue so two concurrent updates on one skill never interleave.
|
|
1534
|
+
return await this.serial(() => this.updateCore(name, content, origin, anchor));
|
|
1535
|
+
}
|
|
1536
|
+
async updateCore(name, content, origin, anchor) {
|
|
1537
|
+
// P3 (v15): guard BEFORE dirOf — same order as patchCore, so no path
|
|
1538
|
+
// string is ever built from an unvalidated name (dirOf does not touch IO,
|
|
1539
|
+
// so this is consistency hygiene, not a reachable gap).
|
|
1540
|
+
const badName = this.badName(name);
|
|
1541
|
+
if (badName)
|
|
1542
|
+
return { ok: false, message: badName };
|
|
1543
|
+
const dir = this.dirOf(name);
|
|
1544
|
+
const path = join(dir, 'SKILL.md');
|
|
1545
|
+
const protection = await this.writeProtection(name, origin);
|
|
1546
|
+
if (protection)
|
|
1547
|
+
return { ok: false, message: `技能「${name}」受保护(${protection})。` };
|
|
1548
|
+
// S1.2: a lock-free read feeds only the shrink exemption below; the commit
|
|
1549
|
+
// still runs inside the write transaction with its own locked read.
|
|
1550
|
+
// N36: three-state. "Could not read" answers `null` like "no file yet", which is the CONSERVATIVE
|
|
1551
|
+
// direction here (an already-over-limit body loses the repair exemption and is refused, never
|
|
1552
|
+
// accepted), and the reason is named.
|
|
1553
|
+
const limitProbe = await probeText(this.io, path);
|
|
1554
|
+
if (isUnknown(limitProbe))
|
|
1555
|
+
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`);
|
|
1556
|
+
const currentForLimit = isPresent(limitProbe) ? limitProbe.value : null;
|
|
1557
|
+
const validation = validateFrontmatter(content, name, this.limits, currentForLimit);
|
|
1558
|
+
if (validation)
|
|
1559
|
+
return { ok: false, message: validation };
|
|
1560
|
+
// 0.3.11: normalize at the write point (see create) — same reasoning.
|
|
1561
|
+
const norm = normalizeFrontmatter(content);
|
|
1562
|
+
if (norm.issues.length > 0)
|
|
1563
|
+
return { ok: false, message: `技能「${name}」的 frontmatter 无法自动修复:${norm.issues[0]}:请用双引号包住取值后重试。` };
|
|
1564
|
+
const finalContent = norm.changed ? norm.content : content;
|
|
1565
|
+
if (norm.changed) {
|
|
1566
|
+
const revalidated = validateFrontmatter(finalContent, name, this.limits, currentForLimit);
|
|
1567
|
+
if (revalidated)
|
|
1568
|
+
return { ok: false, message: revalidated };
|
|
1569
|
+
}
|
|
1570
|
+
const threat = this.contentThreatBlock(finalContent);
|
|
1571
|
+
if (threat)
|
|
1572
|
+
return { ok: false, message: threat };
|
|
1573
|
+
return await this.runSingleWrite(path, (current) => {
|
|
1574
|
+
// v35 C9: the stage-time anchor is checked against THIS locked read, so the
|
|
1575
|
+
// compare and the commit share one lock (the caller's former pre-read left
|
|
1576
|
+
// a window where both writers could report success).
|
|
1577
|
+
const verdict = anchorVerdict(anchor, current);
|
|
1578
|
+
if (verdict !== 'match')
|
|
1579
|
+
return { result: anchorRefusal(name, verdict), write: null };
|
|
1580
|
+
if (current === null)
|
|
1581
|
+
return { result: { ok: false, message: `技能「${name}」不存在。` }, write: null };
|
|
1582
|
+
// F-318 (②): a byte-equivalent rewrite (same content modulo trailing
|
|
1583
|
+
// newlines) is a no-op — no write, no audit, no mutation event, so the
|
|
1584
|
+
// mutation-maturity counter is not inflated by a redundant re-save.
|
|
1585
|
+
if (finalContent.trimEnd() === current.trimEnd()) {
|
|
1586
|
+
return { result: { ok: true, message: `技能「${name}」未变更:提供的内容与当前文件一致,未写入。`, noop: true, path: dir }, write: null };
|
|
1587
|
+
}
|
|
1588
|
+
// F-337: hash the BYTES that actually land on disk (write uses
|
|
1589
|
+
// trimEnd()+'\n'), so the audit afterHash is replay-identical to the file.
|
|
1590
|
+
const onDisk = finalContent.trimEnd() + '\n';
|
|
1591
|
+
return {
|
|
1592
|
+
result: { ok: true, message: `技能「${name}」已更新。`, path: dir, ...(norm.changed ? { normalizedFrontmatterFields: norm.fields } : {}) },
|
|
1593
|
+
write: onDisk,
|
|
1594
|
+
audit: { skillName: name, action: 'update', before: current, after: onDisk, summary: 'updated' },
|
|
1595
|
+
event: { action: 'update', name, skillDir: dir },
|
|
1596
|
+
};
|
|
1597
|
+
}, anchor !== undefined ? () => anchorUnverifiable(name, null) : error => unreadableTarget(name, error));
|
|
1598
|
+
}
|
|
1599
|
+
async patch(rawName, oldString, newString, filePath = '', replaceAll = false, origin = this.authority.origin) {
|
|
1600
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
1601
|
+
const name = rawName.trim();
|
|
1602
|
+
// F-208: the whole read→patch→write runs under the in-process serialize
|
|
1603
|
+
// queue so two concurrent patches on one skill never interleave.
|
|
1604
|
+
return await this.serial(() => this.patchCore(name, oldString, newString, filePath, replaceAll, origin));
|
|
1605
|
+
}
|
|
1606
|
+
async patchCore(name, oldString, newString, filePath, replaceAll, origin) {
|
|
1607
|
+
// P1-1 (v14): patch was the ONE path-building mutator without the name
|
|
1608
|
+
// guard — `name = '../outside'` plus a `file_path` escaped the skills root
|
|
1609
|
+
// and rewrote an existing support file there (the SKILL.md branch was
|
|
1610
|
+
// saved only by the frontmatter name equality check, the support-file
|
|
1611
|
+
// branch had no such second line of defence).
|
|
1612
|
+
const badName = this.badName(name);
|
|
1613
|
+
if (badName)
|
|
1614
|
+
return { ok: false, message: badName };
|
|
1615
|
+
const dir = this.dirOf(name);
|
|
1616
|
+
const skillMd = join(dir, 'SKILL.md');
|
|
1617
|
+
if (!await this.io.exists(skillMd))
|
|
1618
|
+
return { ok: false, message: `技能「${name}」不存在。` };
|
|
1619
|
+
const protection = await this.writeProtection(name, origin);
|
|
1620
|
+
if (protection)
|
|
1621
|
+
return { ok: false, message: `技能「${name}」受保护(${protection})。` };
|
|
1622
|
+
let target = skillMd;
|
|
1623
|
+
let patchLabel = 'SKILL.md';
|
|
1624
|
+
if (filePath) {
|
|
1625
|
+
const validation = validateSupportPath(filePath);
|
|
1626
|
+
if (validation)
|
|
1627
|
+
return { ok: false, message: validation };
|
|
1628
|
+
target = join(dir, ...filePath.replace(/\\/g, '/').split('/').filter(Boolean));
|
|
1629
|
+
patchLabel = filePath;
|
|
1630
|
+
}
|
|
1631
|
+
return await this.runSingleWrite(target, (current) => {
|
|
1632
|
+
const md = current;
|
|
1633
|
+
if (md === null)
|
|
1634
|
+
return { result: { ok: false, message: `文件不存在:${patchLabel}` }, write: null };
|
|
1635
|
+
// V6-17 (0.3.37): the fuzzy scan is O(n·m) with no input bound (a
|
|
1636
|
+
// model-supplied 20k anchor over a 20k file measured ~6s with the event
|
|
1637
|
+
// loop blocked). The exact `includes` path served an exact hit already;
|
|
1638
|
+
// a NON-exact anchor past the budget is refused with an honest message.
|
|
1639
|
+
if (!md.includes(oldString) && (oldString.length > FUZZY_MAX_PATTERN_CHARS || md.length * oldString.length > FUZZY_MAX_WORK)) {
|
|
1640
|
+
return { result: { ok: false, message: `old_string too large for fuzzy match (${oldString.length} chars in ${patchLabel}); use update for a full rewrite or a narrower anchor.` }, write: null };
|
|
1641
|
+
}
|
|
1642
|
+
// P3-30 (v14): an empty or whitespace-only anchor is refused by the
|
|
1643
|
+
// fuzzy boundary, but the caller used to report it as "Could not find
|
|
1644
|
+
// old_string" — indistinguishable from a genuine miss. Name the real
|
|
1645
|
+
// reason so the model retries with an anchor instead of rewriting.
|
|
1646
|
+
if (oldString === '' || trimPatternBoundaries(oldString) === '') {
|
|
1647
|
+
return { result: { ok: false, message: `old_string is empty (or whitespace-only) — a patch of "${name}/${patchLabel}" needs an anchor; use update for a full rewrite.` }, write: null };
|
|
1648
|
+
}
|
|
1649
|
+
const patched = fuzzyPatch(md, oldString, newString, replaceAll);
|
|
1650
|
+
// `null` means "no match" or (V7-11) the replaceAll loop exceeded the
|
|
1651
|
+
// cumulative fuzzy budget; an empty string is a legitimate replacement.
|
|
1652
|
+
if (patched === null)
|
|
1653
|
+
return { result: { ok: false, message: `在「${name}/${patchLabel}」里找不到 old_string(或 replaceAll 的模糊匹配预算已用尽)。整篇重写请改用 update。` }, write: null };
|
|
1654
|
+
// V27 G5.1: a first-occurrence-only patch is the silent case the tool
|
|
1655
|
+
// description used to omit — report how many anchors the file carries, so
|
|
1656
|
+
// a multi-site edit is never half-applied without the model noticing.
|
|
1657
|
+
const anchorCount = oldString === '' ? 0 : md.split(oldString).length - 1;
|
|
1658
|
+
const firstOnly = !replaceAll && anchorCount > 1;
|
|
1659
|
+
const patchNote = firstOnly ? ` Replaced the FIRST of ${anchorCount} occurrences; pass replace_all=true to change every one.` : '';
|
|
1660
|
+
let writeContent = patched;
|
|
1661
|
+
let normalizedFields;
|
|
1662
|
+
if (target === skillMd) {
|
|
1663
|
+
const validation = validateFrontmatter(patched, name, this.limits, md);
|
|
1664
|
+
if (validation)
|
|
1665
|
+
return { result: { ok: false, message: `补丁被拒绝:${validation}` }, write: null };
|
|
1666
|
+
// 0.3.11: normalize at the write point (see create) — a patch may edit
|
|
1667
|
+
// the frontmatter directly.
|
|
1668
|
+
const norm = normalizeFrontmatter(patched);
|
|
1669
|
+
if (norm.issues.length > 0)
|
|
1670
|
+
return { result: { ok: false, message: `补丁被拒绝:frontmatter 无法自动修复(${norm.issues[0]})。` }, write: null };
|
|
1671
|
+
if (norm.changed) {
|
|
1672
|
+
writeContent = norm.content;
|
|
1673
|
+
normalizedFields = norm.fields;
|
|
1674
|
+
const revalidated = validateFrontmatter(writeContent, name, this.limits, md);
|
|
1675
|
+
if (revalidated)
|
|
1676
|
+
return { result: { ok: false, message: `补丁被拒绝:${revalidated}` }, write: null };
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
if (Buffer.byteLength(writeContent, 'utf8') > this.limits.maxSkillFileBytes && target !== skillMd) {
|
|
1680
|
+
return { result: { ok: false, message: `修补后的文件超过 ${this.limits.maxSkillFileBytes} 字节。` }, write: null };
|
|
1681
|
+
}
|
|
1682
|
+
const overLimit = exceedsContentLimit(writeContent, this.limits.maxSkillContentChars);
|
|
1683
|
+
const repairing = shrinksOverLimit(writeContent, md, this.limits.maxSkillContentChars);
|
|
1684
|
+
if (overLimit && target === skillMd && !repairing) {
|
|
1685
|
+
return { result: { ok: false, message: `修补后的内容超过 ${this.limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}` }, write: null };
|
|
1686
|
+
}
|
|
1687
|
+
// V4: the same cap on a SUPPORT file. Report mode writes with an advisory;
|
|
1688
|
+
// enforce refuses unless the patch makes an already-over file smaller.
|
|
1689
|
+
const supportOverCap = overLimit && target !== skillMd && !repairing;
|
|
1690
|
+
const capAdvisory = supportOverCap
|
|
1691
|
+
? ` Warning: ${writeContent.length} characters is over the ${this.limits.maxSkillContentChars}-character cap upstream applies to every file; plan a split.`
|
|
1692
|
+
: '';
|
|
1693
|
+
if (supportOverCap && this.supportFileCharPolicy() === 'enforce') {
|
|
1694
|
+
return { result: { ok: false, message: `修补后的文件内容超过 ${this.limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}` }, write: null };
|
|
1695
|
+
}
|
|
1696
|
+
const threat = this.contentThreatBlock(writeContent);
|
|
1697
|
+
if (threat)
|
|
1698
|
+
return { result: { ok: false, message: threat }, write: null };
|
|
1699
|
+
// 0.3.18 (E-68): old_string === replacement reaches fuzzyPatch's exact
|
|
1700
|
+
// path and yields patched === md. Previously the file was rewritten, the
|
|
1701
|
+
// patch counter bumped and the whole catalog invalidated for zero change.
|
|
1702
|
+
// Byte-identical-to-write means a true no-op: skip write/audit/notify.
|
|
1703
|
+
// F-318 (①): normalize BOTH sides — a legacy file without a trailing
|
|
1704
|
+
// newline (or with multiple trailing newlines) used to fail this check for
|
|
1705
|
+
// an identical replacement, rewriting + auditing + invalidating the catalog
|
|
1706
|
+
// for zero change.
|
|
1707
|
+
if (writeContent.trimEnd() === md.trimEnd()) {
|
|
1708
|
+
return { result: { ok: true, message: `技能「${name}」未变更:old_string 与替换内容相同(${patchLabel}),未写入。`, noop: true, path: dir }, write: null };
|
|
1709
|
+
}
|
|
1710
|
+
// F-337: hash the bytes that actually land on disk (write uses
|
|
1711
|
+
// trimEnd()+'\n'), so the audit afterHash is replay-identical to the file.
|
|
1712
|
+
const onDisk = writeContent.trimEnd() + '\n';
|
|
1713
|
+
return {
|
|
1714
|
+
result: { ok: true, message: `技能「${name}」已修补(${patchLabel})。${patchNote}${capAdvisory}`, path: dir, ...(normalizedFields ? { normalizedFrontmatterFields: normalizedFields } : {}) },
|
|
1715
|
+
write: onDisk,
|
|
1716
|
+
audit: { skillName: name, action: 'patch', before: md, after: onDisk, summary: `patched ${patchLabel}` },
|
|
1717
|
+
event: { action: 'patch', name, skillDir: dir },
|
|
1718
|
+
};
|
|
1719
|
+
}, error => unreadableTarget(name + '/' + patchLabel, error));
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* P2-9 (v15): the destructive directory move shared by archive and
|
|
1723
|
+
* restoreFromArchive — rename first, copy+remove fallback when the backend
|
|
1724
|
+
* cannot rename across media (V5-35), with the E-14 rollback when the
|
|
1725
|
+
* fallback's source removal fails. Returns a failure MESSAGE on a failed
|
|
1726
|
+
* move (caller wraps into a structured result) or undefined on success.
|
|
1727
|
+
*/
|
|
1728
|
+
async moveDir(dir, dest) {
|
|
1729
|
+
try {
|
|
1730
|
+
await this.io.rename(dir, dest);
|
|
1731
|
+
return undefined;
|
|
1732
|
+
}
|
|
1733
|
+
catch {
|
|
1734
|
+
// P2 (v17): rename failing because the DESTINATION appeared mid-move
|
|
1735
|
+
// (concurrent create/restore on the same name) must NOT fall through to
|
|
1736
|
+
// the copy fallback — `cp force` merges trees, silently overwriting the
|
|
1737
|
+
// concurrent writer's bytes and reporting success. Refuse instead; the
|
|
1738
|
+
// fallback is only for genuine cross-media rename failures.
|
|
1739
|
+
if (await this.io.exists(dest)) {
|
|
1740
|
+
return 'the destination appeared mid-move (concurrent create or restore); refusing to merge — inspect both trees';
|
|
1741
|
+
}
|
|
1742
|
+
// Some IO providers cannot rename across media. Copy the whole tree
|
|
1743
|
+
// first so support files are never lost during archival fallback.
|
|
1744
|
+
// V5-35 (0.3.32): a concurrent archiver may have already moved the
|
|
1745
|
+
// source (or the rename failed for another transcient reason) — surface
|
|
1746
|
+
// the fallback failure as a result instead of a raw ENOENT.
|
|
1747
|
+
try {
|
|
1748
|
+
await this.io.copy(dir, dest);
|
|
1749
|
+
}
|
|
1750
|
+
catch (copyError) {
|
|
1751
|
+
return `the move fell back to copy but failed (${copyError instanceof Error ? copyError.message : String(copyError)}); the tree stays where it is`;
|
|
1752
|
+
}
|
|
1753
|
+
try {
|
|
1754
|
+
await this.io.remove(dir);
|
|
1755
|
+
return undefined;
|
|
1756
|
+
}
|
|
1757
|
+
catch (error) {
|
|
1758
|
+
// 0.3.16 (E-14): a failed remove left the tree in BOTH locations, and
|
|
1759
|
+
// the source was never counted as moved so a rollback loop would not
|
|
1760
|
+
// clean it. Undo the copy we just made; if even that fails, say so
|
|
1761
|
+
// instead of rethrowing the remove error.
|
|
1762
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1763
|
+
try {
|
|
1764
|
+
await this.io.remove(dest);
|
|
1765
|
+
return `复制成功,但源目录删不掉(${reason});已回滚复制出的目录树`;
|
|
1766
|
+
}
|
|
1767
|
+
catch {
|
|
1768
|
+
return `复制成功,但源目录删不掉(${reason}),且复制出的目录树也无法回滚 —— 该目录树现在同时存在于两处;请手动清理`;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* P2 (v16): the write-lock probe for the DESTRUCTIVE MOVERS (archive /
|
|
1775
|
+
* restoreFromArchive). A byte-writer mid-flight is the ghost-generator —
|
|
1776
|
+
* after the move its transact commit re-creates `<dir>/…` (mkdir
|
|
1777
|
+
* recursive) and the tree ends half-archived.
|
|
1778
|
+
* G2 S4: the writer-lock probe that used to answer this question is retired — writes go through
|
|
1779
|
+
* the provider's versioned writes, so a writer that raced the move fails its own write loudly
|
|
1780
|
+
* instead of landing on a moved-away path. What REMAINS load-bearing here is the fail-closed
|
|
1781
|
+
* listing rule, which is about the filesystem misbehaving rather than about writers.
|
|
1782
|
+
*/
|
|
1783
|
+
async supportDirsUnlistable(dir) {
|
|
1784
|
+
for (const supportDir of SUPPORT_DIRS) {
|
|
1785
|
+
try {
|
|
1786
|
+
await this.io.list(join(dir, supportDir));
|
|
1787
|
+
}
|
|
1788
|
+
catch {
|
|
1789
|
+
// P3 (v17), kept: fail-CLOSED — a real list failure (EACCES/EIO; a missing dir reads as []
|
|
1790
|
+
// per the seam contract) must refuse the move, not read as "nothing there".
|
|
1791
|
+
return true;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
return false;
|
|
1795
|
+
}
|
|
1796
|
+
/** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
|
|
1797
|
+
* only a single, non-traversing path component is safe. Dotfiles
|
|
1798
|
+
* (`.usage.json`) stay allowed — sidecars are legitimately dot-prefixed.
|
|
1799
|
+
* P2-4 (v19): a non-string entry (`skills: [123]`) is refused structurally
|
|
1800
|
+
* instead of throwing `name.includes is not a function`. */
|
|
1801
|
+
safeSnapshotEntryName(name) {
|
|
1802
|
+
if (typeof name !== 'string')
|
|
1803
|
+
return false;
|
|
1804
|
+
return name !== '' && name !== '.' && name !== '..'
|
|
1805
|
+
&& !name.includes('/') && !name.includes('\\')
|
|
1806
|
+
&& basename(name) === name;
|
|
1807
|
+
}
|
|
1808
|
+
async archive(rawName, options = {}, origin = this.authority.origin) {
|
|
1809
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
1810
|
+
const name = rawName.trim();
|
|
1811
|
+
const badName = this.badName(name, { allowReserved: true });
|
|
1812
|
+
if (badName)
|
|
1813
|
+
return { ok: false, message: badName };
|
|
1814
|
+
const dir = this.dirOf(name);
|
|
1815
|
+
const md = await this.io.readText(join(dir, 'SKILL.md'));
|
|
1816
|
+
// C-14: a 0-byte SKILL.md is "present but corrupt" — the tree is
|
|
1817
|
+
// still archivable (protection checks and the audit do not need body
|
|
1818
|
+
// bytes; the audit hashes the empty before-string). Only a genuinely
|
|
1819
|
+
// MISSING file reads as "not found".
|
|
1820
|
+
if (md === null)
|
|
1821
|
+
return { ok: false, message: `技能「${name}」不存在。` };
|
|
1822
|
+
// C-13 (v10 audit): the redundant ternary is gone — deleteProtection now
|
|
1823
|
+
// accepts an explicit `undefined` allowBundled, so the options object
|
|
1824
|
+
// passes through verbatim.
|
|
1825
|
+
const protection = await this.deleteProtection(name, options);
|
|
1826
|
+
if (protection) {
|
|
1827
|
+
return {
|
|
1828
|
+
ok: false,
|
|
1829
|
+
message: protection === 'pinned'
|
|
1830
|
+
? `Skill "${name}" is pinned and cannot be archived. Remove the \`.pinned\` marker in its directory, then retry.`
|
|
1831
|
+
: `技能「${name}」受保护(${protection})。`,
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
if (options.absorbedInto) {
|
|
1835
|
+
// 0.3.18 (E-69): `delete X absorbed_into=X` used to pass the existence
|
|
1836
|
+
// check (it read the archived skill's own still-present file) and
|
|
1837
|
+
// archive X "into itself". Refuse the self-absorption up front.
|
|
1838
|
+
if (options.absorbedInto.trim() === name) {
|
|
1839
|
+
return { ok: false, message: 'absorbed_into 不能是正在归档的技能本身(不能吸收到自己)。' };
|
|
1840
|
+
}
|
|
1841
|
+
// P2-8 (v15): absorbedInto reaches dirOf below — the same guard every
|
|
1842
|
+
// other path-building entry applies must hold here too, or
|
|
1843
|
+
// `absorbed_into: '../x'` builds a probe path outside the skills root
|
|
1844
|
+
// and the raw value lands in .archive-reason / the audit summary.
|
|
1845
|
+
const intoBad = this.badName(options.absorbedInto.trim());
|
|
1846
|
+
if (intoBad)
|
|
1847
|
+
return { ok: false, message: `absorbed_into: ${intoBad}` };
|
|
1848
|
+
const target = await this.io.readText(join(this.dirOf(options.absorbedInto), 'SKILL.md'));
|
|
1849
|
+
if (!target)
|
|
1850
|
+
return { ok: false, message: `absorbed_into="${options.absorbedInto}" does not exist.` };
|
|
1851
|
+
}
|
|
1852
|
+
const archiveRoot = join(this.root, '.archive');
|
|
1853
|
+
let dest = join(archiveRoot, name.trim());
|
|
1854
|
+
if (await this.io.exists(dest)) {
|
|
1855
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
1856
|
+
dest = join(archiveRoot, `${name.trim()}-${stamp}`);
|
|
1857
|
+
// Stamp collisions within one second (N-6): two re-archives of the same
|
|
1858
|
+
// name in the same second used to share one stamped destination and
|
|
1859
|
+
// overwrite each other. Keep probing with a random suffix, mirroring the
|
|
1860
|
+
// snapshotAll() collision guard.
|
|
1861
|
+
while (await this.io.exists(dest)) {
|
|
1862
|
+
dest = join(archiveRoot, `${name.trim()}-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
// Symlink guard (G7): moving a symlinked tree would relocate the link, not
|
|
1866
|
+
// the content it points at — refuse before the rename instead.
|
|
1867
|
+
if (this.io.isSymlink) {
|
|
1868
|
+
const link = await this.io.isSymlink(dir);
|
|
1869
|
+
if (link === true)
|
|
1870
|
+
return { ok: false, message: `技能「${name}」是符号链接;拒绝归档。` };
|
|
1871
|
+
}
|
|
1872
|
+
// G2 S4: refuse when a support directory cannot be LISTED (fail-closed — see
|
|
1873
|
+
// `supportDirsUnlistable`). The writer probe that used to stand here is retired: writes are
|
|
1874
|
+
// versioned, so a writer that raced this move fails its own write instead of landing on a
|
|
1875
|
+
// moved-away path.
|
|
1876
|
+
if (await this.supportDirsUnlistable(dir)) {
|
|
1877
|
+
return { ok: false, message: `无法检查技能「${name}」是否有写入方(有一个支撑目录列不出来):请重试归档。` };
|
|
1878
|
+
}
|
|
1879
|
+
// OPT-06 (2026-09): pin/protection RE-CHECK at the commit point. The
|
|
1880
|
+
// protection probe near the top runs several awaits before the move; a
|
|
1881
|
+
// `.pinned` marker landing in that window used to be archived anyway
|
|
1882
|
+
// (the same race A1-14 closed for update by holding the serial chain —
|
|
1883
|
+
// archive cannot take that chain because consolidate calls it from
|
|
1884
|
+
// inside one). This re-check shrinks the window to the single gap
|
|
1885
|
+
// before moveDir; fully closing it would need check-under-lock semantics
|
|
1886
|
+
// in the io.ts mover protocol, which is deliberately out of scope here.
|
|
1887
|
+
const commitProtection = await this.deleteProtection(name, options);
|
|
1888
|
+
if (commitProtection) {
|
|
1889
|
+
return {
|
|
1890
|
+
ok: false,
|
|
1891
|
+
message: commitProtection === 'pinned'
|
|
1892
|
+
? `Skill "${name}" is pinned and cannot be archived. Remove the \`.pinned\` marker in its directory, then retry.`
|
|
1893
|
+
: `技能「${name}」受保护(${commitProtection})。`,
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
const moveFailure = await this.moveDir(dir, dest);
|
|
1897
|
+
if (moveFailure !== undefined)
|
|
1898
|
+
return { ok: false, message: `技能「${name}」归档失败:${moveFailure}。` };
|
|
1899
|
+
const reason = options.reason ?? (options.absorbedInto ? `Consolidated into ${options.absorbedInto}` : 'Archived by self-evolution curator');
|
|
1900
|
+
// P2-9 (v14): the move already landed, so a failed metadata write must NOT
|
|
1901
|
+
// abort the archive — the caller (consolidate) would then report a rollback
|
|
1902
|
+
// it cannot perform while the tree stays archived, and audit/notify below
|
|
1903
|
+
// would be skipped entirely. The reason file is metadata; best-effort like
|
|
1904
|
+
// `audit()`.
|
|
1905
|
+
try {
|
|
1906
|
+
await this.io.writeText(join(dest, '.archive-reason'), `${new Date().toISOString()}: ${reason}\n`);
|
|
1907
|
+
}
|
|
1908
|
+
catch {
|
|
1909
|
+
// The archive itself succeeded; only the human-readable reason is missing.
|
|
1910
|
+
}
|
|
1911
|
+
// V27 G8.4: record the owning skill name next to the archived tree. The
|
|
1912
|
+
// restore match used to depend on the archived SKILL.md's frontmatter, so an
|
|
1913
|
+
// entry whose SKILL.md is 0 bytes (or whose frontmatter no longer parses)
|
|
1914
|
+
// was unrecoverable by name even though its directory says which skill it
|
|
1915
|
+
// was. Best-effort like the reason marker: the archive is already complete.
|
|
1916
|
+
try {
|
|
1917
|
+
await this.io.writeText(join(dest, '.archive-name'), `${name}\n`);
|
|
1918
|
+
}
|
|
1919
|
+
catch {
|
|
1920
|
+
// Same posture as .archive-reason: metadata only, never abort the archive.
|
|
1921
|
+
}
|
|
1922
|
+
await this.audit(name, 'archive', md, null, reason, undefined, undefined, undefined, origin);
|
|
1923
|
+
this.notifyMutation({ action: 'archive', name, archivedPath: dest });
|
|
1924
|
+
return { ok: true, message: `技能「${name}」已归档到 .archive。`, path: dest };
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* V2 (design §16.7): what an apply-mode consolidation would do for ONE source,
|
|
1928
|
+
* computed BEFORE any side effect. `blocked` means behaviour is exactly the
|
|
1929
|
+
* plan-mode refusal (with the reason named); `apply` carries the rewritten body
|
|
1930
|
+
* and the support files that must be copied into the target.
|
|
1931
|
+
* @param source - the moving skill's name.
|
|
1932
|
+
* @param targetName - the destination skill's name.
|
|
1933
|
+
* @param body - the moving body (frontmatter stripped).
|
|
1934
|
+
* @returns the decision plus the note to append to a refusal.
|
|
1935
|
+
*/
|
|
1936
|
+
async planSourceRehoming(source, targetName, body, extraTargetFiles = []) {
|
|
1937
|
+
const policy = this.limits.referenceRewrite ?? DEFAULT_REFERENCE_REWRITE_POLICY;
|
|
1938
|
+
if (policy === 'off')
|
|
1939
|
+
return { kind: 'blocked', note: '' };
|
|
1940
|
+
const sourceFiles = await this.listSupportFiles(source);
|
|
1941
|
+
const targetFiles = await this.listSupportFiles(targetName);
|
|
1942
|
+
if (!isPresent(sourceFiles) || !isPresent(targetFiles))
|
|
1943
|
+
return { kind: 'blocked', note: ' plan: unavailable (support-file listing unknown)' };
|
|
1944
|
+
// Already-planned destinations count as taken, so two sources cannot both
|
|
1945
|
+
// claim the same re-homed path inside one run.
|
|
1946
|
+
const { moves, collisions } = planRehoming(sourceFiles.value, [...targetFiles.value, ...extraTargetFiles], source);
|
|
1947
|
+
const plan = planReferenceRewrite({
|
|
1948
|
+
content: body,
|
|
1949
|
+
files: sourceFiles.value,
|
|
1950
|
+
moves,
|
|
1951
|
+
targetFiles: [...targetFiles.value, ...extraTargetFiles, ...moves.map(move => move.to)],
|
|
1952
|
+
});
|
|
1953
|
+
const described = ` ${describeReferenceRewrite(plan)}${collisionsNote(collisions)}`;
|
|
1954
|
+
if (policy === 'plan')
|
|
1955
|
+
return { kind: 'blocked', note: described };
|
|
1956
|
+
// apply: the plan must PROVE it leaves nothing dangling, otherwise the merge
|
|
1957
|
+
// is refused exactly like `plan` (the refusal names what is in the way).
|
|
1958
|
+
if (plan.residualDangling.length > 0 || plan.unresolved.length > 0) {
|
|
1959
|
+
return { kind: 'blocked', note: `${described} (cannot apply)` };
|
|
1960
|
+
}
|
|
1961
|
+
const files = [];
|
|
1962
|
+
for (const move of plan.moves) {
|
|
1963
|
+
const bytes = await this.io.readText(join(this.dirOf(source), ...move.from.split('/')));
|
|
1964
|
+
// An unreadable source file is a hard stop: copying nothing would leave the
|
|
1965
|
+
// rewritten reference dangling.
|
|
1966
|
+
if (bytes === null)
|
|
1967
|
+
return { kind: 'blocked', note: ` plan: unavailable (cannot read ${move.from})` };
|
|
1968
|
+
files.push({ from: move.from, to: move.to, content: bytes });
|
|
1969
|
+
}
|
|
1970
|
+
return { kind: 'apply', note: described, rewritten: applyReferenceRewrite(body, plan.edits), files };
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Merge the bodies of `sources` into `target` and archive the sources with
|
|
1974
|
+
* an absorbed-into marker. Hermes-style consolidation: overlapping skills
|
|
1975
|
+
* collapse into one, and the originals stay recoverable under `.archive/`.
|
|
1976
|
+
*
|
|
1977
|
+
* `mode:'append'` (default) appends each source body to the target. The
|
|
1978
|
+
* target write goes through the tree-change kernel (009) — byte-level
|
|
1979
|
+
* rollback, audit and the mutation event are kernel-owned. Package-integrity
|
|
1980
|
+
* (009-I): append-mode consolidation REFUSES a source whose directory has
|
|
1981
|
+
* support files or whose body carries support-directory links — an append
|
|
1982
|
+
* would leave those references pointing at an archived package (dangling);
|
|
1983
|
+
* the refusal message directs to the reference mode / whole-package archive.
|
|
1984
|
+
*
|
|
1985
|
+
* `mode:'reference'` writes each source's body (frontmatter stripped) into
|
|
1986
|
+
* `target/references/<source>.md` and archives the source — the demote path
|
|
1987
|
+
* (009-II). A source body with support-directory links is refused there too
|
|
1988
|
+
* (the references file would carry links whose files were archived).
|
|
1989
|
+
*
|
|
1990
|
+
* V2 (design §16.7) lifts that refusal when `referenceRewrite:'apply'` and the
|
|
1991
|
+
* plan proves the move is safe: the support files the body NEEDS are copied into
|
|
1992
|
+
* the target under their own paths (renamed only on a collision) and the body's
|
|
1993
|
+
* references are rewritten to match, in the same commit. Anything the plan
|
|
1994
|
+
* cannot place — a cited file the source no longer has, an unreadable listing —
|
|
1995
|
+
* still refuses, and the refusal names it. The archived source keeps whatever
|
|
1996
|
+
* the body did not need.
|
|
1997
|
+
*/
|
|
1998
|
+
async consolidate(target, sources, origin = this.authority.origin, options = {}) {
|
|
1999
|
+
// Names normalize before validation (rc.42 audit P2-5): the trimmed form
|
|
2000
|
+
// is what every path-building call below resolves to, so validation and
|
|
2001
|
+
// IO can never disagree about which skill is meant.
|
|
2002
|
+
const targetName = target.trim();
|
|
2003
|
+
const normalizedSources = [...new Set(sources.map(name => name.trim()))].filter(name => name !== targetName);
|
|
2004
|
+
const mode = options.mode ?? 'append';
|
|
2005
|
+
if (normalizedSources.length === 0)
|
|
2006
|
+
return { ok: false, message: '合并至少需要一个互不相同的来源技能。' };
|
|
2007
|
+
for (const name of [targetName, ...normalizedSources]) {
|
|
2008
|
+
// C-16: the name guard is badName() now — consolidate gains the
|
|
2009
|
+
// same 64-char length ceiling and message form as every other mutator.
|
|
2010
|
+
const bad = this.badName(name);
|
|
2011
|
+
if (bad)
|
|
2012
|
+
return { ok: false, message: bad };
|
|
2013
|
+
}
|
|
2014
|
+
const targetDir = this.dirOf(targetName);
|
|
2015
|
+
const targetProtection = await this.writeProtection(targetName, origin);
|
|
2016
|
+
if (targetProtection)
|
|
2017
|
+
return { ok: false, message: `技能「${targetName}」受保护(${targetProtection})。` };
|
|
2018
|
+
// V8-11 (0.3.46): the targetMd pre-read below MOVED into the serial queue
|
|
2019
|
+
// (commit section) — a concurrent patch between the old pre-read and the
|
|
2020
|
+
// commit used to be silently overwritten by the merged body.
|
|
2021
|
+
// OPT-04 (2026-09): the ENTIRE mutation phase — source reads, source
|
|
2022
|
+
// archives, target merge, commit and rollback — now runs inside the
|
|
2023
|
+
// in-process serialize queue (the same wrapper restructure uses). Before,
|
|
2024
|
+
// the source bodies were read OUTSIDE the queue and the commit reused
|
|
2025
|
+
// those stale bytes: a patch landing on a SOURCE between its read and its
|
|
2026
|
+
// archive was silently dropped from the merged output (lost update — the
|
|
2027
|
+
// same defect class V8-11/V24-01 closed for the target side only, now
|
|
2028
|
+
// closed for the sources too). archive()/restoreFromArchive() take no
|
|
2029
|
+
// serial lock themselves, so the nested calls here cannot self-deadlock.
|
|
2030
|
+
// Every refusal below (protection, missing skill, package integrity)
|
|
2031
|
+
// still fires before ANY side effect, so the pre-side-effect refusal
|
|
2032
|
+
// semantics are unchanged.
|
|
2033
|
+
return await this.serial(async () => {
|
|
2034
|
+
const referenceWrites = [];
|
|
2035
|
+
// V2 (design §16.7): support files copied into the target from an apply-mode
|
|
2036
|
+
// source, staged before any side effect and committed in the same tree change.
|
|
2037
|
+
const rehomed = [];
|
|
2038
|
+
// V3: one pointer block per demoted source (pointer line + link list).
|
|
2039
|
+
const demotedPointers = [];
|
|
2040
|
+
const parts = [];
|
|
2041
|
+
// S1-E5: the plan-time bytes per source, re-verified right before that
|
|
2042
|
+
// source is archived. The serial queue closes the read→merge window for
|
|
2043
|
+
// THIS process only; a cross-process writer (shared DSH_HOME, transact
|
|
2044
|
+
// is a supported deployment) could still land a patch between the plan
|
|
2045
|
+
// read and the archive — the OPT-04 "closed for the sources too" claim
|
|
2046
|
+
// was true only in-process. A mismatch aborts through the rollback below.
|
|
2047
|
+
const plannedSourceBytes = new Map();
|
|
2048
|
+
if (mode === 'append') {
|
|
2049
|
+
for (const source of normalizedSources) {
|
|
2050
|
+
const protection = await this.deleteProtection(source);
|
|
2051
|
+
if (protection)
|
|
2052
|
+
return { ok: false, message: `技能「${source}」受保护(${protection})。` };
|
|
2053
|
+
const sourceMd = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
|
|
2054
|
+
if (!sourceMd)
|
|
2055
|
+
return { ok: false, message: `技能「${source}」不存在。` };
|
|
2056
|
+
const parsed = parseFrontmatter(sourceMd);
|
|
2057
|
+
if (!parsed)
|
|
2058
|
+
return { ok: false, message: `技能「${source}」没有有效的 frontmatter;拒绝合并。` };
|
|
2059
|
+
// Package integrity (009-I): an append must never leave dangling
|
|
2060
|
+
// support links — refuse before ANY side effect (no archive, no write).
|
|
2061
|
+
const carriesSupport = await this.countSupportDirs(source) > 0;
|
|
2062
|
+
const refs = supportRefs(parsed.body);
|
|
2063
|
+
let appendedBody = parsed.body;
|
|
2064
|
+
if (carriesSupport || refs.length > 0) {
|
|
2065
|
+
const decision = await this.planSourceRehoming(source, targetName, parsed.body, rehomed.map(file => file.to));
|
|
2066
|
+
if (decision.kind === 'blocked') {
|
|
2067
|
+
const reason = carriesSupport
|
|
2068
|
+
? `source "${source}" carries support files`
|
|
2069
|
+
: `source "${source}" body references support files (${refs.join(', ')})`;
|
|
2070
|
+
return { ok: false, message: `合并被拒绝:${reason} 会被落下。请改用 mode:'reference',或直接归档整个包。${decision.note}` };
|
|
2071
|
+
}
|
|
2072
|
+
rehomed.push(...decision.files);
|
|
2073
|
+
appendedBody = decision.rewritten;
|
|
2074
|
+
}
|
|
2075
|
+
parts.push(`\n<!-- consolidated from ${source} at ${new Date().toISOString()} -->\n${appendedBody.trim()}`);
|
|
2076
|
+
plannedSourceBytes.set(source, sourceMd);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
else {
|
|
2080
|
+
for (const source of normalizedSources) {
|
|
2081
|
+
const protection = await this.deleteProtection(source);
|
|
2082
|
+
if (protection)
|
|
2083
|
+
return { ok: false, message: `技能「${source}」受保护(${protection})。` };
|
|
2084
|
+
const sourceMd = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
|
|
2085
|
+
if (!sourceMd)
|
|
2086
|
+
return { ok: false, message: `技能「${source}」不存在。` };
|
|
2087
|
+
const parsed = parseFrontmatter(sourceMd);
|
|
2088
|
+
if (!parsed)
|
|
2089
|
+
return { ok: false, message: `技能「${source}」没有有效的 frontmatter;拒绝降级。` };
|
|
2090
|
+
const refs = supportRefs(parsed.body);
|
|
2091
|
+
let demotedBody = parsed.body;
|
|
2092
|
+
if (refs.length > 0) {
|
|
2093
|
+
const decision = await this.planSourceRehoming(source, targetName, parsed.body, rehomed.map(file => file.to));
|
|
2094
|
+
if (decision.kind === 'blocked') {
|
|
2095
|
+
return { ok: false, message: `合并被拒绝:来源技能「${source}」的正文引用了会被落下的支撑文件(${refs.join(', ')}):请改为归档整个包。${decision.note}` };
|
|
2096
|
+
}
|
|
2097
|
+
rehomed.push(...decision.files);
|
|
2098
|
+
demotedBody = decision.rewritten;
|
|
2099
|
+
}
|
|
2100
|
+
const target = join(targetDir, 'references', `${source}.md`);
|
|
2101
|
+
referenceWrites.push({ target, content: `<!-- demoted from ${source} at ${new Date().toISOString()} -->\n${demotedBody.trim()}\n` });
|
|
2102
|
+
// Same convention as restructure: the pointer carries the link list of
|
|
2103
|
+
// what the demoted body cited, so the target body keeps mentioning them.
|
|
2104
|
+
demotedPointers.push(pointerBlock(`references/${source}.md`, demotedBody));
|
|
2105
|
+
plannedSourceBytes.set(source, sourceMd);
|
|
2106
|
+
}
|
|
2107
|
+
// Discoverability: the umbrella's body gains one pointer per demoted
|
|
2108
|
+
// source — built INSIDE the serial queue below against the fresh target
|
|
2109
|
+
// (V8-11), so it never merges over a concurrent patch.
|
|
2110
|
+
}
|
|
2111
|
+
// Two-phase commit so a failure partway never leaves the tree inconsistent:
|
|
2112
|
+
// (1) archive every source first — a source that cannot be archived aborts
|
|
2113
|
+
// before target is touched; (2) only when all sources are safely in
|
|
2114
|
+
// .archive does the kernel commit the writes (byte-level rollback).
|
|
2115
|
+
const archived = [];
|
|
2116
|
+
try {
|
|
2117
|
+
// V9-04 (0.3.50): restore the cheap target-existence pre-check BEFORE
|
|
2118
|
+
// the destructive archive loop — V8-11 moved the authoritative read into
|
|
2119
|
+
// the serial queue, which silently turned a clean "Skill not found"
|
|
2120
|
+
// refusal into "archive ALL sources, then roll back" (a half-completed
|
|
2121
|
+
// tree when a restore fails). The serial read stays authoritative for
|
|
2122
|
+
// the merge; this probe keeps the failure path non-destructive.
|
|
2123
|
+
const preTargetMd = await this.io.readText(join(targetDir, 'SKILL.md'));
|
|
2124
|
+
if (!preTargetMd)
|
|
2125
|
+
return { ok: false, message: `技能「${targetName}」不存在。` };
|
|
2126
|
+
for (const source of normalizedSources) {
|
|
2127
|
+
// S1-E5: verify the plan-time bytes right before THIS source is
|
|
2128
|
+
// archived. A mismatch throws into the rollback below, so sources
|
|
2129
|
+
// archived before the abort are brought back and nothing lands —
|
|
2130
|
+
// the cross-process gap the serial queue cannot close.
|
|
2131
|
+
if (plannedSourceBytes.has(source)) {
|
|
2132
|
+
const freshSource = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
|
|
2133
|
+
if (freshSource !== plannedSourceBytes.get(source)) {
|
|
2134
|
+
throw new Error(`Consolidation aborted: source "${source}" changed while the consolidation ran (its planned bytes are no longer current).`);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
const result = await this.archive(source, { absorbedInto: targetName });
|
|
2138
|
+
if (!result.ok)
|
|
2139
|
+
throw new Error(result.message);
|
|
2140
|
+
archived.push(source);
|
|
2141
|
+
}
|
|
2142
|
+
// V8-11 (0.3.46): the target pre-read and the merged/pointer construction
|
|
2143
|
+
// happen INSIDE the in-process serialize queue — a concurrent
|
|
2144
|
+
// patch/update between the old pre-read and the commit used to be
|
|
2145
|
+
// silently overwritten (the serial chain is the same second layer
|
|
2146
|
+
// update/patch/restructure/writeSupportFile use).
|
|
2147
|
+
const freshTargetMd = await this.io.readText(join(targetDir, 'SKILL.md'));
|
|
2148
|
+
// S1-E1: everything below runs AFTER the sources are already archived —
|
|
2149
|
+
// these three exits used to `return`, which skips the catch's rollback
|
|
2150
|
+
// (JS: return does not pass through catch) and left every source in
|
|
2151
|
+
// .archive while the message implied nothing had happened. Throw so
|
|
2152
|
+
// the T-14 rollback below brings them back; the catch prefixes the
|
|
2153
|
+
// reason with the rollback outcome, so the reasons here name only the
|
|
2154
|
+
// failure itself.
|
|
2155
|
+
if (!freshTargetMd)
|
|
2156
|
+
throw new Error(`target "${targetName}" disappeared mid-consolidate`);
|
|
2157
|
+
// V10-01 (P2-2): reference-mode targets APPEND, mirroring restructure
|
|
2158
|
+
// (base + '\n\n' + new text) — a second consolidate of a re-created
|
|
2159
|
+
// source used to overwrite the first demotion's bytes (silent loss of
|
|
2160
|
+
// the older degraded knowledge). The previous bytes are read INSIDE
|
|
2161
|
+
// the serial queue (V8-11 discipline), so a concurrent patch between
|
|
2162
|
+
// planning and commit is never silently overwritten; applyTreeChange
|
|
2163
|
+
// re-reads at commit for the rollback bytes.
|
|
2164
|
+
const writes = [];
|
|
2165
|
+
for (const reference of referenceWrites) {
|
|
2166
|
+
const previous = await this.readForCas(reference.target);
|
|
2167
|
+
const base = previous?.trimEnd() ?? '';
|
|
2168
|
+
// V24-01: carry the plan-time bytes as the CAS baseline — a read
|
|
2169
|
+
// error here reads as null and stays fail-closed at commit (an
|
|
2170
|
+
// existing file then counts as drift).
|
|
2171
|
+
writes.push({ target: reference.target, content: base === '' ? reference.content : `${base}\n\n${reference.content}`, expected: previous });
|
|
2172
|
+
}
|
|
2173
|
+
// V2 (design §16.7): the re-homed support files ride the SAME commit as the
|
|
2174
|
+
// merged body — one tree change, one rollback domain, and the body's
|
|
2175
|
+
// rewritten references can never be committed without their targets.
|
|
2176
|
+
for (const file of rehomed) {
|
|
2177
|
+
const target = join(targetDir, ...file.to.split('/'));
|
|
2178
|
+
const previous = await this.readForCas(target);
|
|
2179
|
+
writes.push({ target, content: file.content, expected: previous });
|
|
2180
|
+
}
|
|
2181
|
+
if (mode === 'append') {
|
|
2182
|
+
const merged = freshTargetMd.trimEnd() + parts.join('\n') + '\n';
|
|
2183
|
+
const validation = validateFrontmatter(merged, targetName, this.limits);
|
|
2184
|
+
if (validation)
|
|
2185
|
+
throw new Error(`merge validation failed: ${validation}`);
|
|
2186
|
+
writes.push({ target: join(targetDir, 'SKILL.md'), content: merged, expected: freshTargetMd });
|
|
2187
|
+
}
|
|
2188
|
+
else {
|
|
2189
|
+
const pointerLines = demotedPointers.map(lines => `\n${lines.join('\n')}`).join('');
|
|
2190
|
+
const extended = freshTargetMd.trimEnd() + pointerLines + '\n';
|
|
2191
|
+
const validation = validateFrontmatter(extended, targetName, this.limits);
|
|
2192
|
+
if (validation)
|
|
2193
|
+
throw new Error(`merge validation failed: ${validation}`);
|
|
2194
|
+
writes.push({ target: join(targetDir, 'SKILL.md'), content: extended, expected: freshTargetMd });
|
|
2195
|
+
}
|
|
2196
|
+
const result = await this.applyTreeChange({
|
|
2197
|
+
name: targetName,
|
|
2198
|
+
origin,
|
|
2199
|
+
protection: 'write',
|
|
2200
|
+
writes,
|
|
2201
|
+
auditAction: 'consolidate',
|
|
2202
|
+
auditSummary: `consolidated ${normalizedSources.join(', ')} (${mode}) into ${targetName}`
|
|
2203
|
+
+ (rehomed.length === 0 ? '' : `; re-homed ${rehomed.map(file => `${file.from}->${file.to}`).join(', ')}`),
|
|
2204
|
+
eventAction: 'consolidate',
|
|
2205
|
+
});
|
|
2206
|
+
if (!result.ok)
|
|
2207
|
+
throw new Error(result.message);
|
|
2208
|
+
return { ok: true, message: `已把 ${normalizedSources.join(', ')} 合并进「${targetName}」。`, path: targetDir };
|
|
2209
|
+
}
|
|
2210
|
+
catch (error) {
|
|
2211
|
+
// Bring back every source we already archived so the merge is fully
|
|
2212
|
+
// undone. 0.3.16 (T-14): a failed restore used to be swallowed by
|
|
2213
|
+
// `.catch(() => {})` while the message still claimed a full rollback —
|
|
2214
|
+
// the source stayed in .archive silently. Surface it.
|
|
2215
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2216
|
+
const failedRestores = [];
|
|
2217
|
+
for (const source of archived.reverse()) {
|
|
2218
|
+
try {
|
|
2219
|
+
const restored = await this.restoreFromArchive(source);
|
|
2220
|
+
if (!restored.ok)
|
|
2221
|
+
failedRestores.push(source);
|
|
2222
|
+
}
|
|
2223
|
+
catch {
|
|
2224
|
+
// restoreFromArchive never rejects by contract; if it ever does,
|
|
2225
|
+
// count the source as unrestored rather than replacing the report.
|
|
2226
|
+
failedRestores.push(source);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
if (failedRestores.length > 0) {
|
|
2230
|
+
return { ok: false, message: `合并失败(${reason});已回滚,但以下未回滚:${failedRestores.join(', ')}:它们仍在 .archive 里,请用 /evolution skill restore 恢复。` };
|
|
2231
|
+
}
|
|
2232
|
+
return { ok: false, message: `合并失败,已回滚:${reason}` };
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Content-distribution repair (008 batch B, 009-R kernel): move body
|
|
2238
|
+
* sections — anchored by their exact `## heading` lines — into references/
|
|
2239
|
+
* support files and replace each span with a pointer line. The skill
|
|
2240
|
+
* name/dir never change (routing stays; only content location shifts, so a
|
|
2241
|
+
* fat body sheds its log-like detail). Deterministic, never automatic:
|
|
2242
|
+
* candidates come from an approved review plan. The write batch goes
|
|
2243
|
+
* through the tree-change kernel — one commit point, byte-level rollback.
|
|
2244
|
+
* Package integrity (009-R): a moved section whose text carries
|
|
2245
|
+
* support-directory links is refused (those links' files stay behind in the
|
|
2246
|
+
* same package; the moved text belongs in references/ beside them).
|
|
2247
|
+
*/
|
|
2248
|
+
async restructure(rawName, moves, origin = this.authority.origin) {
|
|
2249
|
+
const name = rawName.trim();
|
|
2250
|
+
// F-208: the whole read→plan→write runs under the in-process serialize
|
|
2251
|
+
// queue so two concurrent restructures on one skill never interleave.
|
|
2252
|
+
return await this.serial(() => this.restructureCore(name, moves, origin));
|
|
2253
|
+
}
|
|
2254
|
+
async restructureCore(name, moves, origin) {
|
|
2255
|
+
const badName = this.badName(name);
|
|
2256
|
+
if (badName)
|
|
2257
|
+
return { ok: false, message: badName };
|
|
2258
|
+
if (moves.length === 0)
|
|
2259
|
+
return { ok: false, message: '重构至少需要一次章节搬移。' };
|
|
2260
|
+
if (moves.length > MAX_RESTRUCTURE_MOVES)
|
|
2261
|
+
return { ok: false, message: `重构超过 ${MAX_RESTRUCTURE_MOVES} 次搬移。` };
|
|
2262
|
+
for (const move of moves) {
|
|
2263
|
+
// V24-20a (v24): element-shape guard BEFORE the field reads — a null
|
|
2264
|
+
// element used to throw on `move.heading` instead of returning the
|
|
2265
|
+
// structured refusal the library owes every entry point. The `unknown`
|
|
2266
|
+
// view is deliberate: the declared element type promises an object, but
|
|
2267
|
+
// model/plan payloads have historically crossed that promise (V8-09).
|
|
2268
|
+
const raw = move;
|
|
2269
|
+
if (raw === null || typeof raw !== 'object') {
|
|
2270
|
+
return { ok: false, message: '每次重构搬移都必须是带 heading 的对象。' };
|
|
2271
|
+
}
|
|
2272
|
+
if (typeof move.heading !== 'string' || !move.heading.trim()) {
|
|
2273
|
+
return { ok: false, message: '每次重构搬移都需要非空的 heading。' };
|
|
2274
|
+
}
|
|
2275
|
+
// A1-6 (v18): reuse the single validator (regex + Windows device stems)
|
|
2276
|
+
// so restructure cannot mint a target the support-file layer refuses.
|
|
2277
|
+
const targetIssue = validateRestructureTarget(move.toFile);
|
|
2278
|
+
if (targetIssue)
|
|
2279
|
+
return { ok: false, message: targetIssue };
|
|
2280
|
+
}
|
|
2281
|
+
const dir = this.dirOf(name);
|
|
2282
|
+
const md = await this.io.readText(join(dir, 'SKILL.md'));
|
|
2283
|
+
if (!md)
|
|
2284
|
+
return { ok: false, message: `技能「${name}」不存在。` };
|
|
2285
|
+
// 0.3.16 (E-38/E-38a): the block boundary once used the loose
|
|
2286
|
+
// indexOf('\n---', 3) form, which matched a `----` line and leaked
|
|
2287
|
+
// frontmatter into the body — frontmatterBlock owns the strict line rule
|
|
2288
|
+
// (P3-3). Planning runs on a \n-normalized body, but the final SKILL.md
|
|
2289
|
+
// re-uses the file's own line ending so a CRLF file keeps them (E-38a).
|
|
2290
|
+
// The body slice is byte-based (md.slice(header.length)) so the newline
|
|
2291
|
+
// AFTER the closing `---` stays with the body — a line-based slice would
|
|
2292
|
+
// consume it as the separator and splice the first body line onto the
|
|
2293
|
+
// closing fence.
|
|
2294
|
+
const block = frontmatterBlock(md);
|
|
2295
|
+
if (!block)
|
|
2296
|
+
return { ok: false, message: 'SKILL.md 没有有效的 frontmatter;拒绝重构。' };
|
|
2297
|
+
// P1-1 (v7 audit): the planner must receive the BODY ONLY — its rebuilt
|
|
2298
|
+
// body is spliced after the frontmatter header, so feeding it the full
|
|
2299
|
+
// text duplicated the frontmatter on every successful restructure (a
|
|
2300
|
+
// second `---` block the lenient parser tolerated but strict YAML
|
|
2301
|
+
// consumers read as duplicate name/description keys).
|
|
2302
|
+
const header = block.lines.slice(0, block.end + 1).join('\n');
|
|
2303
|
+
const bodyRaw = md.slice(header.length);
|
|
2304
|
+
const plan = planRestructureSections(bodyRaw.replace(/\r\n/g, '\n'), moves);
|
|
2305
|
+
if ('error' in plan)
|
|
2306
|
+
return { ok: false, message: `重构被拒绝:${plan.error}` };
|
|
2307
|
+
const newMd = `${header}${plan.body}`.replace(/\r\n/g, '\n');
|
|
2308
|
+
const finalMd = block.nl === '\r\n' ? newMd.replace(/\n/g, '\r\n') : newMd;
|
|
2309
|
+
const newMdCheck = validateFrontmatter(finalMd, name, this.limits);
|
|
2310
|
+
if (newMdCheck)
|
|
2311
|
+
return { ok: false, message: `重构被拒绝:${newMdCheck}` };
|
|
2312
|
+
// Design §2.2: the refusal is scoped to the case it exists for. A section
|
|
2313
|
+
// carrying citations used to be refused outright — which blocked the ONLY
|
|
2314
|
+
// non-destructive way to shrink a citation-dense body (measured: 10/10
|
|
2315
|
+
// sections of the family's own maintenance skill). Nothing LEAVES the skill
|
|
2316
|
+
// on a restructure, so the failure mode is not "dangling" but "a citation
|
|
2317
|
+
// whose target does not exist": that is what is checked here. An
|
|
2318
|
+
// unavailable file listing keeps the conservative token scan — unknown never
|
|
2319
|
+
// passes on a write path.
|
|
2320
|
+
const policy = this.limits.citationPolicy ?? DEFAULT_CITATION_POLICY;
|
|
2321
|
+
const listing = policy === 'refuse' ? null : await this.listSupportFiles(name);
|
|
2322
|
+
const files = listing !== null && isPresent(listing) ? listing.value : null;
|
|
2323
|
+
for (const section of plan.sections) {
|
|
2324
|
+
const refs = supportRefs(section.text);
|
|
2325
|
+
if (refs.length === 0)
|
|
2326
|
+
continue;
|
|
2327
|
+
if (files === null) {
|
|
2328
|
+
return { ok: false, message: `重构被拒绝:章节「## ${section.heading}」引用了会留在原地的支撑文件(${refs.join(', ')}):请拆开该章节,或连同它的文件一起搬。` };
|
|
2329
|
+
}
|
|
2330
|
+
const report = resolveCitations({ content: section.text, file: 'SKILL.md', files });
|
|
2331
|
+
if (report.dangling.length > 0) {
|
|
2332
|
+
const missing = report.dangling.map(ref => ref.target ?? ref.raw).join(', ');
|
|
2333
|
+
return { ok: false, message: `重构被拒绝:章节「## ${section.heading}」引用的支撑文件不存在(${missing}):请修正引用,或先创建该文件。` };
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
// Aggregate by destination: two moves into one file append in move order.
|
|
2337
|
+
const byRel = new Map();
|
|
2338
|
+
for (const section of plan.sections) {
|
|
2339
|
+
const entry = byRel.get(section.rel) ?? { rel: section.rel, texts: [] };
|
|
2340
|
+
entry.texts.push(section.text);
|
|
2341
|
+
byRel.set(section.rel, entry);
|
|
2342
|
+
}
|
|
2343
|
+
const writes = [];
|
|
2344
|
+
for (const entry of byRel.values()) {
|
|
2345
|
+
const target = join(dir, ...entry.rel.split('/'));
|
|
2346
|
+
const previous = await this.readForCas(target);
|
|
2347
|
+
const base = previous?.trimEnd() ?? '';
|
|
2348
|
+
writes.push({
|
|
2349
|
+
target,
|
|
2350
|
+
content: base === '' ? entry.texts.join('\n\n') : `${base}\n\n${entry.texts.join('\n\n')}`,
|
|
2351
|
+
// V24-01: plan-time bytes as the CAS baseline (fail-closed on a read
|
|
2352
|
+
// error: `previous` is null then, so an existing file counts as drift).
|
|
2353
|
+
expected: previous,
|
|
2354
|
+
});
|
|
2355
|
+
}
|
|
2356
|
+
writes.push({ target: join(dir, 'SKILL.md'), content: finalMd, expected: md });
|
|
2357
|
+
const result = await this.applyTreeChange({
|
|
2358
|
+
name,
|
|
2359
|
+
origin,
|
|
2360
|
+
protection: 'write',
|
|
2361
|
+
writes,
|
|
2362
|
+
auditAction: 'restructure',
|
|
2363
|
+
auditSummary: `moved ${plan.sections.length} section(s): ${[...byRel.keys()].join(', ')}`,
|
|
2364
|
+
eventAction: 'restructure',
|
|
2365
|
+
});
|
|
2366
|
+
if (!result.ok)
|
|
2367
|
+
return result;
|
|
2368
|
+
return { ok: true, message: `已重构「${name}」:把 ${plan.sections.length} 个章节搬到 references/。`, path: dir };
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Unified tree-change commit point (009 kernel): owns validation order,
|
|
2372
|
+
* pre-read rollback bytes, two-phase write with byte-level rollback, audit
|
|
2373
|
+
* and the mutation event. Mutators compose `TreeChangePlan`s — consolidate,
|
|
2374
|
+
* restructure (and future reference-mode consolidations) never implement
|
|
2375
|
+
* two-phase commit themselves.
|
|
2376
|
+
*/
|
|
2377
|
+
async applyTreeChange(plan) {
|
|
2378
|
+
const name = plan.name.trim();
|
|
2379
|
+
const badName = this.badName(name, { allowReserved: true });
|
|
2380
|
+
if (badName)
|
|
2381
|
+
return { ok: false, message: badName };
|
|
2382
|
+
const dir = this.dirOf(name);
|
|
2383
|
+
const md = await this.io.readText(join(dir, 'SKILL.md'));
|
|
2384
|
+
if (!md)
|
|
2385
|
+
return { ok: false, message: `技能「${name}」不存在。` };
|
|
2386
|
+
const protection = plan.protection === 'write'
|
|
2387
|
+
? await this.writeProtection(name, plan.origin)
|
|
2388
|
+
: plan.protection === 'delete'
|
|
2389
|
+
? await this.deleteProtection(name)
|
|
2390
|
+
: null;
|
|
2391
|
+
if (protection)
|
|
2392
|
+
return { ok: false, message: `技能「${name}」受保护(${protection})。` };
|
|
2393
|
+
// Pre-read EVERY write target: the rollback bytes are kernel-owned and the
|
|
2394
|
+
// caller cannot fabricate them. The same read feeds the append semantics of
|
|
2395
|
+
// restructure (the caller re-reads for its own construction — kernel reads
|
|
2396
|
+
// again because the bytes it restores must be the bytes on disk at commit).
|
|
2397
|
+
const landing = [];
|
|
2398
|
+
for (const write of plan.writes) {
|
|
2399
|
+
// A1-11 (v18): a read failure (EACCES/EIO) is NOT "missing" —
|
|
2400
|
+
// treating it as null made the rollback delete a file it could not
|
|
2401
|
+
// restore. Refuse the whole plan before any write.
|
|
2402
|
+
let previous;
|
|
2403
|
+
try {
|
|
2404
|
+
previous = await this.io.readText(write.target);
|
|
2405
|
+
}
|
|
2406
|
+
catch (error) {
|
|
2407
|
+
return { ok: false, message: `拒绝目录树变更:无法安全预读 ${write.target}(${error instanceof Error ? error.message : String(error)});未执行任何写入` };
|
|
2408
|
+
}
|
|
2409
|
+
if (Buffer.byteLength(write.content, 'utf8') > this.limits.maxSkillFileBytes) {
|
|
2410
|
+
return { ok: false, message: `写入超过 ${this.limits.maxSkillFileBytes} 字节:${write.target}` };
|
|
2411
|
+
}
|
|
2412
|
+
const threat = this.contentThreatBlock(write.content);
|
|
2413
|
+
if (threat)
|
|
2414
|
+
return { ok: false, message: threat };
|
|
2415
|
+
landing.push({ target: write.target, content: write.content, previous, expected: write.expected });
|
|
2416
|
+
}
|
|
2417
|
+
const written = [];
|
|
2418
|
+
let durabilityWarning = '';
|
|
2419
|
+
try {
|
|
2420
|
+
for (const entry of landing) {
|
|
2421
|
+
try {
|
|
2422
|
+
if (this.transact) {
|
|
2423
|
+
// v22 (LOCK-1): CAS write — the previous blind writeText committed
|
|
2424
|
+
// plan-time bytes over whatever was on disk, so a concurrent
|
|
2425
|
+
// patch/update (another process, shared DSH_HOME) between the
|
|
2426
|
+
// pre-read above and this write was silently overwritten (lost
|
|
2427
|
+
// update; both writers reported success). The task only commits
|
|
2428
|
+
// when the disk still holds the pre-read baseline; any drift
|
|
2429
|
+
// aborts the plan into the rollback below.
|
|
2430
|
+
// v24 (V24-01): the CAS baseline is the caller's PLAN-TIME bytes
|
|
2431
|
+
// (`expected`) when the write carries them — the pre-read above
|
|
2432
|
+
// only supplies the rollback bytes. Lock-1's own pre-read baseline
|
|
2433
|
+
// still left a window for appends/merges built from an earlier
|
|
2434
|
+
// read: a concurrent write landing between planning and the
|
|
2435
|
+
// pre-read became the CAS baseline, so plan-time content
|
|
2436
|
+
// (composed WITHOUT it) committed cleanly over it — lost update,
|
|
2437
|
+
// both writers successful. Comparing against the plan-time bytes
|
|
2438
|
+
// closes the whole planning→commit span.
|
|
2439
|
+
// P3 (v22; gate fix 0.3.66): the flag lives in a holder so the
|
|
2440
|
+
// control-flow analysis (which cannot see the transact callback's
|
|
2441
|
+
// assignment) does not narrow it to a literal `false`.
|
|
2442
|
+
const baseline = entry.expected === undefined ? entry.previous : entry.expected;
|
|
2443
|
+
const drift = { seen: false };
|
|
2444
|
+
// S1.4 (v37 P2-7): a transact backend that never invokes the task
|
|
2445
|
+
// (the V6-19 contract) would leave BOTH holders at their initial
|
|
2446
|
+
// values and this write would be reported as a success.
|
|
2447
|
+
const ran = { done: false };
|
|
2448
|
+
await this.transact(this.io, entry.target, (current) => {
|
|
2449
|
+
// v43 audit (FLOW3-1): per-attempt reset. This callback re-runs on a
|
|
2450
|
+
// lock-loss retry, and a stale `drift.seen` from the previous
|
|
2451
|
+
// attempt threw "concurrent modification" even when the retry's own
|
|
2452
|
+
// write had landed — a partial tree change reported as a failure.
|
|
2453
|
+
drift.seen = false;
|
|
2454
|
+
ran.done = true;
|
|
2455
|
+
if (current !== baseline) {
|
|
2456
|
+
drift.seen = true;
|
|
2457
|
+
return current;
|
|
2458
|
+
}
|
|
2459
|
+
return entry.content;
|
|
2460
|
+
});
|
|
2461
|
+
if (!ran.done) {
|
|
2462
|
+
throw new Error(`internal error: the write transaction for ${entry.target} did not invoke the task; nothing was written`);
|
|
2463
|
+
}
|
|
2464
|
+
if (drift.seen) {
|
|
2465
|
+
throw new Error(`concurrent modification detected: ${entry.target} changed after the plan was computed (a concurrent writer won the race); no further writes were performed`);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
else {
|
|
2469
|
+
// No transact backend: the previous blind write (the class was
|
|
2470
|
+
// constructed without cross-process exclusion anyway).
|
|
2471
|
+
await this.io.writeText(entry.target, entry.content);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
catch (error) {
|
|
2475
|
+
// A1-15 (v18): a post-rename dir-fsync failure means the bytes DID
|
|
2476
|
+
// land — rolling back would delete a visible write, and the audit/
|
|
2477
|
+
// event below must still run. Record it and keep going.
|
|
2478
|
+
if (!isCommittedOnly(error))
|
|
2479
|
+
throw error;
|
|
2480
|
+
durabilityWarning = error instanceof Error ? error.message : String(error);
|
|
2481
|
+
}
|
|
2482
|
+
written.push({ target: entry.target, previous: entry.previous });
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
catch (error) {
|
|
2486
|
+
// V27 G8.3: the rollback is best-effort (the .backups snapshot is the
|
|
2487
|
+
// recovery path when the caller took one), so the result must not claim a
|
|
2488
|
+
// rollback that did not happen. Track which targets failed to roll back
|
|
2489
|
+
// and name them; a silent `.catch` made the message a promise the code
|
|
2490
|
+
// never made.
|
|
2491
|
+
const stuck = [];
|
|
2492
|
+
for (const entry of written.reverse()) {
|
|
2493
|
+
try {
|
|
2494
|
+
if (entry.previous === null)
|
|
2495
|
+
await this.io.remove(entry.target);
|
|
2496
|
+
else
|
|
2497
|
+
await this.io.writeText(entry.target, entry.previous);
|
|
2498
|
+
}
|
|
2499
|
+
catch {
|
|
2500
|
+
stuck.push(entry.target);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2504
|
+
if (stuck.length === 0)
|
|
2505
|
+
return { ok: false, message: `目录树变更失败,已回滚:${reason}` };
|
|
2506
|
+
return { ok: false, message: `目录树变更失败:${reason}。回滚无法恢复 ${stuck.join(', ')}:可恢复性由当前快照决定:${await this.snapshotRecoveryAvailability()} —— 若上面不是「最新快照」,请重新应用这次变更。` };
|
|
2507
|
+
}
|
|
2508
|
+
// A7: both sides come from the KERNEL's own pre-read of the SKILL.md target — the baseline the
|
|
2509
|
+
// CAS write compared against and the bytes the rollback restores — instead of the earlier `md`
|
|
2510
|
+
// read, which names a state the commit did not necessarily replace. (In every current caller the
|
|
2511
|
+
// CAS refuses a drift between the two reads, so this is the same value on every committing run.)
|
|
2512
|
+
// G3a: ONE tree change is ONE unit — every path it wrote is audited under a shared unit id.
|
|
2513
|
+
// The old form picked only the SKILL.md landing, so a support file the same change moved got
|
|
2514
|
+
// no version entry at all and the undo could not restore the file set as a unit.
|
|
2515
|
+
const unitId = plan.unitId ?? 'u-' + Date.now().toString(36) + '-' + (nextUnitSeq += 1).toString(36);
|
|
2516
|
+
for (const land of landing) {
|
|
2517
|
+
const isBody = land.target.split(/[\\/]/).pop() === 'SKILL.md';
|
|
2518
|
+
const rel = isBody ? undefined : land.target.slice(dir.length + 1).split('\\').join('/');
|
|
2519
|
+
// G3b(A22):代表片=第一条 landing;kind 判 null(previous 的取值域只有 string/null)。
|
|
2520
|
+
const unitHead = land === landing[0] ? {
|
|
2521
|
+
moves: landing
|
|
2522
|
+
.filter(l => l.target.split(/[\\/]/).pop() !== 'SKILL.md')
|
|
2523
|
+
.map((l) => {
|
|
2524
|
+
const p = l.target.slice(dir.length + 1).split(String.fromCharCode(92)).join(String.fromCharCode(47));
|
|
2525
|
+
return {
|
|
2526
|
+
path: p,
|
|
2527
|
+
kind: l.previous === null ? 'created' : 'moved',
|
|
2528
|
+
to: p,
|
|
2529
|
+
...l.previous === null ? {} : { beforeRef: contentHash(l.previous) },
|
|
2530
|
+
afterRef: contentHash(l.content),
|
|
2531
|
+
};
|
|
2532
|
+
})
|
|
2533
|
+
// F1 (independent review, 2026-10-14): when one plan writes the SAME path twice, keep the
|
|
2534
|
+
// LAST write -- first-wins left `afterRef` describing the earlier bytes (stale). `beforeRef`
|
|
2535
|
+
// and `kind` are unaffected: both come from the same per-path pre-read.
|
|
2536
|
+
.filter((m, idx, all) => all.map(x => x.path).lastIndexOf(m.path) === idx),
|
|
2537
|
+
sources: [{ skill: name }],
|
|
2538
|
+
} : undefined;
|
|
2539
|
+
// A22: previous is string | null (a read failure rejects the whole plan), so the old
|
|
2540
|
+
// `=== undefined ? md : previous` was an unreachable branch -- the type is the truth.
|
|
2541
|
+
const auditedBefore = land.previous;
|
|
2542
|
+
const auditedAfter = land.content;
|
|
2543
|
+
// G5: the plan already carries the write face (`origin`) — put it in the ledger; G3a adds the unit.
|
|
2544
|
+
await this.audit(name, plan.auditAction, auditedBefore, auditedAfter, plan.auditSummary, rel, unitId, unitHead, plan.origin);
|
|
2545
|
+
}
|
|
2546
|
+
this.notifyMutation({ action: plan.eventAction, name, skillDir: dir });
|
|
2547
|
+
return {
|
|
2548
|
+
ok: true,
|
|
2549
|
+
message: durabilityWarning === ''
|
|
2550
|
+
? `${plan.eventAction} "${name}" succeeded.`
|
|
2551
|
+
: `${plan.eventAction} "${name}" succeeded (警告:写入已落盘,但目录 fsync 失败 —— 持久性未确认:${durabilityWarning})`,
|
|
2552
|
+
path: dir,
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
/**
|
|
2556
|
+
* Restore one skill from `.archive/` back to the active root. Hermes-style
|
|
2557
|
+
* recoverability: archival never deletes, and this is the control-plane
|
|
2558
|
+
* path back. The `.archive-reason` marker is dropped on restore.
|
|
2559
|
+
*/
|
|
2560
|
+
async restoreFromArchive(rawName, origin = this.authority.origin) {
|
|
2561
|
+
const name = rawName.trim();
|
|
2562
|
+
// P2-2 (v11): the inline regex + hand-written message bypassed badName()
|
|
2563
|
+
// (C-12 single source — the regex carries no length bound, so a >64-char
|
|
2564
|
+
// name used to pass here). Reserved names allowed: recovery of a legacy
|
|
2565
|
+
// entry; on win32 the mkdir fails with a raw errno (documented).
|
|
2566
|
+
const bad = this.badName(name, { allowReserved: true });
|
|
2567
|
+
if (bad)
|
|
2568
|
+
return { ok: false, message: bad };
|
|
2569
|
+
// A1-20 (v18): probe the destination DIRECTORY, not only its SKILL.md. A
|
|
2570
|
+
// directory without SKILL.md (partial/hand-made) used to fall through to
|
|
2571
|
+
// moveDir's dest-exists failure, whose message named a different obstacle.
|
|
2572
|
+
const dest = this.dirOf(name);
|
|
2573
|
+
if (await this.io.exists(dest)) {
|
|
2574
|
+
const hasSkillFile = await this.io.exists(join(dest, 'SKILL.md'));
|
|
2575
|
+
return {
|
|
2576
|
+
ok: false,
|
|
2577
|
+
message: hasSkillFile
|
|
2578
|
+
? `技能「${name}」已存在于当前根目录;拒绝覆盖。`
|
|
2579
|
+
: `技能目录「${name}」已存在于当前根目录,但没有 SKILL.md;恢复前请先移除或修复它。`,
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
// OPT-08 (2026-09): case-variant collision probe, the restore-side twin of
|
|
2583
|
+
// create()'s D-6 scan. The exact-name probe above passes when a
|
|
2584
|
+
// DIFFERENTLY-CASED sibling directory already exists (hand maintenance, a
|
|
2585
|
+
// git round-trip on a case-sensitive filesystem) and the restore then
|
|
2586
|
+
// created a second same-name-different-case skill: duplicate catalog
|
|
2587
|
+
// entries and an ambiguous consolidate/patch target. Case-insensitive
|
|
2588
|
+
// filesystems are already covered by the exists() probe; this closes the
|
|
2589
|
+
// POSIX side.
|
|
2590
|
+
let caseVariant;
|
|
2591
|
+
try {
|
|
2592
|
+
const rootEntries = await this.io.list(this.root);
|
|
2593
|
+
caseVariant = rootEntries.find(entry => entry !== name && entry.toLowerCase() === name.toLowerCase());
|
|
2594
|
+
}
|
|
2595
|
+
catch {
|
|
2596
|
+
// An unreadable root fails loudly at the move below; this probe must
|
|
2597
|
+
// not mask that error with an invented refusal.
|
|
2598
|
+
}
|
|
2599
|
+
if (caseVariant !== undefined) {
|
|
2600
|
+
return {
|
|
2601
|
+
ok: false,
|
|
2602
|
+
message: `技能「${caseVariant}」(同名、仅大小写不同)已存在于当前根目录;把「${name}」恢复到它旁边会造成含义不清的重复:请恢复为「${caseVariant}」,或先移除该变体。`,
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
const archiveRoot = join(this.root, '.archive');
|
|
2606
|
+
let entries;
|
|
2607
|
+
try {
|
|
2608
|
+
entries = await this.io.list(archiveRoot);
|
|
2609
|
+
}
|
|
2610
|
+
catch {
|
|
2611
|
+
return { ok: false, message: '没有可用的技能归档。' };
|
|
2612
|
+
}
|
|
2613
|
+
// 0.3.16 (E-3): `${name}-` also matches a SIBLING skill's archive entry
|
|
2614
|
+
// (restoring `foo` used to pick `foo-bar` after a lexical sort: foo got
|
|
2615
|
+
// foo-bar's content and foo-bar's archive vanished). The directory stamp
|
|
2616
|
+
// makes the name unique, not the owner — verify each candidate's own
|
|
2617
|
+
// SKILL.md frontmatter name before restoring.
|
|
2618
|
+
//
|
|
2619
|
+
// V27 G8.4: three signals can identify the owner, in decreasing authority:
|
|
2620
|
+
// the `.archive-name` marker this store now writes, the archived
|
|
2621
|
+
// SKILL.md frontmatter, and — only for a candidate whose directory name is
|
|
2622
|
+
// EXACTLY the requested name — the directory itself. The last one is what
|
|
2623
|
+
// makes an entry with a 0-byte SKILL.md recoverable; it stays safe because a
|
|
2624
|
+
// sibling cannot own the exact name (a sibling would be `foo-bar<TAB>`).
|
|
2625
|
+
const candidates = entries.filter(entry => entry === name || entry.startsWith(`${name}-`)).sort().reverse();
|
|
2626
|
+
let chosen;
|
|
2627
|
+
const unreadable = [];
|
|
2628
|
+
for (const candidate of candidates) {
|
|
2629
|
+
// O-7 / PLAN S3.2 (audit 1-2): a read FAILURE is not "no marker" and not "no file". The
|
|
2630
|
+
// marker decides only when it produced bytes, and an unreadable SKILL.md sends the candidate
|
|
2631
|
+
// to `unreadable` (the list the refusal below names) instead of letting the exact-name
|
|
2632
|
+
// shortcut claim it — the same A1-17 rule this file's list() already applies.
|
|
2633
|
+
const markerProbe = await probeText(this.io, join(archiveRoot, candidate, '.archive-name'));
|
|
2634
|
+
if (isUnknown(markerProbe)) {
|
|
2635
|
+
unreadable.push(candidate);
|
|
2636
|
+
continue;
|
|
2637
|
+
}
|
|
2638
|
+
const marked = isPresent(markerProbe) ? markerProbe.value.trim() : '';
|
|
2639
|
+
if (marked === name) {
|
|
2640
|
+
chosen = candidate;
|
|
2641
|
+
break;
|
|
2642
|
+
}
|
|
2643
|
+
const mdProbe = await probeText(this.io, join(archiveRoot, candidate, 'SKILL.md'));
|
|
2644
|
+
if (isUnknown(mdProbe)) {
|
|
2645
|
+
unreadable.push(candidate);
|
|
2646
|
+
continue;
|
|
2647
|
+
}
|
|
2648
|
+
const parsed = parseFrontmatter(isPresent(mdProbe) ? mdProbe.value : '');
|
|
2649
|
+
if (parsed?.frontmatter.name === name) {
|
|
2650
|
+
chosen = candidate;
|
|
2651
|
+
break;
|
|
2652
|
+
}
|
|
2653
|
+
if (parsed === null && candidate === name) {
|
|
2654
|
+
chosen = candidate;
|
|
2655
|
+
break;
|
|
2656
|
+
}
|
|
2657
|
+
if (parsed === null)
|
|
2658
|
+
unreadable.push(candidate);
|
|
2659
|
+
}
|
|
2660
|
+
if (!chosen) {
|
|
2661
|
+
if (unreadable.length === 0)
|
|
2662
|
+
return { ok: false, message: `技能「${name}」不在 .archive 里。` };
|
|
2663
|
+
return {
|
|
2664
|
+
ok: false,
|
|
2665
|
+
message: `.archive 里没有「${name}」对应的 frontmatter 名,但有 ${unreadable.length} 个名字相似的归档项:${unreadable.join(', ')}。`
|
|
2666
|
+
+ `这些归档项没有可读的 SKILL.md(或名字对不上),按名字恢复无法区分它们:请把目录改成技能名后再次恢复;快照这条路的当前状态是:${await this.snapshotRecoveryAvailability()}。`,
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
const source = join(archiveRoot, chosen);
|
|
2670
|
+
// Symlink guard (G7): restoring a symlinked archive entry would recreate a
|
|
2671
|
+
// link in the active tree instead of the real content — refuse first.
|
|
2672
|
+
if (this.io.isSymlink) {
|
|
2673
|
+
const link = await this.io.isSymlink(source);
|
|
2674
|
+
if (link === true)
|
|
2675
|
+
return { ok: false, message: `归档项「${chosen}」是符号链接;拒绝恢复。` };
|
|
2676
|
+
}
|
|
2677
|
+
// P2-9 (v15): same writer-collision probe as archive — restoring ONTO a
|
|
2678
|
+
// skill a writer is mid-write on would drop the new bytes behind the
|
|
2679
|
+
// restored tree.
|
|
2680
|
+
if (await this.supportDirsUnlistable(dest)) {
|
|
2681
|
+
return { ok: false, message: `无法检查技能「${name}」是否有写入方(有一个支撑目录列不出来):请重试恢复。` };
|
|
2682
|
+
}
|
|
2683
|
+
const moveFailure = await this.moveDir(source, dest);
|
|
2684
|
+
if (moveFailure !== undefined) {
|
|
2685
|
+
// 0.3.16 (E-13 follow-up): the fallback failure must come back as a
|
|
2686
|
+
// structured result, never a rejection — consolidates call this in a
|
|
2687
|
+
// rollback loop and a throw there would replace the rollback report.
|
|
2688
|
+
return { ok: false, message: `从 .archive 恢复「${name}」失败:${moveFailure}` };
|
|
2689
|
+
}
|
|
2690
|
+
// P2 (v16): scrub lock residue that rode inside the archived entry (a
|
|
2691
|
+
// crashed writer, or the archive-probe TOCTOU strand) — a live-pid lock
|
|
2692
|
+
// landing in the live root structurally closes the writers' self-heal and
|
|
2693
|
+
// would keep the restored skill unwritable until process restart.
|
|
2694
|
+
for (const marker of ['.archive-reason', '.archive-name']) {
|
|
2695
|
+
// V27 G8.4: both markers are archive-directory metadata — dropping them is
|
|
2696
|
+
// what keeps a restored tree identical to the tree that was archived.
|
|
2697
|
+
if (await this.io.exists(join(dest, marker))) {
|
|
2698
|
+
await this.io.remove(join(dest, marker));
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
// P3 (v15): `.mutations.json` documents "records every skill mutation" —
|
|
2702
|
+
// restore (including consolidate's rollback restores) was the one
|
|
2703
|
+
// mutation invisible in the audit history. before=null (absent from the
|
|
2704
|
+
// tree), after=the restored SKILL.md bytes (best-effort read).
|
|
2705
|
+
// O-7 / PLAN S3.2: the audit records the restored bytes. A read FAILURE is not "no bytes" —
|
|
2706
|
+
// `null` here means the file has none — so the degradation is called out instead of being
|
|
2707
|
+
// written into the mutation trail as an absent `after`.
|
|
2708
|
+
const restoredProbe = await probeText(this.io, join(dest, 'SKILL.md'));
|
|
2709
|
+
if (isUnknown(restoredProbe)) {
|
|
2710
|
+
console.warn(`evolution-skill-store: the restored SKILL.md of "${name}" could not be read back (${restoredProbe.reason}); the restore is recorded without after-bytes`);
|
|
2711
|
+
}
|
|
2712
|
+
await this.audit(name, 'restore', null, isPresent(restoredProbe) ? restoredProbe.value : null, `restored from ${source}`, undefined, undefined, undefined, origin);
|
|
2713
|
+
this.notifyMutation({ action: 'restore', name, skillDir: dest });
|
|
2714
|
+
return { ok: true, message: `技能「${name}」已从 .archive 恢复。`, path: dest };
|
|
2715
|
+
}
|
|
2716
|
+
async writeSupportFile(rawName, filePath, content, origin = this.authority.origin, anchor) {
|
|
2717
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
2718
|
+
const name = rawName.trim();
|
|
2719
|
+
// F-208: the whole read→validate→write runs under the in-process serialize
|
|
2720
|
+
// queue so two concurrent writers to one support file never interleave.
|
|
2721
|
+
return await this.serial(() => this.writeSupportFileCore(name, filePath, content, origin, anchor));
|
|
2722
|
+
}
|
|
2723
|
+
async writeSupportFileCore(name, filePath, content, origin, anchor) {
|
|
2724
|
+
// P3 (v17): guard BEFORE dirOf — the last holdout of the old order
|
|
2725
|
+
// (updateCore/patchCore/removeSupportFileCore all check first).
|
|
2726
|
+
const badName = this.badName(name);
|
|
2727
|
+
if (badName)
|
|
2728
|
+
return { ok: false, message: badName };
|
|
2729
|
+
const dir = this.dirOf(name);
|
|
2730
|
+
if (!await this.io.exists(join(dir, 'SKILL.md')))
|
|
2731
|
+
return { ok: false, message: `技能「${name}」不存在。` };
|
|
2732
|
+
const protection = await this.writeProtection(name, origin);
|
|
2733
|
+
if (protection)
|
|
2734
|
+
return { ok: false, message: `技能「${name}」受保护(${protection})。` };
|
|
2735
|
+
const validation = validateSupportPath(filePath);
|
|
2736
|
+
if (validation)
|
|
2737
|
+
return { ok: false, message: validation };
|
|
2738
|
+
if (Buffer.byteLength(content, 'utf8') > this.limits.maxSkillFileBytes)
|
|
2739
|
+
return { ok: false, message: `支撑文件超过 ${this.limits.maxSkillFileBytes} 字节。` };
|
|
2740
|
+
const threat = this.contentThreatBlock(content);
|
|
2741
|
+
if (threat)
|
|
2742
|
+
return { ok: false, message: threat };
|
|
2743
|
+
const target = join(dir, ...filePath.replace(/\\/g, '/').split('/').filter(Boolean));
|
|
2744
|
+
return await this.runSingleWrite(target, (current) => {
|
|
2745
|
+
// v35 C9: the stage-time anchor is verified against this locked read; a
|
|
2746
|
+
// mismatch refuses before the no-op check (a stale anchor is a refusal even
|
|
2747
|
+
// when the supplied bytes happen to equal the current ones).
|
|
2748
|
+
const verdict = anchorVerdict(anchor, current);
|
|
2749
|
+
if (verdict !== 'match')
|
|
2750
|
+
return { result: anchorRefusalFile(name, filePath, verdict), write: null };
|
|
2751
|
+
// V6-15 (0.3.36): a byte-equivalent (modulo trailing whitespace) rewrite
|
|
2752
|
+
// is a no-op — no write, no audit, no mutation event — so a repeated
|
|
2753
|
+
// write_file cannot inflate the mutation-maturity counter or churn the
|
|
2754
|
+
// catalog (the update/patch/memory-add noop discipline, 0.3.29).
|
|
2755
|
+
// V4 (design §16.6): upstream applies the 100k-character cap to EVERY written
|
|
2756
|
+
// file. Landing it here in report mode first — the write goes through with an
|
|
2757
|
+
// advisory; `enforce` refuses, and a NET SHRINK of an already-over file is the
|
|
2758
|
+
// repair path in both modes (so a legacy 189k file can always be reduced).
|
|
2759
|
+
const overCap = exceedsContentLimit(content, this.limits.maxSkillContentChars);
|
|
2760
|
+
const repairing = shrinksOverLimit(content, current, this.limits.maxSkillContentChars);
|
|
2761
|
+
const capAdvisory = overCap && !repairing
|
|
2762
|
+
? ` Warning: ${content.length} characters is over the ${this.limits.maxSkillContentChars}-character cap upstream applies to every file; plan a split.`
|
|
2763
|
+
: '';
|
|
2764
|
+
if (overCap && !repairing && this.supportFileCharPolicy() === 'enforce') {
|
|
2765
|
+
return { result: { ok: false, message: `支撑文件「${filePath}」的内容超过 ${this.limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}` }, write: null };
|
|
2766
|
+
}
|
|
2767
|
+
if (current !== null && content.trimEnd() === current.trimEnd()) {
|
|
2768
|
+
return { result: { ok: true, message: `支撑文件「${filePath}」未变更:提供的内容与当前文件一致,未写入。`, noop: true, path: target }, write: null };
|
|
2769
|
+
}
|
|
2770
|
+
return {
|
|
2771
|
+
result: { ok: true, message: `支撑文件「${filePath}」已写入「${name}」。${capAdvisory}`, path: target },
|
|
2772
|
+
write: content,
|
|
2773
|
+
audit: { skillName: name, action: 'write_file', before: current, after: content, summary: `wrote ${filePath}`, path: filePath },
|
|
2774
|
+
event: { action: 'write_file', name, skillDir: dir, file: target },
|
|
2775
|
+
};
|
|
2776
|
+
}, anchor !== undefined ? () => anchorUnverifiable(name, filePath) : error => unreadableTarget(name + '/' + filePath, error));
|
|
2777
|
+
}
|
|
2778
|
+
async removeSupportFile(rawName, filePath, origin = this.authority.origin, anchor, unitId) {
|
|
2779
|
+
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
2780
|
+
const name = rawName.trim();
|
|
2781
|
+
// P2-4 (v11): the exists/read/remove window runs under the serialize queue
|
|
2782
|
+
// — writeSupportFile wraps itself (F-208), so an unwrapped remove could
|
|
2783
|
+
// interleave with a concurrent write and silently drop its write.
|
|
2784
|
+
return await this.serial(() => this.removeSupportFileCore(name, filePath, origin, anchor, unitId));
|
|
2785
|
+
}
|
|
2786
|
+
async removeSupportFileCore(name, filePath, origin, anchor,
|
|
2787
|
+
// G3a: the unit this removal belongs to (the undo path stamps its own `u-undo-*`), so the
|
|
2788
|
+
// `remove_file` entry it audits lands under the same unit as the rest of that operation.
|
|
2789
|
+
unitId) {
|
|
2790
|
+
const badName = this.badName(name);
|
|
2791
|
+
if (badName)
|
|
2792
|
+
return { ok: false, message: badName };
|
|
2793
|
+
const dir = this.dirOf(name);
|
|
2794
|
+
if (!await this.io.exists(join(dir, 'SKILL.md')))
|
|
2795
|
+
return { ok: false, message: `技能「${name}」不存在。` };
|
|
2796
|
+
const protection = await this.writeProtection(name, origin);
|
|
2797
|
+
if (protection)
|
|
2798
|
+
return { ok: false, message: `技能「${name}」受保护(${protection})。` };
|
|
2799
|
+
const validation = validateSupportPath(filePath);
|
|
2800
|
+
if (validation)
|
|
2801
|
+
return { ok: false, message: validation };
|
|
2802
|
+
const target = join(dir, ...filePath.replace(/\\/g, '/').split('/').filter(Boolean));
|
|
2803
|
+
if (!await this.io.exists(target)) {
|
|
2804
|
+
// v35 C9: a staged replay reports its own anchor verdict for a vanished
|
|
2805
|
+
// target; the generic not-found report is for unanchored callers.
|
|
2806
|
+
if (anchor !== undefined)
|
|
2807
|
+
return anchorRefusalFile(name, filePath, 'missing');
|
|
2808
|
+
return { ok: false, message: `文件「${filePath}」不在技能「${name}」里。` };
|
|
2809
|
+
}
|
|
2810
|
+
// P2-10 (v15): `io.remove` is a recursive rm, so a DIRECTORY path passed
|
|
2811
|
+
// as file_path (nested support dirs are a feature) would wipe the whole
|
|
2812
|
+
// subtree with only a null audit `before` — bytes gone without a hash.
|
|
2813
|
+
// A regular file always reads as a string here; exists+unreadable means
|
|
2814
|
+
// directory (EISDIR) or an unreadable file — refuse both (fail-closed;
|
|
2815
|
+
// directories must be removed file by file).
|
|
2816
|
+
// N36: three-state. Both non-present answers REFUSE below (the pre-read decides whether the target
|
|
2817
|
+
// is a readable regular file — EISDIR lands here too); the unknown one names its reason.
|
|
2818
|
+
const beforeProbe = await probeText(this.io, target);
|
|
2819
|
+
if (!isPresent(beforeProbe)) {
|
|
2820
|
+
if (isUnknown(beforeProbe))
|
|
2821
|
+
console.warn(`skill-store: could not read ${target} before removing it (${beforeProbe.reason}); refusing the removal`);
|
|
2822
|
+
return { ok: false, message: `"${filePath}" is not a readable regular file — remove the files inside it one by one.` };
|
|
2823
|
+
}
|
|
2824
|
+
const before = beforeProbe.value;
|
|
2825
|
+
// A1-5 (v18): a bare `io.remove` does not participate in the write-lock
|
|
2826
|
+
// protocol, so a cross-instance/process writer could land its rename after
|
|
2827
|
+
// our read and have the bytes deleted while it reports success. Route the
|
|
2828
|
+
// delete through the same per-path transact lock (returning null = remove).
|
|
2829
|
+
// v35 C9: verify the anchor inside the same lock the delete runs under. A
|
|
2830
|
+
// mismatch returns `current` unchanged — "no change" in the transact
|
|
2831
|
+
// contract — so nothing is deleted and nothing is audited.
|
|
2832
|
+
let verdict = anchorVerdict(anchor, before);
|
|
2833
|
+
// A7: the audit's `before` is the bytes the LOCKED read saw. The pre-read above
|
|
2834
|
+
// only decides the fast path and supplies a baseline when the target is already
|
|
2835
|
+
// gone at commit time; a concurrent writer between the two reads would otherwise
|
|
2836
|
+
// leave the ledger naming bytes this operation never removed.
|
|
2837
|
+
let lockedBefore = before;
|
|
2838
|
+
if (verdict === 'match' && this.transact) {
|
|
2839
|
+
// S1.4 (v37 P2-7): a violating transact backend would leave `verdict` at
|
|
2840
|
+
// its pre-read value, so the "removed" claim (and the audit record and the
|
|
2841
|
+
// mutation event) would all be false while the file stayed on disk.
|
|
2842
|
+
// Holder (not a `let`): the control-flow analysis cannot see the transact
|
|
2843
|
+
// callback's assignment and would narrow a plain flag to a literal.
|
|
2844
|
+
const ran = { done: false };
|
|
2845
|
+
await this.transact(this.io, target, (current) => {
|
|
2846
|
+
ran.done = true;
|
|
2847
|
+
verdict = anchorVerdict(anchor, current);
|
|
2848
|
+
// An unanchored caller accepts ANY current, so the locked bytes are the ones the
|
|
2849
|
+
// delete replaces — `null` (the file vanished before the lock) keeps the pre-read.
|
|
2850
|
+
if (verdict === 'match' && current !== null)
|
|
2851
|
+
lockedBefore = current;
|
|
2852
|
+
return verdict === 'match' ? null : current;
|
|
2853
|
+
});
|
|
2854
|
+
if (!ran.done)
|
|
2855
|
+
return { ok: false, message: '内部错误:删除事务没有调用任务;没有删除任何东西' };
|
|
2856
|
+
}
|
|
2857
|
+
else if (verdict === 'match') {
|
|
2858
|
+
await this.io.remove(target);
|
|
2859
|
+
}
|
|
2860
|
+
if (verdict !== 'match')
|
|
2861
|
+
return anchorRefusalFile(name, filePath, verdict);
|
|
2862
|
+
await this.audit(name, 'remove_file', lockedBefore, null, `removed ${filePath}`, filePath, unitId);
|
|
2863
|
+
this.notifyMutation({ action: 'remove_file', name, skillDir: dir, file: target });
|
|
2864
|
+
return { ok: true, message: `支撑文件「${filePath}」已从「${name}」移除。`, path: target };
|
|
2865
|
+
}
|
|
2866
|
+
/**
|
|
2867
|
+
* v23 (ML-1): `.archive` retention. Archived skills are recoverable history,
|
|
2868
|
+
* but nothing bounded their growth — the curator auto-archives idle skills,
|
|
2869
|
+
* consolidation and manual deletes take the same path, and every snapshot
|
|
2870
|
+
* copies the whole `.archive` (keep-5 retention amplifies it ×6). Entries
|
|
2871
|
+
* older than this many days are pruned at snapshot time. Generous by
|
|
2872
|
+
* design: a year-old auto-archive is effectively dead recoverability.
|
|
2873
|
+
* Backends without the mtime probe skip pruning (no false deletes on
|
|
2874
|
+
* unknown age).
|
|
2875
|
+
*/
|
|
2876
|
+
/**
|
|
2877
|
+
* 0.5.0 V1 (design §16.6-④): the retention window's READ half. Names every
|
|
2878
|
+
* archived entry past the window without touching it — the report path the
|
|
2879
|
+
* default policy uses, and the input the curator's run report carries.
|
|
2880
|
+
* @returns the expired entry names ([] when the backend has no mtime probe,
|
|
2881
|
+
* because an unknown age must never read as expired).
|
|
2882
|
+
*/
|
|
2883
|
+
async expiredArchives() {
|
|
2884
|
+
const archiveRoot = join(this.root, '.archive');
|
|
2885
|
+
// Three-state read (N14), the same helper every other listing uses: a
|
|
2886
|
+
// version-control-free listing failure is UNKNOWN, and the report must say so
|
|
2887
|
+
// rather than claim nothing expired.
|
|
2888
|
+
const listed = await probeList(this.io, archiveRoot);
|
|
2889
|
+
if (isUnknown(listed))
|
|
2890
|
+
return null;
|
|
2891
|
+
if (!isPresent(listed))
|
|
2892
|
+
return [];
|
|
2893
|
+
if (!this.io.mtime)
|
|
2894
|
+
return null;
|
|
2895
|
+
const cutoff = Date.now() - ARCHIVE_RETENTION_DAYS * 86_400_000;
|
|
2896
|
+
const expired = [];
|
|
2897
|
+
let undated = 0;
|
|
2898
|
+
for (const entry of listed.value) {
|
|
2899
|
+
// N36: a failed mtime probe is not "not expired" — the entry is RETAINED (the safe direction)
|
|
2900
|
+
// and the count is reported, so a broken archive cannot look like a fully swept one.
|
|
2901
|
+
const probe = await probeMtime(this.io, join(archiveRoot, entry));
|
|
2902
|
+
if (isUnknown(probe)) {
|
|
2903
|
+
undated += 1;
|
|
2904
|
+
continue;
|
|
2905
|
+
}
|
|
2906
|
+
if (isAbsent(probe) || probe.value > cutoff)
|
|
2907
|
+
continue;
|
|
2908
|
+
expired.push(entry);
|
|
2909
|
+
}
|
|
2910
|
+
if (undated > 0)
|
|
2911
|
+
console.warn(`skill-store: ${undated} archived entr(ies) could not be dated (mtime probe failed); they are retained — a failed probe never expires an entry`);
|
|
2912
|
+
return expired;
|
|
2913
|
+
}
|
|
2914
|
+
/** V4 (design §16.6): support-file char policy, resolved at the call site. */
|
|
2915
|
+
supportFileCharPolicy() {
|
|
2916
|
+
return this.limits.supportFileCharPolicy ?? DEFAULT_SUPPORT_FILE_CHAR_POLICY;
|
|
2917
|
+
}
|
|
2918
|
+
/** Retention policy resolved at the call site (absent limits object = report).
|
|
2919
|
+
* Public so a run report can say WHICH policy produced its numbers. */
|
|
2920
|
+
archiveRetentionPolicy() {
|
|
2921
|
+
return this.limits.archiveRetention ?? DEFAULT_ARCHIVE_RETENTION_POLICY;
|
|
2922
|
+
}
|
|
2923
|
+
async pruneExpiredArchives() {
|
|
2924
|
+
// ONE listing site for both halves of retention (expiredArchives decides what
|
|
2925
|
+
// is expired; this only deletes what it named).
|
|
2926
|
+
const expired = await this.expiredArchives();
|
|
2927
|
+
if (expired === null)
|
|
2928
|
+
return;
|
|
2929
|
+
for (const entry of expired) {
|
|
2930
|
+
const entryPath = join(this.root, '.archive', entry);
|
|
2931
|
+
await this.io.remove(entryPath).catch(() => { });
|
|
2932
|
+
console.warn(`skill-store: pruned archived skill "${entry}" (older than ${ARCHIVE_RETENTION_DAYS} days; recoverable from snapshots until they rotate)`);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
2936
|
+
* Snapshot the recoverable skills state: active tree, usage/suppression
|
|
2937
|
+
* sidecars, `.archive/` and caller-supplied extras. `extras` are opaque
|
|
2938
|
+
* side files the Snapshot owner cares about (curator state); they are
|
|
2939
|
+
* listed in the manifest and only those names are ever read back.
|
|
2940
|
+
*/
|
|
2941
|
+
async snapshotAll(reason = 'pre-mutation', extras = []) {
|
|
2942
|
+
// v23 (ML-1): retention runs BEFORE the copy, so the snapshot does not
|
|
2943
|
+
// enshrine entries that are about to be pruned.
|
|
2944
|
+
// 0.5.0 V1: only the `prune` policy deletes; the default `report` names the
|
|
2945
|
+
// expired entries and leaves them alone (upstream's never-delete invariant,
|
|
2946
|
+
// and §14's "no automatic deletion"). The report reaches the operator through
|
|
2947
|
+
// the curator run report, not only through a warn line here.
|
|
2948
|
+
if (this.archiveRetentionPolicy() === 'prune') {
|
|
2949
|
+
await this.pruneExpiredArchives();
|
|
2950
|
+
}
|
|
2951
|
+
else {
|
|
2952
|
+
const expired = await this.expiredArchives();
|
|
2953
|
+
if (expired === null) {
|
|
2954
|
+
console.warn('skill-store: archive retention state UNKNOWN — the .archive listing could not be read (archiveRetention=report, nothing deleted)');
|
|
2955
|
+
}
|
|
2956
|
+
else if (expired.length > 0) {
|
|
2957
|
+
console.warn(`skill-store: ${expired.length} archived skill(s) past the ${ARCHIVE_RETENTION_DAYS}-day window kept (archiveRetention=report): ${expired.slice(0, 5).join(', ')}`);
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
const backupRoot = join(this.root, '.backups');
|
|
2961
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
2962
|
+
let dest = join(backupRoot, `skills-${stamp}`);
|
|
2963
|
+
// Same-millisecond collision guard: two snapshots in one ms (e.g.
|
|
2964
|
+
// restoreLatestSnapshot's pre-rollback snapshot racing the snapshot it is
|
|
2965
|
+
// about to restore from) used to share one directory, and the later copy
|
|
2966
|
+
// overwrote the earlier manifest — a restore then read the WRONG tree
|
|
2967
|
+
// (rc.42-audit-adjacent, flaked the boundary snapshot/restore test).
|
|
2968
|
+
while (await this.io.exists(dest)) {
|
|
2969
|
+
dest = join(backupRoot, `skills-${stamp}-${Math.random().toString(36).slice(2, 8)}`);
|
|
2970
|
+
}
|
|
2971
|
+
try {
|
|
2972
|
+
const names = await listNames(this.root, this.io);
|
|
2973
|
+
// Parallel copies: snapshot backups touch disjoint directories, and the
|
|
2974
|
+
// per-path write locks never contend (P2-6). A1-18 (v18): allSettled
|
|
2975
|
+
// (not all) so the cleanup below cannot race a still-running copy and
|
|
2976
|
+
// leave a manifest-less orphan directory behind.
|
|
2977
|
+
// V24-20b (v24): the copy joins the destructive-movers' probe discipline
|
|
2978
|
+
// (archive / restore / whole-tree restore all probe `hasWriteLock`
|
|
2979
|
+
// before touching a skill directory). A byte-writer mid-flight used to
|
|
2980
|
+
// surface either as an ENOENT copy failure (rename between the list and
|
|
2981
|
+
// the copy) or — worse — as a TEARSORED snapshot: the copy passed
|
|
2982
|
+
// SKILL.md before the rename and picked up support files after it, and
|
|
2983
|
+
// that mixed generation was exactly what restoreLatestSnapshot would
|
|
2984
|
+
// roll back to. A locked skill is now SKIPPED (recorded in the
|
|
2985
|
+
// manifest as `skipped`, not silently absent) — a partial-but-honest
|
|
2986
|
+
// snapshot beats a torn one.
|
|
2987
|
+
const skipped = [];
|
|
2988
|
+
const copyable = [];
|
|
2989
|
+
for (const name of names) {
|
|
2990
|
+
if (await this.supportDirsUnlistable(this.dirOf(name))) {
|
|
2991
|
+
console.warn(`skill-store: snapshot skipped "${name}" — its support directory could not be listed; the skill is recorded as skipped in the manifest`);
|
|
2992
|
+
skipped.push(name);
|
|
2993
|
+
}
|
|
2994
|
+
else {
|
|
2995
|
+
copyable.push(name);
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
const copyResults = await Promise.allSettled(copyable.map(async (name) => {
|
|
2999
|
+
await this.io.copy(this.dirOf(name), join(dest, name));
|
|
3000
|
+
}));
|
|
3001
|
+
const copyFailure = copyResults.find((result) => result.status === 'rejected');
|
|
3002
|
+
if (copyFailure)
|
|
3003
|
+
throw copyFailure.reason;
|
|
3004
|
+
// OPT-07 (2026-09): post-copy lock RE-PROBE. The probe above runs
|
|
3005
|
+
// before the copies; a byte-writer that started AFTER its skill was
|
|
3006
|
+
// probed but BEFORE its copy landed used to produce a torn snapshot
|
|
3007
|
+
// whose manifest still said `skipped: []` — restoreLatestSnapshot
|
|
3008
|
+
// would then roll the live tree back over that writer's landed bytes.
|
|
3009
|
+
// Re-probing keeps the manifest honest: any skill whose lock appeared
|
|
3010
|
+
// mid-copy is demoted to `skipped` (its copied bytes are suspect and
|
|
3011
|
+
// never referenced by a restore — restoreLatestSnapshot refuses an
|
|
3012
|
+
// incomplete snapshot outright, V27 G0.1). The stale directory copy
|
|
3013
|
+
// stays on disk but is unreachable through the manifest.
|
|
3014
|
+
const suspect = [];
|
|
3015
|
+
for (const name of copyable) {
|
|
3016
|
+
if (await this.supportDirsUnlistable(this.dirOf(name)))
|
|
3017
|
+
suspect.push(name);
|
|
3018
|
+
}
|
|
3019
|
+
if (suspect.length > 0) {
|
|
3020
|
+
for (const name of suspect) {
|
|
3021
|
+
console.warn(`skill-store: snapshot demoted "${name}" to skipped — a write lock appeared while its copy ran; the copied bytes are suspect`);
|
|
3022
|
+
}
|
|
3023
|
+
for (const name of suspect) {
|
|
3024
|
+
copyable.splice(copyable.indexOf(name), 1);
|
|
3025
|
+
}
|
|
3026
|
+
skipped.push(...suspect);
|
|
3027
|
+
}
|
|
3028
|
+
// Sidecar co-snapshot: a rollback that restores the tree but leaves the
|
|
3029
|
+
// post-archival usage/suppression state behind would immediately let the
|
|
3030
|
+
// curator re-decide on stale records (rollback integrity).
|
|
3031
|
+
const sidecars = [];
|
|
3032
|
+
for (const sidecar of [usageFile(this.root), suppressedFile(this.root)]) {
|
|
3033
|
+
if (await this.io.exists(sidecar)) {
|
|
3034
|
+
const name = basename(sidecar);
|
|
3035
|
+
await this.io.copy(sidecar, join(dest, name));
|
|
3036
|
+
sidecars.push(name);
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
// Archive co-snapshot: rollback must restore what was archived at snapshot
|
|
3040
|
+
// time too — archived skills are the recoverable history, and a restore
|
|
3041
|
+
// that leaves a post-run `.archive/` behind breaks the archive invariant
|
|
3042
|
+
// (Hermes curator_backup backs up `.archive/` as well).
|
|
3043
|
+
const archiveRoot = join(this.root, '.archive');
|
|
3044
|
+
let hasArchive = false;
|
|
3045
|
+
if (await this.io.exists(archiveRoot)) {
|
|
3046
|
+
await this.io.copy(archiveRoot, join(dest, '.archive'));
|
|
3047
|
+
hasArchive = true;
|
|
3048
|
+
}
|
|
3049
|
+
// v29 SK-08: invalid extras fail LOUD instead of two silent wrongs — an
|
|
3050
|
+
// out-of-grammar name used to be dropped with no result surface (the
|
|
3051
|
+
// data silently skipped every rollback), and a non-string name coerced
|
|
3052
|
+
// through RegExp.test then threw a bare TypeError inside path.join.
|
|
3053
|
+
// The manifest READER already sanitizes this shape (V27 G0.4); the
|
|
3054
|
+
// writer now refuses it at the same boundary.
|
|
3055
|
+
// The scan runs through the `unknown` element boundary on purpose: the
|
|
3056
|
+
// runtime payload may violate the declared SnapshotExtra shape (the
|
|
3057
|
+
// exact defect this guard refuses).
|
|
3058
|
+
const extrasAsUnknown = extras;
|
|
3059
|
+
const rejectedExtras = extrasAsUnknown
|
|
3060
|
+
.filter(extra => typeof extra?.name !== 'string' || !SNAPSHOT_EXTRA_NAME_RE.test(extra.name))
|
|
3061
|
+
.map(extra => JSON.stringify(extra?.name ?? extra));
|
|
3062
|
+
if (rejectedExtras.length > 0) {
|
|
3063
|
+
throw new Error(`snapshotAll: refusing invalid snapshot extras (name must match ${SNAPSHOT_EXTRA_NAME_RE.source}): ${rejectedExtras.join(', ')}`);
|
|
3064
|
+
}
|
|
3065
|
+
const validExtras = extras;
|
|
3066
|
+
const extraNames = validExtras.map(extra => extra.name);
|
|
3067
|
+
const extraResults = await Promise.allSettled(validExtras.map(async (extra) => {
|
|
3068
|
+
await this.io.writeText(join(dest, 'extras', extra.name), extra.content);
|
|
3069
|
+
}));
|
|
3070
|
+
const extraFailure = extraResults.find((result) => result.status === 'rejected');
|
|
3071
|
+
if (extraFailure)
|
|
3072
|
+
throw extraFailure.reason;
|
|
3073
|
+
await this.io.writeText(join(dest, 'manifest.json'), JSON.stringify({
|
|
3074
|
+
reason,
|
|
3075
|
+
createdAt: new Date().toISOString(),
|
|
3076
|
+
skills: copyable,
|
|
3077
|
+
skipped,
|
|
3078
|
+
sidecars,
|
|
3079
|
+
hasArchive,
|
|
3080
|
+
extras: extraNames,
|
|
3081
|
+
}, null, 2));
|
|
3082
|
+
await this.retainSnapshots(5);
|
|
3083
|
+
}
|
|
3084
|
+
catch (error) {
|
|
3085
|
+
// P2-5 (v11): a mid-snapshot failure used to leave a manifest-less
|
|
3086
|
+
// orphan directory behind — retainSnapshots only counts listed
|
|
3087
|
+
// snapshots, so it was never reaped (unbounded growth on repeated
|
|
3088
|
+
// failures). Best-effort removal of the half-written snapshot.
|
|
3089
|
+
await this.io.remove(dest).catch(() => { });
|
|
3090
|
+
throw error;
|
|
3091
|
+
}
|
|
3092
|
+
return dest;
|
|
3093
|
+
}
|
|
3094
|
+
/** Read and normalize a snapshot manifest; null when the file is missing or unparsable. */
|
|
3095
|
+
/**
|
|
3096
|
+
* V26-03 (v25/v26): sanitize the manifest's `skipped` list before it can
|
|
3097
|
+
* reach a user-visible restore message. Entries must pass the same name
|
|
3098
|
+
* gate as snapshot entries (a corrupted or hand-edited manifest cannot
|
|
3099
|
+
* inject arbitrary text into the result), bounded to 50 entries of at most
|
|
3100
|
+
* 64 chars each (the name-rule maximum — real skill names always fit).
|
|
3101
|
+
*/
|
|
3102
|
+
sanitizeSkippedNames(raw) {
|
|
3103
|
+
if (!Array.isArray(raw))
|
|
3104
|
+
return [];
|
|
3105
|
+
const out = [];
|
|
3106
|
+
for (const entry of raw) {
|
|
3107
|
+
if (typeof entry !== 'string')
|
|
3108
|
+
continue;
|
|
3109
|
+
if (!this.safeSnapshotEntryName(entry))
|
|
3110
|
+
continue;
|
|
3111
|
+
out.push(entry.length > 64 ? entry.slice(0, 64) : entry);
|
|
3112
|
+
if (out.length >= 50)
|
|
3113
|
+
break;
|
|
3114
|
+
}
|
|
3115
|
+
return out;
|
|
3116
|
+
}
|
|
3117
|
+
/**
|
|
3118
|
+
* V27 G0.4 (core-a-01): the per-entry gate `skipped` already has. A corrupted
|
|
3119
|
+
* or hand-edited manifest could carry `extras: [123]`: the array check passed,
|
|
3120
|
+
* `SNAPSHOT_EXTRA_NAME_RE.test(123)` coerced the number to the string "123"
|
|
3121
|
+
* and matched, and the value then threw `TypeError` inside `path.join` — which
|
|
3122
|
+
* `readSnapshotExtras` reached only AFTER a whole-tree restore had committed.
|
|
3123
|
+
* Extras are path components under `extras/`, so they take the entry gate too;
|
|
3124
|
+
* bounded like `skipped` so a hostile manifest cannot grow the read set.
|
|
3125
|
+
*/
|
|
3126
|
+
sanitizeExtraNames(raw) {
|
|
3127
|
+
if (!Array.isArray(raw))
|
|
3128
|
+
return [];
|
|
3129
|
+
const out = [];
|
|
3130
|
+
for (const entry of raw) {
|
|
3131
|
+
if (typeof entry !== 'string')
|
|
3132
|
+
continue;
|
|
3133
|
+
if (!SNAPSHOT_EXTRA_NAME_RE.test(entry))
|
|
3134
|
+
continue;
|
|
3135
|
+
if (!this.safeSnapshotEntryName(entry))
|
|
3136
|
+
continue;
|
|
3137
|
+
out.push(entry);
|
|
3138
|
+
if (out.length >= 50)
|
|
3139
|
+
break;
|
|
3140
|
+
}
|
|
3141
|
+
return out;
|
|
3142
|
+
}
|
|
3143
|
+
async readSnapshotManifest(path) {
|
|
3144
|
+
const raw = await this.io.readText(join(path, 'manifest.json'));
|
|
3145
|
+
if (raw === null)
|
|
3146
|
+
return null;
|
|
3147
|
+
try {
|
|
3148
|
+
const manifest = JSON.parse(raw);
|
|
3149
|
+
// A1-3 (v18): `skills` is the authoritative list that drives the
|
|
3150
|
+
// destructive clear. A manifest without it (or with a non-array value)
|
|
3151
|
+
// is invalid — returning `[]` here made restore clear the whole tree and
|
|
3152
|
+
// report success. The caller refuses an existing-but-invalid manifest
|
|
3153
|
+
// before touching the live tree.
|
|
3154
|
+
if (!Array.isArray(manifest.skills))
|
|
3155
|
+
return null;
|
|
3156
|
+
return {
|
|
3157
|
+
reason: typeof manifest.reason === 'string' ? manifest.reason : '',
|
|
3158
|
+
createdAt: typeof manifest.createdAt === 'string' ? manifest.createdAt : '',
|
|
3159
|
+
skills: manifest.skills,
|
|
3160
|
+
// V25-05 (v25): preserve the skipped list so the restore path can
|
|
3161
|
+
// surface the skills a whole-tree restore will NOT bring back.
|
|
3162
|
+
// V26-03 (v26): the list is SANITIZED before it can reach a
|
|
3163
|
+
// user-visible message — entries must pass the same name gate as
|
|
3164
|
+
// snapshot entries (a corrupted/hand-edited manifest cannot inject
|
|
3165
|
+
// arbitrary text), bounded to 50 entries × 64 chars (the name-rule
|
|
3166
|
+
// maximum).
|
|
3167
|
+
skipped: this.sanitizeSkippedNames(manifest.skipped),
|
|
3168
|
+
sidecars: Array.isArray(manifest.sidecars) ? manifest.sidecars : [],
|
|
3169
|
+
...typeof manifest.hasArchive === 'boolean' ? { hasArchive: manifest.hasArchive } : {},
|
|
3170
|
+
extras: this.sanitizeExtraNames(manifest.extras),
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
catch {
|
|
3174
|
+
return null;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
/** Keep only the newest N snapshots (Hermes keep=5 parity); older ones are removed outright. */
|
|
3178
|
+
/**
|
|
3179
|
+
* G3/3.4: can a snapshot actually be used right now?
|
|
3180
|
+
*
|
|
3181
|
+
* The recovery messages used to promise "restore it from a snapshot" without asking whether a usable
|
|
3182
|
+
* snapshot exists (audit S1-01: the message promised recoverability from a chain that is five deep and
|
|
3183
|
+
* whose newest member held 9 of 35 skills). Three states, like every other reading here: a fragment
|
|
3184
|
+
* naming the usable snapshot, a fragment saying there is none, or a fragment saying the directory could
|
|
3185
|
+
* not be read - the caller embeds whichever it gets, so the sentence is generated by the capability.
|
|
3186
|
+
*/
|
|
3187
|
+
async snapshotRecoveryAvailability() {
|
|
3188
|
+
const listed = await this.listSnapshots();
|
|
3189
|
+
if (isUnknown(listed)) {
|
|
3190
|
+
return '快照目录读不出来(' + listed.reason + ')';
|
|
3191
|
+
}
|
|
3192
|
+
const latest = isPresent(listed) ? listed.value[0] : undefined;
|
|
3193
|
+
if (!latest)
|
|
3194
|
+
return '当前没有可用快照';
|
|
3195
|
+
return '最新快照 ' + latest.path;
|
|
3196
|
+
}
|
|
3197
|
+
async retainSnapshots(keep) {
|
|
3198
|
+
const listed = await this.listSnapshots();
|
|
3199
|
+
// N14: an UNREADABLE .backups is not "no snapshots". Pruning nothing is the
|
|
3200
|
+
// only safe answer there (never delete what we cannot enumerate).
|
|
3201
|
+
if (isUnknown(listed)) {
|
|
3202
|
+
console.warn(`skill-store: snapshot retention skipped — the snapshot directory could not be listed (${listed.reason})`);
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
const snapshots = isPresent(listed) ? listed.value : [];
|
|
3206
|
+
for (const snapshot of snapshots.slice(keep)) {
|
|
3207
|
+
try {
|
|
3208
|
+
await this.io.remove(snapshot.path);
|
|
3209
|
+
}
|
|
3210
|
+
catch {
|
|
3211
|
+
// Best-effort pruning: a failed removal must not fail the snapshot itself.
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
/** Snapshots as a THREE-state read (N14): absent = no .backups directory,
|
|
3216
|
+
* unknown = it exists but could not be listed (a caller must not read a
|
|
3217
|
+
* failed listing as "no snapshots to restore"). */
|
|
3218
|
+
async listSnapshots() {
|
|
3219
|
+
const backupRoot = join(this.root, '.backups');
|
|
3220
|
+
const listed = await probeList(this.io, backupRoot);
|
|
3221
|
+
if (!isPresent(listed))
|
|
3222
|
+
return listed;
|
|
3223
|
+
const out = [];
|
|
3224
|
+
for (const name of listed.value.sort().reverse()) {
|
|
3225
|
+
if (!name.startsWith('skills-'))
|
|
3226
|
+
continue;
|
|
3227
|
+
const manifest = await this.readSnapshotManifest(join(backupRoot, name));
|
|
3228
|
+
if (manifest === null) {
|
|
3229
|
+
// v23 (ML-2): a manifest-less/corrupt snapshot used to ESCAPE the
|
|
3230
|
+
// retention window entirely (list → skip → never reaped), and each
|
|
3231
|
+
// orphan is a full tree+.archive copy. Surface it with an empty
|
|
3232
|
+
// createdAt — the sort treats '' as oldest, so retainSnapshots evicts
|
|
3233
|
+
// orphans FIRST while a genuinely recent valid snapshot stays ahead.
|
|
3234
|
+
out.push({ path: join(backupRoot, name), createdAt: '', reason: '清单读不出来或缺失(孤儿快照)' });
|
|
3235
|
+
continue;
|
|
3236
|
+
}
|
|
3237
|
+
out.push({ path: join(backupRoot, name), createdAt: manifest.createdAt, reason: manifest.reason });
|
|
3238
|
+
}
|
|
3239
|
+
// A1-21 (v18): the manifest's createdAt is the authoritative recency
|
|
3240
|
+
// (same-millisecond random-suffix names can sort wrongly by name).
|
|
3241
|
+
out.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '') || b.path.localeCompare(a.path));
|
|
3242
|
+
return probePresent(out);
|
|
3243
|
+
}
|
|
3244
|
+
/**
|
|
3245
|
+
* Read the extras of a snapshot, restricted to the names declared in the
|
|
3246
|
+
* manifest — an `extras/` directory is never listed directly, so unknown
|
|
3247
|
+
* files cannot leak back as state on the next restore.
|
|
3248
|
+
*/
|
|
3249
|
+
async readSnapshotExtras(path) {
|
|
3250
|
+
const manifest = await this.readSnapshotManifest(path);
|
|
3251
|
+
if (manifest === null)
|
|
3252
|
+
return [];
|
|
3253
|
+
const extras = [];
|
|
3254
|
+
for (const name of manifest.extras) {
|
|
3255
|
+
if (!SNAPSHOT_EXTRA_NAME_RE.test(name))
|
|
3256
|
+
continue;
|
|
3257
|
+
const content = await this.io.readText(join(path, 'extras', name));
|
|
3258
|
+
if (content !== null)
|
|
3259
|
+
extras.push({ name, content });
|
|
3260
|
+
}
|
|
3261
|
+
return extras;
|
|
3262
|
+
}
|
|
3263
|
+
/**
|
|
3264
|
+
* Manifest-driven restore of the latest snapshot: active tree, sidecars,
|
|
3265
|
+
* `.archive/` and (for full-state snapshots) the extras read back by the
|
|
3266
|
+
* caller. `extras` are additionally written into the pre-rollback safety
|
|
3267
|
+
* snapshot so the rollback itself is undoable with the same state.
|
|
3268
|
+
*/
|
|
3269
|
+
async restoreLatestSnapshot(extras = []) {
|
|
3270
|
+
const listed = await this.listSnapshots();
|
|
3271
|
+
// N14: a failed listing is NOT "no snapshot" — say which one happened, and
|
|
3272
|
+
// refuse before the pre-rollback snapshot (nothing has changed yet).
|
|
3273
|
+
if (isUnknown(listed)) {
|
|
3274
|
+
return { ok: false, message: `拒绝快照恢复:快照目录读不出来(${listed.reason})—— 未做任何改动。` };
|
|
3275
|
+
}
|
|
3276
|
+
const latest = isPresent(listed) ? listed.value[0] : undefined;
|
|
3277
|
+
if (!latest)
|
|
3278
|
+
return { ok: false, message: '没有可用的技能快照。' };
|
|
3279
|
+
// v29 SK-07: the pre-rollback snapshot is the rollback INSURANCE, not part
|
|
3280
|
+
// of the restore — a failure taking it (win32 AV/indexer hold on the copy,
|
|
3281
|
+
// ENOSPC) used to reject raw out of this method before the E-13 rollback
|
|
3282
|
+
// machinery could speak. Nothing has been cleared at this point, so the
|
|
3283
|
+
// refusal is structured and says the tree is untouched.
|
|
3284
|
+
let preRollbackPath;
|
|
3285
|
+
try {
|
|
3286
|
+
preRollbackPath = await this.snapshotAll('pre-rollback', extras);
|
|
3287
|
+
}
|
|
3288
|
+
catch (error) {
|
|
3289
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
3290
|
+
return { ok: false, message: `在任何清理之前就拒绝了快照恢复:生成回滚前快照失败(${reason})—— 活动目录树未改变。请解决快照写入失败后重试。` };
|
|
3291
|
+
}
|
|
3292
|
+
// V27 G0.4 (core-a-01): the extras read happens BEFORE the destructive
|
|
3293
|
+
// replace. It used to run after the commit point and outside the rollback
|
|
3294
|
+
// try, so a corrupt manifest (`extras: [123]`) or one real read failure
|
|
3295
|
+
// (EACCES/EBUSY — the seam maps only "missing" to null) threw once the tree
|
|
3296
|
+
// had already been replaced: the mutation event never fired (the catalog
|
|
3297
|
+
// kept serving the pre-restore view) and the caller was told the restore
|
|
3298
|
+
// failed even though it had succeeded — and retrying cleared the tree again.
|
|
3299
|
+
// Reading here means a bad manifest aborts before anything is cleared.
|
|
3300
|
+
// S1-E2: a real READ failure on the manifest/extras (transient win32 AV or
|
|
3301
|
+
// indexer hold) used to escape this method as a raw rejection BETWEEN the
|
|
3302
|
+
// two protected tries — the caller's structured SkillActionResult contract
|
|
3303
|
+
// broke exactly on the path the comment above claims is covered. Nothing
|
|
3304
|
+
// has been cleared, so the refusal is structured and retryable, matching
|
|
3305
|
+
// the pre-rollback posture above.
|
|
3306
|
+
let snapshotExtras;
|
|
3307
|
+
try {
|
|
3308
|
+
snapshotExtras = await this.readSnapshotExtras(latest.path);
|
|
3309
|
+
}
|
|
3310
|
+
catch (error) {
|
|
3311
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
3312
|
+
return { ok: false, message: `在任何清理之前就拒绝了快照恢复:读取快照清单或附属文件失败(${reason})—— 活动目录树未改变。请解决读取失败后重试。` };
|
|
3313
|
+
}
|
|
3314
|
+
try {
|
|
3315
|
+
await this.restoreSnapshotIntoRoot(latest.path);
|
|
3316
|
+
}
|
|
3317
|
+
catch (error) {
|
|
3318
|
+
// 0.3.16 (E-13): the old shape cleared the active root and then restored
|
|
3319
|
+
// with NO protection — a damaged/incomplete snapshot left the tree
|
|
3320
|
+
// empty and the caller received a raw rejection. Roll back to the
|
|
3321
|
+
// pre-rollback snapshot just taken; if that fails too, name both paths
|
|
3322
|
+
// so the operator can rescue by hand.
|
|
3323
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
3324
|
+
try {
|
|
3325
|
+
await this.restoreSnapshotIntoRoot(preRollbackPath);
|
|
3326
|
+
return { ok: false, message: `快照恢复失败(${reason});活动目录树已回滚到回滚前的快照。` };
|
|
3327
|
+
}
|
|
3328
|
+
catch (rollbackError) {
|
|
3329
|
+
const rb = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
|
3330
|
+
// v28 G2.5 (CORE-SK-03): both failures were PRE-CLEAR refusals
|
|
3331
|
+
// (manifest / completeness / live-writer gates) — the active tree is
|
|
3332
|
+
// byte-identical to before the attempt, so the old "Rescue manually"
|
|
3333
|
+
// sent operators hand-rescuing an intact library (and repeated on
|
|
3334
|
+
// every retry while the writer held the lock).
|
|
3335
|
+
if (isPreClearRefusal(reason) && isPreClearRefusal(rb)) {
|
|
3336
|
+
return {
|
|
3337
|
+
ok: false,
|
|
3338
|
+
message: `在任何清理之前就拒绝了快照恢复; 活动目录树未改变。\n- 目标:${reason}\n- 回滚前快照:${rb}\n请解决原因(有存活的技能写入,或快照不完整)后重试 —— 无需手动抢救。`,
|
|
3339
|
+
};
|
|
3340
|
+
}
|
|
3341
|
+
return { ok: false, message: `快照恢复失败(${reason}),且回滚前快照的恢复也失败(${rb})。请手动抢救:${preRollbackPath}(回滚前)、${latest.path}(目标)。` };
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
// Whole-tree replacement: a single synthetic event invalidates the catalog
|
|
3345
|
+
// regardless of how many skills the restore touched (decision C).
|
|
3346
|
+
this.notifyMutation({ action: 'restore', name: 'snapshot' });
|
|
3347
|
+
// V27 G0.1 (core-a-10): a successful restore is always COMPLETE — an
|
|
3348
|
+
// incomplete snapshot is refused before the clear (see
|
|
3349
|
+
// `restoreSnapshotIntoRoot`), so there is no "skipped but restored" message
|
|
3350
|
+
// to render any more: the refusal names the skills instead.
|
|
3351
|
+
return {
|
|
3352
|
+
ok: true,
|
|
3353
|
+
message: `已从 ${latest.path} 恢复技能目录树`,
|
|
3354
|
+
path: latest.path,
|
|
3355
|
+
...snapshotExtras.length === 0 ? {} : { extras: snapshotExtras },
|
|
3356
|
+
};
|
|
3357
|
+
}
|
|
3358
|
+
/**
|
|
3359
|
+
* Whole-tree replacement from one snapshot path (rc.50 P2-14): every
|
|
3360
|
+
* NON-system entry in the active root is cleared first, then the manifest
|
|
3361
|
+
* drives the repopulation (skills, sidecars, `.archive`). Extracted from
|
|
3362
|
+
* restoreLatestSnapshot so a failed restore can roll itself back (E-13).
|
|
3363
|
+
*/
|
|
3364
|
+
async restoreSnapshotIntoRoot(snapshotPath) {
|
|
3365
|
+
// A1-3 (v18): read and validate the manifest BEFORE clearing anything.
|
|
3366
|
+
// The old order cleared the active tree first, then treated an
|
|
3367
|
+
// unreadable/skills-less manifest as "restore nothing" and still reported
|
|
3368
|
+
// ok:true. A manifest.json that exists but cannot be parsed is corruption,
|
|
3369
|
+
// not a legacy snapshot, and must refuse before the destructive clear.
|
|
3370
|
+
const manifest = await this.readSnapshotManifest(snapshotPath);
|
|
3371
|
+
if (manifest === null) {
|
|
3372
|
+
// A1-16 + P2-3 (v19): the refusal must precede EVERY destructive step.
|
|
3373
|
+
// The v18 shape cleared the active tree first and only then rejected a
|
|
3374
|
+
// manifest-less snapshot, so a manifest lost between listSnapshots and
|
|
3375
|
+
// this restore emptied the live library before failing.
|
|
3376
|
+
throw new Error(await this.io.exists(join(snapshotPath, 'manifest.json'))
|
|
3377
|
+
? `snapshot ${snapshotPath} has an unreadable manifest.json; refusing to clear the active tree`
|
|
3378
|
+
: `snapshot ${snapshotPath} has no readable manifest.json; refusing to restore`);
|
|
3379
|
+
}
|
|
3380
|
+
{
|
|
3381
|
+
// A1-4 (v18): every manifest-declared name is a path component copied
|
|
3382
|
+
// into the skills root — reject traversal/absolute shapes up front.
|
|
3383
|
+
for (const name of [...manifest.skills, ...manifest.sidecars]) {
|
|
3384
|
+
if (!this.safeSnapshotEntryName(name)) {
|
|
3385
|
+
throw new Error(`snapshot ${snapshotPath} declares an unsafe entry name ${JSON.stringify(name)}; refusing to restore`);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
// V27 G0.1 (core-a-10): a snapshot that skipped skills is INCOMPLETE and
|
|
3389
|
+
// must never authorize the destructive clear. `snapshotAll` records every
|
|
3390
|
+
// skill it could not copy (a live byte-writer held its write lock) in
|
|
3391
|
+
// `manifest.skipped`, so such a snapshot holds everything EXCEPT those
|
|
3392
|
+
// skills; restoring it clears the live root and copies back only
|
|
3393
|
+
// `manifest.skills`, which DELETES the skipped skills from the live
|
|
3394
|
+
// library while reporting ok:true (v25 only added a message note). With
|
|
3395
|
+
// every skill locked the snapshot is empty and the whole library is wiped.
|
|
3396
|
+
// v24-20b (skip instead of tear) and v19 P1-2 (allow a genuinely empty
|
|
3397
|
+
// tree) are both right on their own; `skipped` was simply never consulted
|
|
3398
|
+
// for completeness. Restoring is therefore limited to COMPLETE snapshots:
|
|
3399
|
+
// the pre-rollback snapshot keeps the current tree, and the refusal names
|
|
3400
|
+
// the skills so the operator can retry once the writer has finished.
|
|
3401
|
+
if (manifest.skipped.length > 0) {
|
|
3402
|
+
throw new Error(`snapshot ${snapshotPath} is incomplete: ${manifest.skipped.length} skill(s) were skipped when it was taken `
|
|
3403
|
+
+ `(a live writer held their write lock: ${manifest.skipped.join(', ')}); restoring it would clear the active tree `
|
|
3404
|
+
+ 'without bringing them back — let the writer finish and take a fresh snapshot, then restore that one');
|
|
3405
|
+
}
|
|
3406
|
+
// A1-3 (v18): a manifest that declares no skills while the snapshot
|
|
3407
|
+
// directory contains UNDECLARED entries is inconsistent (a corrupted/
|
|
3408
|
+
// truncated manifest); clearing to an empty tree would lose the live
|
|
3409
|
+
// library. P1-2 (v19): the allowed set is derived from the manifest's own
|
|
3410
|
+
// declaration — `snapshotAll` co-copies the usage/suppression sidecars
|
|
3411
|
+
// into the snapshot root and lists them in `manifest.sidecars`, so the
|
|
3412
|
+
// v18 hardcoded three-name list rejected every healthy empty-tree
|
|
3413
|
+
// snapshot and killed the rollback channel.
|
|
3414
|
+
if (manifest.skills.length === 0) {
|
|
3415
|
+
const snapshotEntries = await this.io.list(snapshotPath);
|
|
3416
|
+
const declared = new Set(['manifest.json', 'extras', '.archive', ...manifest.skills, ...manifest.sidecars]);
|
|
3417
|
+
const undeclared = snapshotEntries.filter(entry => !declared.has(entry));
|
|
3418
|
+
if (undeclared.length > 0) {
|
|
3419
|
+
throw new Error(`snapshot ${snapshotPath} declares no skills but contains undeclared entries (${undeclared.join(', ')}); refusing to clear the active tree`);
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
let rootEntries;
|
|
3424
|
+
try {
|
|
3425
|
+
rootEntries = await this.io.list(this.root);
|
|
3426
|
+
}
|
|
3427
|
+
catch (error) {
|
|
3428
|
+
// S1.4 (v37 P1-5): a failed root listing must NOT read as "no entries".
|
|
3429
|
+
// The clear loop below would silently degrade into a merge (post-snapshot
|
|
3430
|
+
// skills survive), the live-writer refusal right after it would never run,
|
|
3431
|
+
// and the caller was still told ok:true.
|
|
3432
|
+
throw new Error(`snapshot restore refused: the skill root could not be listed (${error instanceof Error ? error.message : String(error)}) — nothing was changed`);
|
|
3433
|
+
}
|
|
3434
|
+
// A1-7 (v18): the snapshot channel has no writer probe in v17. Refuse while
|
|
3435
|
+
// a skill writer is active; dead lock residue is swept first so a crashed
|
|
3436
|
+
// writer does not block recovery. Root-level sidecar locks are checked the
|
|
3437
|
+
// same way (a live `.usage.json.lock` must not be deleted by the clear).
|
|
3438
|
+
for (const entry of rootEntries) {
|
|
3439
|
+
if (entry === '.archive' || entry === '.backups' || entry === '.mutations.json' || entry === '.curator-suppressed.json' || entry === HISTORY_DIR)
|
|
3440
|
+
continue;
|
|
3441
|
+
const dir = join(this.root, entry);
|
|
3442
|
+
if (await this.io.exists(join(dir, 'SKILL.md'))) {
|
|
3443
|
+
if (await this.supportDirsUnlistable(dir)) {
|
|
3444
|
+
throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
// A1-19 (v18): a snapshot that does not carry `.curator-suppressed.json`
|
|
3449
|
+
// must roll the live one away too (otherwise a post-snapshot suppression
|
|
3450
|
+
// survives as a ghost). The manifest branch copies it back when present.
|
|
3451
|
+
const restoresSuppressed = manifest.sidecars.includes('.curator-suppressed.json');
|
|
3452
|
+
// F-316 (0.3.25): only the system directories and the durable sidecars are
|
|
3453
|
+
// survived by a snapshot restore. A dot-entry that appeared AFTER the
|
|
3454
|
+
// snapshot (e.g. a fresh `.usage.json`) used to be kept by the blanket
|
|
3455
|
+
// `startsWith('.')` skip, leaving a ghost record that disagrees with the
|
|
3456
|
+
// restored tree (E-15 self-heal could converge, but the intermediate state
|
|
3457
|
+
// is observable). Usage is derived data — the next curator run seeds
|
|
3458
|
+
// empty records — so it is dropped with the tree; the mutation audit is
|
|
3459
|
+
// real history and stays. `.curator-suppressed.json` is a co-snapshotted
|
|
3460
|
+
// sidecar and comes back with the snapshot (a later suppression made after
|
|
3461
|
+
// the snapshot is rolled back with it — the correct rollback semantics).
|
|
3462
|
+
// A1-7 (v18): lock files are protocol state, never snapshot content; the
|
|
3463
|
+
// clear skips them (a live one was refused above, a dead one swept).
|
|
3464
|
+
// G3/3.1 (audit 3.1 + 1.6): the clear set is DERIVED and it is REPORTED before anything is removed.
|
|
3465
|
+
// Until this guard the loop walked the LIVE root and removed whatever the snapshot did not declare,
|
|
3466
|
+
// so "what will this restore delete" was stated nowhere; the only protection was the completeness
|
|
3467
|
+
// gate above. `doomed` is the derived difference: live entries minus the survival set minus every
|
|
3468
|
+
// name the manifest declares. It is warned about here and then used as the loop itself.
|
|
3469
|
+
const declared = new Set([...manifest.skills, ...manifest.sidecars]);
|
|
3470
|
+
const doomed = rootEntries.filter((entry) => {
|
|
3471
|
+
if (entry === '.archive' || entry === '.backups' || entry === '.mutations.json')
|
|
3472
|
+
return false;
|
|
3473
|
+
if (entry === HISTORY_DIR)
|
|
3474
|
+
return false;
|
|
3475
|
+
if (entry === '.curator-suppressed.json' && restoresSuppressed)
|
|
3476
|
+
return false;
|
|
3477
|
+
return !declared.has(entry);
|
|
3478
|
+
});
|
|
3479
|
+
if (doomed.length > 0) {
|
|
3480
|
+
console.warn('skill-store: snapshot restore will delete ' + String(doomed.length) + ' live entr(y|ies) the snapshot does not declare: ' + doomed.join(', '));
|
|
3481
|
+
}
|
|
3482
|
+
for (const entry of doomed) {
|
|
3483
|
+
await this.io.remove(join(this.root, entry));
|
|
3484
|
+
}
|
|
3485
|
+
// P2-3 (v19): `manifest` is non-null here — the refusal above precedes the
|
|
3486
|
+
// clear, so this branch cannot leave an emptied tree behind.
|
|
3487
|
+
for (const name of manifest.skills) {
|
|
3488
|
+
await this.io.copy(join(snapshotPath, name), join(this.root, name));
|
|
3489
|
+
}
|
|
3490
|
+
for (const sidecar of manifest.sidecars) {
|
|
3491
|
+
await this.io.copy(join(snapshotPath, sidecar), join(this.root, sidecar));
|
|
3492
|
+
}
|
|
3493
|
+
{
|
|
3494
|
+
// Archive is part of the whole-state rollback: a snapshot that carried
|
|
3495
|
+
// `.archive/` replaces the current one; a snapshot with no archive
|
|
3496
|
+
// means the archive content post-dates it, so it is rolled away too
|
|
3497
|
+
// (the pre-rollback snapshot above preserved it). Legacy manifests
|
|
3498
|
+
// without the field leave `.archive` untouched.
|
|
3499
|
+
const archiveRoot = join(this.root, '.archive');
|
|
3500
|
+
if (manifest.hasArchive === true) {
|
|
3501
|
+
await this.io.remove(archiveRoot);
|
|
3502
|
+
await this.io.copy(join(snapshotPath, '.archive'), archiveRoot);
|
|
3503
|
+
}
|
|
3504
|
+
else if (manifest.hasArchive === false) {
|
|
3505
|
+
await this.io.remove(archiveRoot);
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
// P3 (v17): the snapshot channel has the same stranded-lock hazard as
|
|
3509
|
+
// restoreFromArchive (v16 fixed only that entry point) — a writer lock
|
|
3510
|
+
// that rode into the snapshot rides back into the live root here, where
|
|
3511
|
+
// its live-pid body structurally closes the writers' self-heal. Sweep
|
|
3512
|
+
// every restored skill directory best-effort.
|
|
3513
|
+
for (const entry of await this.io.list(this.root)) {
|
|
3514
|
+
if (entry.startsWith('.'))
|
|
3515
|
+
continue;
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
export function newSkillLibrary(options) {
|
|
3520
|
+
const { config, io, limits, ctx, transact, threatExemptLabels, authority } = options;
|
|
3521
|
+
return new SkillLibrary(resolveSkillsRoot(config ?? {}), io, limits, ctx ? (event) => { ctx.emit('evolution/skill-mutated', event); } : undefined, transact,
|
|
3522
|
+
// Copied once, here: the library holds the array for its lifetime and must
|
|
3523
|
+
// not alias a config object the caller still owns.
|
|
3524
|
+
[...(threatExemptLabels ?? [])], authority);
|
|
3525
|
+
}
|
|
3526
|
+
export { DEFAULT_SKILL_LIMITS, DEFAULT_CITATION_POLICY, DEFAULT_REFERENCE_REWRITE_POLICY, DEFAULT_ARCHIVE_RETENTION_POLICY, DEFAULT_SUPPORT_FILE_CHAR_POLICY, POLICY_STAGE_DEFAULTS, policyStageLimits, } from "./limits.js";
|
|
3527
|
+
export { authoringFeedback, frontmatterBlock, frontmatterCatalogInvalid, normalizeFrontmatter, parseFrontmatter, relatedSkillNames, validateFrontmatter, yamlPlainScalarNeedsQuotes } from "./frontmatter.js";
|
|
3528
|
+
//# sourceMappingURL=skill-store.js.map
|