@lmzhen/dsh-tool-skill-manage 0.8.0 → 0.10.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 CHANGED
@@ -6,15 +6,44 @@ library nor its lifecycle rules.
6
6
 
7
7
  ## Model surface
8
8
 
9
- - **Model-visible:** the `skill_manage` tool schema and its success/validation messages; result tokens scale with what is returned.
9
+ - **Model-visible:** the `skill_manage` tool schema and its success/validation messages; result tokens scale with what is returned. The skills-guidance section (`evolution-skills-guidance`) ends with the policy's protected-skill list, carried by the prompt variable `evolution_skill_guard`: the provider runs at EVERY assembly (a policy change needs no reload), and an empty list leaves the section byte-identical to the guidance alone, so the guidance's prefix-cache behaviour is unchanged.
10
10
  - **Prompt prefix / KV cache:** the tool schema is prefix-stable, and skill writes do not alter the current request prompt; catalog invalidation affects the next request; family rules: `packages/README.md` §"Model-visible prompt prefix and the KV cache".
11
11
  - **Mount it?** yes — the `tool-skill-manage` row, carried by the `evolution-all` and one-click `evolution-preset` bundles and the Evolution agent preset delta.
12
12
 
13
13
  ## Safety model
14
14
 
15
+ ### Write gates
16
+
17
+ Every mutation THROUGH THIS TOOL passes ONE ordered admission sequence (`src/write-gates.ts`): the
18
+ scalar argument shape, the required arguments per action, the policy's protected list,
19
+ read-before-write, and the operator confirmation. The
20
+ ADMISSION point runs the sequence before the approval seam, so a write that is refused is never
21
+ staged for approval and never spends the operator's attention; the EXECUTION point (`executeCore`)
22
+ re-runs the gates a replay or a direct write can still fail, because a replay's stored arguments
23
+ passed no schema and the protected list can change while a record sits pending. Both points read the
24
+ same table, so they cannot diverge.
25
+
26
+ The sequence covers the MODEL tool path. The family's other writers reach their own library
27
+ handles: the `/evolution` and `/graph` command face writes what the operator typed (the operator is
28
+ the authority there), the review's plan executor writes a plan its own filter already screened
29
+ (`filterUnreadSkillOps`), and a record staged outside this tool replays into the execution point
30
+ without re-deciding the admission-only gates — there is no writing session to read.
31
+
32
+ Read-before-write is admission-only (the replayed record has no writing session): a non-foreground
33
+ write whose target the session never read is refused with `E-318`, and only a read that did not fail
34
+ counts. A session log the tool cannot read proceeds with one warning rather than blocking every
35
+ autonomous write.
36
+
37
+ A foreground `create`, or a bare foreground `delete`, asks the operator once before it writes and is
38
+ refused with `E-317` when the answer is not the confirm label; a `delete` carrying `absorbed_into` is the
39
+ merge protocol and does not ask. This gate is UX, not a security door: it is admission-only (a
40
+ replayed record already carries the human release that staged it), and an unmounted question
41
+ service, a caller that is not the registry's exact live root agent, or a failing ask all PROCEED
42
+ with one warning — the operator's own session stays the authority that asked for the write.
43
+
15
44
  ### Approval seam
16
45
 
17
- Mutations (create/edit/update/patch/delete/write_file/remove_file/restructure) pass through the evolution approval seam when `evolution-approval` is mounted; approved/staged writes are replayed by the registered runner with the library origin preserved. The missing-required-argument pre-check runs BEFORE that boundary — one shared table and one refusal builder with `executeCore`, so the stage boundary and the execution check cannot diverge: a write that cannot execute is refused, never staged for approval.
46
+ Mutations (create/edit/update/patch/delete/write_file/remove_file/restructure) pass through the evolution approval seam when `evolution-approval` is mounted; approved/staged writes are replayed by the registered runner with the library origin preserved. The admission gates run BEFORE that boundary, so a write that cannot execute is refused, never staged for approval.
18
47
 
19
48
  ### pin/unpin: explicit exception
20
49
 
@@ -29,6 +58,42 @@ Mutations (create/edit/update/patch/delete/write_file/remove_file/restructure) p
29
58
  per user. Reading the legacy name still works; writing it is refused. Removal was planned for 0.7.0 and is deferred:
30
59
  the alias still ships, so no later version is claimed here.
31
60
 
61
+ ### Description length: the three numbers
62
+
63
+ A skill description is measured against three different bounds. They are not interchangeable, and
64
+ only the second one refuses a write:
65
+
66
+ | Bound | Value | Source | What it does |
67
+ |---|---|---|---|
68
+ | Authoring bar | 60 chars | `AUTHORING_DESCRIPTION_BAR` (the upstream 60-char rule) | advisory feedback on every create/edit/update; `descriptionStrict: true` turns it into a refusal |
69
+ | Family storage ceiling | 1024 chars | `MAX_DESCRIPTION_LENGTH`, lowered per deployment by this row's `maxDescriptionLength` | the hard validation limit — the ONLY refusal threshold of the three |
70
+ | Platform catalog view | 500 chars by default | `catalogDescriptionMaxLength` on the platform's `tool-skill` row (`PLATFORM_CATALOG_DESCRIPTION_DEFAULT`) | truncates the description in the skill catalog the model reads; the family's own rows set that field to 60 (`evolution-host`, `evolution-all` and `evolution-preset` each carry it, and the composer injects it onto a generated preset row too), so 500 governs only a composition that overrides none of them; `packages/README.md` states which of those rows takes effect per install form |
71
+
72
+ The third row is a view, not a rule: a description the family accepts can still be cut in a catalog
73
+ viewer, which is why the authoring feedback names the bar rather than treating the cut as a limit.
74
+
75
+ ### The create-time duplicate hint
76
+
77
+ A `create` compares its candidate against the library listing before it writes, over the name +
78
+ description projection (core `nearDuplicateSummaries`, at `SUMMARY_DUPLICATE_HINT_THRESHOLD`), and
79
+ names the closest existing skills in the same "Authoring check" block. Three properties are
80
+ deliberate: it adds no read of its own (`library.list()` already reads every SKILL.md to parse
81
+ its frontmatter — that listing is what publishes `description` — and the hint never calls
82
+ `library.read`, never holds a body and never compares content); it is a HINT (the write proceeds
83
+ — only the model's next decision changes); and a listing failure degrades to a named line instead of
84
+ silence, because "nothing similar was checked" and "nothing similar exists" must not look alike. The
85
+ library-wide BODY scan stays where it was, in the `review` action.
86
+
87
+ ### The retention line
88
+
89
+ A whole-body replacement that keeps less than half of the previous SKILL.md says so in the same result
90
+ message (`Content kept 26% of the previous body (2104 of 8123 characters); the replaced version is
91
+ preserved in this skill's history.`). It is computed where the write path still holds both bodies — the
92
+ library's own in-lock read, core `contentRetentionFeedback` — because a caller re-reading the file
93
+ would race the writers the library serializes. Like the duplicate hint it is FEEDBACK, not a gate: no ratio
94
+ refuses a write, small skills stay quiet (a 40-character skill cut in half is not news), and patches,
95
+ support-file writes, creates and archives have no ratio to report.
96
+
32
97
  ## Known limitations
33
98
 
34
99
  - No known durable consumer gaps at this time. Runtime contracts are covered by package and boundary tests.
package/lib/index.js CHANGED
@@ -1,7 +1,171 @@
1
1
  import { effectiveSessionPolicy } from "@lmzhen/dsh-evolution-approval";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { defineTool } from "@deepseek-ai/dsh-tools";
4
- import { DEFAULT_ARCHIVE_RETENTION_POLICY, DEFAULT_CITATION_POLICY, DEFAULT_REFERENCE_REWRITE_POLICY, DEFAULT_SKILL_LIMITS, DEFAULT_SUPPORT_FILE_CHAR_POLICY, DSH_AUTHORING_STANDARDS, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, authoringFeedback, callingScope, clampedNumber, computeDedupGroups, contentHash, evolutionIoAdapter, installParamSection, isPresent, isUnknown, newSkillLibrary, paramNamespace, parseFrontmatter, policyStageLimits, probePresent, probeUnknown, readNumberParam, resolveExecOrigins } from "@lmzhen/dsh-evolution-core";
4
+ import { DEFAULT_ARCHIVE_RETENTION_POLICY, DEFAULT_CITATION_POLICY, DEFAULT_REFERENCE_REWRITE_POLICY, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_VERSION_KEEP, DEFAULT_SUPPORT_FILE_CHAR_POLICY, DSH_AUTHORING_STANDARDS, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, authoringFeedback, callingScope, clampedNumber, computeDedupGroups, contentHash, errorText, evolutionIoAdapter, installParamSection, isPresent, isUnknown, isUnreadWrite, nearDuplicateSummaries, newSkillLibrary, paramNamespace, parseFrontmatter, policyStageLimits, probePresent, probeUnknown, readNumberParam, resolveExecOrigins, sessionReadSkillNames } from "@lmzhen/dsh-evolution-core";
5
+ //#region lib/types/write-gates.js
6
+ /**
7
+ * The write-admission sequence: ONE ordered table of the reasons a skill write is refused.
8
+ *
9
+ * Two points read this table (design `dsh-evolution-write-gate-design.md` §3). The ADMISSION
10
+ * point (`execute`, BEFORE the approval seam) runs every gate that applies to `'admission'`, so a
11
+ * write that is refused is never staged and never spends the operator's attention twice. The
12
+ * EXECUTION point (the `executeCore` funnel, reached by the direct path and by the approval
13
+ * replay) runs the gates that apply to `'execution'`: the stored plan of a replayed write passed
14
+ * no schema, and the protected list can change while a record sits pending, so those verdicts are
15
+ * re-decided at the bytes.
16
+ *
17
+ * Order is part of the contract: a gate that can only refuse comes before one that needs a round
18
+ * trip with the operator, so a write that is refused anyway never asks anything.
19
+ *
20
+ * A gate decides whether a write may be ATTEMPTED. The library decides whether it can be
21
+ * PERFORMED — its own structured refusals (a restructure element that is not an object, a patch
22
+ * anchor that no longer matches, an `absorbed_into` that does not exist) stay where the bytes are
23
+ * read.
24
+ *
25
+ * Rule bodies live in `evolution-core` (the read-before-write rule in `skill-reads`, the
26
+ * required-argument table in `SKILL_ACTION_REQUIRED_FIELDS`); this module owns the ORDER and the
27
+ * applicability of each gate, which is an execution-point fact of this tool.
28
+ * @module @lmzhen/dsh-tool-skill-manage/write-gates
29
+ */
30
+ /** The confirm question's stable id — one question per write, so an answer to an earlier prompt
31
+ * can never be read as the answer to a later one. */
32
+ const CONFIRM_QUESTION_ID = "evolution-skill-write";
33
+ /** The cancel label every confirm question offers. */
34
+ const CANCEL_LABEL = "Cancel";
35
+ /** The scalar fields the tool schema types as strings; the replay channel has no schema.
36
+ * `action` is one of them: a non-string action used to fall through to the library as "Unknown
37
+ * action" while the read gate re-defaulted it to `patch` and refused with E-318 (review
38
+ * 2026-09-26, P2), so the shape gate refuses it by name instead. */
39
+ const SCALAR_ARG_FIELDS = [
40
+ "action",
41
+ "name",
42
+ "content",
43
+ "old_string",
44
+ "new_string",
45
+ "file_path",
46
+ "file_content",
47
+ "absorbed_into"
48
+ ];
49
+ /**
50
+ * Narrow the arguments once for every gate.
51
+ *
52
+ * The admission point reaches the gates AFTER the schema, the replay channel BEFORE any
53
+ * validation, so a gate reads only what it can prove: anything else counts as absent, exactly as
54
+ * the missing-argument check already treated a non-string action.
55
+ * @param args - the arguments as the caller sent them.
56
+ * @returns the narrowed view.
57
+ */
58
+ function gateArgsOf(args) {
59
+ const scalars = typeof args === "object" && args !== null ? args : {};
60
+ const action = scalars.action;
61
+ const name = scalars.name;
62
+ const absorbedInto = scalars.absorbed_into;
63
+ return {
64
+ action: typeof action === "string" ? action : void 0,
65
+ name: typeof name === "string" ? name : "",
66
+ absorbedInto: typeof absorbedInto === "string" && absorbedInto.trim() !== "" ? absorbedInto : void 0,
67
+ scalars
68
+ };
69
+ }
70
+ /** The sequence, in order. A gate whose `appliesTo` omits the asking point is skipped. */
71
+ const GATES = [
72
+ {
73
+ id: "argument-shape",
74
+ appliesTo: ["admission", "execution"],
75
+ run: ({ view }) => {
76
+ for (const field of SCALAR_ARG_FIELDS) {
77
+ const value = view.scalars[field];
78
+ if (value === void 0 || value === null || typeof value === "string") continue;
79
+ return `skill_manage: "${field}" must be a string (got ${typeof value}); refusing the write.`;
80
+ }
81
+ return null;
82
+ }
83
+ },
84
+ {
85
+ id: "missing-args",
86
+ appliesTo: ["admission", "execution"],
87
+ run: ({ view }) => {
88
+ const action = view.action;
89
+ const missing = (action === void 0 || !Object.hasOwn(SKILL_ACTION_REQUIRED_FIELDS, action) ? [] : SKILL_ACTION_REQUIRED_FIELDS[action] ?? []).filter((field) => view.scalars[field] === void 0 || view.scalars[field] === null);
90
+ if (missing.length === 0) return null;
91
+ return `skill_manage ${action} requires ${missing.join(", ")}; the tool description lists the arguments per action.`;
92
+ }
93
+ },
94
+ {
95
+ id: "policy-protection",
96
+ appliesTo: ["admission", "execution"],
97
+ run: ({ view, origin, protectedNames }) => {
98
+ if (origin === "foreground") return null;
99
+ if (view.name === "" || !protectedNames?.includes(view.name)) return null;
100
+ return `skill_manage: "${view.name}" is protected by the current policy (protectedSkillNames); replayed/autonomous writes are refused.`;
101
+ }
102
+ },
103
+ {
104
+ id: "read-before-write",
105
+ appliesTo: ["admission"],
106
+ run: ({ view, origin, readNames, warn }) => {
107
+ if (origin === "foreground") return null;
108
+ if (readNames === void 0) {
109
+ warn("skill_manage: the read-before-write check could not run for this call — the session log is not readable here, so the write is allowed.");
110
+ return null;
111
+ }
112
+ if (view.action === void 0) return null;
113
+ if (!isUnreadWrite({
114
+ action: view.action,
115
+ name: view.name
116
+ }, readNames)) return null;
117
+ return errorText("e-318-skill-write-without-a-read", { a1: view.name });
118
+ }
119
+ },
120
+ {
121
+ id: "human-confirm",
122
+ appliesTo: ["admission"],
123
+ run: async ({ view, origin, confirm, warn }) => {
124
+ const action = view.action;
125
+ if (action === void 0 || origin !== "foreground" || view.name === "") return null;
126
+ if (!(action === "create" || action === "delete" && view.absorbedInto === void 0)) return null;
127
+ if (confirm === void 0) {
128
+ warn("skill_manage: no confirmation channel is mounted, so the write proceeds unconfirmed.");
129
+ return null;
130
+ }
131
+ const confirmLabel = action === "create" ? "Create" : "Delete";
132
+ if (await confirm({
133
+ action,
134
+ name: view.name,
135
+ confirmLabel,
136
+ question: {
137
+ id: CONFIRM_QUESTION_ID,
138
+ header: "Confirm",
139
+ question: action === "create" ? `Create skill "${view.name}"? A new skill directory is written into the family tree.` : `Delete skill "${view.name}"? It is archived under .archive and leaves the catalog.`,
140
+ options: [{ label: confirmLabel }, { label: CANCEL_LABEL }]
141
+ }
142
+ })) return null;
143
+ return errorText("e-317-skill-write-not-confirmed", {
144
+ a1: view.name,
145
+ a2: action === "create" ? "created" : "deleted"
146
+ });
147
+ }
148
+ }
149
+ ];
150
+ /**
151
+ * Run the sequence for one write attempt.
152
+ * @param context - the asking point, the write, and the seams the human-facing gate needs.
153
+ * @returns the FIRST refusal, or null when every applicable gate passed. Both points return the
154
+ * message as the tool result unchanged, so the two entries refuse with identical wording.
155
+ */
156
+ async function runWriteGates(context) {
157
+ const input = {
158
+ ...context,
159
+ view: gateArgsOf(context.args)
160
+ };
161
+ for (const gate of GATES) {
162
+ if (!gate.appliesTo.includes(context.point)) continue;
163
+ const refusal = await gate.run(input);
164
+ if (refusal !== null) return refusal;
165
+ }
166
+ return null;
167
+ }
168
+ //#endregion
5
169
  //#region lib/types/index.js
6
170
  /**
7
171
  * Model-facing skill_manage tool over ctx.evolutionIo + ctx.skillUsage.
@@ -31,6 +195,16 @@ const inject = [
31
195
  /** 0.3.18 (E-70): review output lists at most this many near-duplicate groups
32
196
  * (a cap, not a per-group limit) — the one named bound for the slice below. */
33
197
  const MAX_DEDUP_GROUPS_IN_REVIEW = 3;
198
+ /** B4 (design §4.3): the prompt variable that carries the protected-skill list into the guidance
199
+ * section. Names must match the platform's `[a-z][a-z0-9_]*` reference syntax. */
200
+ const PROTECTED_SKILLS_VARIABLE = "evolution_skill_guard";
201
+ /** Names shown inline before the line collapses to a count — the guidance is in EVERY request, so
202
+ * the line is bounded even when the policy protects a long list. */
203
+ const PROTECTED_SKILLS_INLINE_MAX = 12;
204
+ /** The guidance section's text: `SKILLS_GUIDANCE` followed by the variable reference. Composed HERE
205
+ * rather than inside the core constant, because the review prompts share that constant and no
206
+ * variable is registered for them. */
207
+ const GUIDANCE_WITH_GUARD = `${SKILLS_GUIDANCE}{{${PROTECTED_SKILLS_VARIABLE}}}`;
34
208
  const Config = z.object({
35
209
  root: z.string().default(""),
36
210
  maxSkillNameLength: z.number().min(1).default(DEFAULT_SKILL_LIMITS.maxNameLength),
@@ -39,7 +213,8 @@ const Config = z.object({
39
213
  maxSkillFileBytes: z.number().min(1).default(DEFAULT_SKILL_LIMITS.maxSkillFileBytes),
40
214
  descriptionStrict: z.boolean().default(false),
41
215
  threatExemptLabels: z.array(z.string()).default([]),
42
- strictCrossSource: z.boolean().default(false)
216
+ strictCrossSource: z.boolean().default(false),
217
+ skillVersionKeep: z.number().min(1).default(DEFAULT_SKILL_LIMITS.versionKeep ?? DEFAULT_SKILL_VERSION_KEEP)
43
218
  });
44
219
  /** Schema the platform validates the user layer against; defaults mirror the core
45
220
  * constants and the row schema, so an empty document resolves to today's behaviour. */
@@ -70,31 +245,126 @@ const SKILL_SETTINGS_CAPS = [
70
245
  function validateSkillSettings(value, ceilings) {
71
246
  for (const key of SKILL_SETTINGS_CAPS) if (value[key] > ceilings[key]) throw new Error(`${key} may only be tightened: ${value[key]} exceeds the deployment value ${ceilings[key]}`);
72
247
  }
248
+ /** The platform error code a question-service failure carries, when it carries one. */
249
+ function questionErrorCode(error) {
250
+ const code = error?.code;
251
+ return typeof code === "string" ? code : void 0;
252
+ }
253
+ /** The message of a caught platform error, for the one warning a degraded gate emits. */
254
+ function reasonOfError(error) {
255
+ return error instanceof Error ? error.message : String(error);
256
+ }
257
+ /**
258
+ * The registry's live ROOT agent for this call's session, or `undefined`.
259
+ *
260
+ * The registry keys agents by their shared agent/session id (platform `agent/src/index.ts:567`), and
261
+ * the platform's `ask()` accepts only an agent that is BOTH its exact live instance and a root
262
+ * (platform `user-questions/src/index.ts:96-106`). An id that resolves to an agent owned by another
263
+ * agent is not a root: the caller then proceeds unconfirmed rather than blocking a human question on
264
+ * an agent no human is watching.
265
+ * @param probe - the context, read through the optional-service probe.
266
+ * @param exec - this call's execution context.
267
+ * @returns the live root agent, when this session has one.
268
+ */
269
+ function liveRootAgentOf(probe, exec) {
270
+ const agents = probe.get("agents");
271
+ const sessionId = exec.agent?.session?.id;
272
+ if (agents === void 0 || typeof sessionId !== "string") return void 0;
273
+ const candidate = agents.get(sessionId);
274
+ return candidate !== void 0 && agents.roots().includes(candidate) ? candidate : void 0;
275
+ }
276
+ /** The one thing the operator's answer decides: was the confirm label selected? */
277
+ function confirmedBy(answer, questionId, confirmLabel) {
278
+ return (answer.answers.find((entry) => entry.id === questionId)?.selected ?? []).includes(confirmLabel);
279
+ }
73
280
  /** v30 REV-02: read the protected-skill list off the (optional) policy
74
281
  * snapshot through an `unknown` boundary — the Context augmentation types the
75
282
  * getter non-optionally, but at runtime the row can be absent. */
76
283
  function policySnapshotOf(source) {
77
284
  return source?.get?.();
78
285
  }
79
- function missingRequiredArgs(args) {
80
- const action = args.action;
81
- const scalarArgs = args;
82
- return (action === void 0 || typeof action !== "string" || !Object.hasOwn(SKILL_ACTION_REQUIRED_FIELDS, action) ? [] : SKILL_ACTION_REQUIRED_FIELDS[action] ?? []).filter((field) => scalarArgs[field] === void 0 || scalarArgs[field] === null);
83
- }
84
- function missingArgsRefusal(action, missing) {
85
- return {
86
- ok: false,
87
- message: `skill_manage ${action} requires ${missing.join(", ")}; the tool description lists the arguments per action.`,
88
- skills: []
89
- };
90
- }
91
286
  function apply(ctx, rawConfig = {}) {
92
287
  const systemPrompt = ctx.get("systemPrompt");
93
- if (systemPrompt) ctx.effect(() => systemPrompt.section({
94
- name: "evolution-skills-guidance",
95
- order: SKILLS_GUIDANCE_SECTION_ORDER,
96
- text: SKILLS_GUIDANCE
97
- }), "tool-skill-manage.skills-guidance");
288
+ if (systemPrompt) ctx.effect(() => {
289
+ const disposeVariable = systemPrompt.variable(PROTECTED_SKILLS_VARIABLE, () => protectedSkillsLine());
290
+ const disposeSection = systemPrompt.section({
291
+ name: "evolution-skills-guidance",
292
+ order: SKILLS_GUIDANCE_SECTION_ORDER,
293
+ text: GUIDANCE_WITH_GUARD
294
+ });
295
+ return () => {
296
+ disposeSection();
297
+ disposeVariable();
298
+ };
299
+ }, "tool-skill-manage.skills-guidance");
300
+ /** The policy snapshot's protected list, or `undefined` when the row is not mounted — read by
301
+ * the write gates at BOTH points, through the one unknown-boundary helper above. */
302
+ const protectedSkillNamesOf = () => policySnapshotOf(ctx.get("evolutionPolicy"))?.protectedSkillNames;
303
+ /**
304
+ * The protected-skill line appended to the skills guidance (B4, design §4.3).
305
+ *
306
+ * The list is otherwise visible only to the REVIEW prompts, so the tool-path model could learn a
307
+ * name was protected only by having a write refused — this line is what lets it avoid the call.
308
+ * It returns `''` — never `undefined`, which the platform refuses to render — when no policy
309
+ * row is mounted or nothing is protected, and the empty value leaves the section byte-identical
310
+ * to the guidance alone, so the guidance's prefix-cache stability is untouched.
311
+ * @returns the line including the newline that joins it to the guidance.
312
+ */
313
+ const protectedSkillsLine = () => {
314
+ try {
315
+ const names = protectedSkillNamesOf() ?? [];
316
+ if (names.length === 0) return "";
317
+ const shown = names.slice(0, PROTECTED_SKILLS_INLINE_MAX);
318
+ const rest = names.length - shown.length;
319
+ return `\nProtected skills (do not edit): ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}`;
320
+ } catch {
321
+ return "";
322
+ }
323
+ };
324
+ let writeGateWarned = false;
325
+ const warnWriteGateOnce = (message) => {
326
+ if (writeGateWarned) return;
327
+ writeGateWarned = true;
328
+ ctx.logger.warn(message);
329
+ };
330
+ const probe = ctx;
331
+ let confirmWarned = false;
332
+ const confirmUnavailable = (reason) => {
333
+ if (!confirmWarned) {
334
+ confirmWarned = true;
335
+ ctx.logger.warn(`skill_manage: the confirmation prompt could not be shown (${reason}) — the write proceeds unconfirmed.`);
336
+ }
337
+ return true;
338
+ };
339
+ const isDismissed = (error) => questionErrorCode(error) === "ASK_ABORTED";
340
+ const confirmSkillWrite = async (request, exec) => {
341
+ const questions = probe.get("userQuestions");
342
+ if (questions === void 0) return confirmUnavailable("no user-questions service is mounted");
343
+ const ask = (agent) => questions.ask({
344
+ questions: [{
345
+ id: request.question.id,
346
+ header: request.question.header,
347
+ question: request.question.question,
348
+ options: request.question.options.map((option) => ({ label: option.label }))
349
+ }],
350
+ agent,
351
+ ...exec.signal !== void 0 ? { signal: exec.signal } : {}
352
+ });
353
+ const declared = exec.agent;
354
+ if (declared !== void 0) try {
355
+ return confirmedBy(await ask(declared), request.question.id, request.confirmLabel);
356
+ } catch (error) {
357
+ if (isDismissed(error)) return false;
358
+ if (questionErrorCode(error) !== "CALLER_NOT_LIVE") return confirmUnavailable(reasonOfError(error));
359
+ }
360
+ const located = liveRootAgentOf(probe, exec);
361
+ if (located === void 0) return confirmUnavailable("the calling session has no live root agent");
362
+ try {
363
+ return confirmedBy(await ask(located), request.question.id, request.confirmLabel);
364
+ } catch (error) {
365
+ return isDismissed(error) ? false : confirmUnavailable(reasonOfError(error));
366
+ }
367
+ };
98
368
  const io = evolutionIoAdapter(() => ctx.evolutionIo.provider());
99
369
  const numericClamped = [];
100
370
  const limit = (name, value, fallback) => {
@@ -107,7 +377,8 @@ function apply(ctx, rawConfig = {}) {
107
377
  maxNameLength: limit("maxSkillNameLength", rawConfig.maxSkillNameLength, DEFAULT_SKILL_LIMITS.maxNameLength),
108
378
  maxDescriptionLength: limit("maxDescriptionLength", rawConfig.maxDescriptionLength, DEFAULT_SKILL_LIMITS.maxDescriptionLength),
109
379
  maxSkillContentChars: limit("maxSkillContentChars", readNumberParam(rawConfig, "skillContentChars"), DEFAULT_SKILL_LIMITS.maxSkillContentChars),
110
- maxSkillFileBytes: limit("maxSkillFileBytes", rawConfig.maxSkillFileBytes, DEFAULT_SKILL_LIMITS.maxSkillFileBytes)
380
+ maxSkillFileBytes: limit("maxSkillFileBytes", rawConfig.maxSkillFileBytes, DEFAULT_SKILL_LIMITS.maxSkillFileBytes),
381
+ versionKeep: limit("skillVersionKeep", rawConfig.skillVersionKeep, DEFAULT_SKILL_LIMITS.versionKeep ?? DEFAULT_SKILL_VERSION_KEEP)
111
382
  };
112
383
  const library = newSkillLibrary({
113
384
  config: rawConfig,
@@ -238,38 +509,28 @@ function apply(ctx, rawConfig = {}) {
238
509
  skills: []
239
510
  };
240
511
  }
241
- const scalarArgs = args;
242
- for (const field of [
243
- "name",
244
- "content",
245
- "old_string",
246
- "new_string",
247
- "file_path",
248
- "file_content",
249
- "absorbed_into"
250
- ]) {
251
- const value = scalarArgs[field];
252
- if (value !== void 0 && value !== null && typeof value !== "string") return {
253
- ok: false,
254
- message: `skill_manage: "${field}" must be a string (got ${typeof value}); refusing the write.`,
255
- skills: []
256
- };
257
- }
258
- if (origin !== "foreground") {
259
- if ((policySnapshotOf(ctx.get("evolutionPolicy"))?.protectedSkillNames)?.includes(name)) return {
260
- ok: false,
261
- message: `skill_manage: "${name}" is protected by the current policy (protectedSkillNames); replayed/autonomous writes are refused.`,
262
- skills: []
263
- };
264
- }
265
- const missing = missingRequiredArgs(args);
266
- if (missing.length > 0) return missingArgsRefusal(action, missing);
512
+ const refusal = await runWriteGates({
513
+ point: "execution",
514
+ args,
515
+ origin,
516
+ protectedNames: protectedSkillNamesOf(),
517
+ readNames: void 0,
518
+ confirm: void 0,
519
+ warn: warnWriteGateOnce
520
+ });
521
+ if (refusal !== null) return {
522
+ ok: false,
523
+ message: refusal,
524
+ skills: []
525
+ };
267
526
  let feedbackLines = [];
268
527
  const stagedAnchor = typeof args.staged_from_sha256 === "string" && args.staged_from_sha256 !== "" ? args.staged_from_sha256 === "absent" ? { absent: true } : { sha256: args.staged_from_sha256 } : void 0;
528
+ let candidateDescription = "";
269
529
  if ((action === "create" || action === "edit" || action === "update") && args.content) {
270
530
  const parsed = parseFrontmatter(args.content);
271
531
  if (parsed) {
272
532
  const feedback = authoringFeedback(parsed.frontmatter);
533
+ candidateDescription = parsed.frontmatter.description ?? "";
273
534
  feedbackLines = feedback.lines;
274
535
  if (settings().descriptionStrict && feedback.over60) return {
275
536
  ok: false,
@@ -279,8 +540,10 @@ function apply(ctx, rawConfig = {}) {
279
540
  }
280
541
  }
281
542
  let result;
282
- if (action === "create") result = await library.create(name, args.content ?? "", origin);
283
- else if (action === "edit" || action === "update") result = await library.update(name, args.content ?? "", origin, stagedAnchor);
543
+ if (action === "create") {
544
+ feedbackLines.push(...await duplicateHintLines(name, candidateDescription));
545
+ result = await library.create(name, args.content ?? "", origin);
546
+ } else if (action === "edit" || action === "update") result = await library.update(name, args.content ?? "", origin, stagedAnchor);
284
547
  else if (action === "patch") result = await library.patch(name, args.old_string ?? "", args.new_string ?? "", args.file_path ?? "", args.replace_all === true, origin);
285
548
  else if (action === "delete") result = await library.archive(name, args.absorbed_into ? { absorbedInto: args.absorbed_into } : {});
286
549
  else if (action === "write_file") result = await library.writeSupportFile(name, args.file_path ?? "", args.file_content ?? "", origin, stagedAnchor);
@@ -322,6 +585,39 @@ function apply(ctx, rawConfig = {}) {
322
585
  skills: []
323
586
  };
324
587
  }
588
+ /** Batch C (2026-09-27, design §3C-2): the create-time near-duplicate hint — one
589
+ * line naming the existing skill(s) this candidate is about to near-copy, over
590
+ * core's `summary` projection (name + description). Three properties are the
591
+ * design's, not stylistic choices:
592
+ *
593
+ * - It adds NO read of its own. `library.list()` reads each SKILL.md to parse its
594
+ * frontmatter — that listing is what publishes `description` — and the hint never
595
+ * calls `library.read`, never holds a body and never compares content, so a create
596
+ * pays no SECOND whole-tree pass for it.
597
+ * - It is a HINT: the write proceeds either way (G4 — create's foreground
598
+ * behavior is unchanged). The model gets the news, not a refusal.
599
+ * - A failed listing degrades to a NAMED line, never to silence: "nothing
600
+ * similar was checked" and "nothing similar exists" must not look alike
601
+ * (the PLAN-R2 P2-5 posture the review path already takes).
602
+ *
603
+ * The level and the math live in core (`nearDuplicateSummaries`,
604
+ * `SUMMARY_DUPLICATE_HINT_THRESHOLD`) — this function owns the wording only. */
605
+ async function duplicateHintLines(name, description) {
606
+ let matches;
607
+ try {
608
+ matches = nearDuplicateSummaries({
609
+ candidate: {
610
+ name,
611
+ description
612
+ },
613
+ existing: await library.list()
614
+ });
615
+ } catch (error) {
616
+ return [`Duplicate check skipped: the skill listing failed (${error instanceof Error ? error.message : String(error)}).`];
617
+ }
618
+ if (matches.length === 0) return [];
619
+ return [`Near-duplicate of existing skill(s): ${matches.map((match) => `${match.name} (${match.score.toFixed(2)})`).join(", ")} — prefer patch/update on that skill over adding a near-copy.`];
620
+ }
325
621
  async function buildSkillReviewText() {
326
622
  const list = await library.list();
327
623
  const report = await ctx.skillUsage.report();
@@ -445,8 +741,20 @@ function apply(ctx, rawConfig = {}) {
445
741
  skills: []
446
742
  };
447
743
  }
448
- const missing = missingRequiredArgs(args);
449
- if (missing.length > 0) return missingArgsRefusal(args.action, missing);
744
+ const refusal = await runWriteGates({
745
+ point: "admission",
746
+ args,
747
+ origin: libraryOrigin,
748
+ protectedNames: protectedSkillNamesOf(),
749
+ readNames: sessionReadSkillNames(exec.agent?.session),
750
+ confirm: async (request) => confirmSkillWrite(request, exec),
751
+ warn: warnWriteGateOnce
752
+ });
753
+ if (refusal !== null) return {
754
+ ok: false,
755
+ message: refusal,
756
+ skills: []
757
+ };
450
758
  const approval = ctx.get("evolutionApproval");
451
759
  if (approval && args.action !== "list" && args.action !== "review" && args.action !== "pin" && args.action !== "unpin") {
452
760
  if ((args.action === "update" || args.action === "edit") && typeof args.name === "string" && args.name !== "") {
@@ -46,6 +46,10 @@ export interface Config {
46
46
  * the family tree) is REFUSED instead of warned about. Default false —
47
47
  * warn only, keeping the family tree an autonomous evolution zone. */
48
48
  strictCrossSource?: boolean;
49
+ /** Content versions retained per skill (skill-history.ts). E2: a deployment value on this row,
50
+ * like the four caps above — the settings layer deliberately has no card for it (retention is
51
+ * storage policy, not an authoring knob). */
52
+ skillVersionKeep?: number;
49
53
  }
50
54
  export declare const Config: z<Config>;
51
55
  /** Write behaviour a user may change (G3/S3.4). Field names are the CANONICAL
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The write-admission sequence: ONE ordered table of the reasons a skill write is refused.
3
+ *
4
+ * Two points read this table (design `dsh-evolution-write-gate-design.md` §3). The ADMISSION
5
+ * point (`execute`, BEFORE the approval seam) runs every gate that applies to `'admission'`, so a
6
+ * write that is refused is never staged and never spends the operator's attention twice. The
7
+ * EXECUTION point (the `executeCore` funnel, reached by the direct path and by the approval
8
+ * replay) runs the gates that apply to `'execution'`: the stored plan of a replayed write passed
9
+ * no schema, and the protected list can change while a record sits pending, so those verdicts are
10
+ * re-decided at the bytes.
11
+ *
12
+ * Order is part of the contract: a gate that can only refuse comes before one that needs a round
13
+ * trip with the operator, so a write that is refused anyway never asks anything.
14
+ *
15
+ * A gate decides whether a write may be ATTEMPTED. The library decides whether it can be
16
+ * PERFORMED — its own structured refusals (a restructure element that is not an object, a patch
17
+ * anchor that no longer matches, an `absorbed_into` that does not exist) stay where the bytes are
18
+ * read.
19
+ *
20
+ * Rule bodies live in `evolution-core` (the read-before-write rule in `skill-reads`, the
21
+ * required-argument table in `SKILL_ACTION_REQUIRED_FIELDS`); this module owns the ORDER and the
22
+ * applicability of each gate, which is an execution-point fact of this tool.
23
+ * @module @lmzhen/dsh-tool-skill-manage/write-gates
24
+ */
25
+ import { type WriteOrigin } from '@lmzhen/dsh-evolution-core';
26
+ /** The point whose verdict is being taken. */
27
+ export type WriteGatePoint = 'admission' | 'execution';
28
+ /** One confirm question, as the gate writes it and the seam asks it. */
29
+ export interface WriteConfirmRequest {
30
+ /** The action being confirmed (`create` or `delete`). */
31
+ readonly action: string;
32
+ /** The skill the write would create or archive. */
33
+ readonly name: string;
34
+ /** The question for the operator: the options are the confirm label, then the cancel label. */
35
+ readonly question: {
36
+ readonly id: string;
37
+ readonly header: string;
38
+ readonly question: string;
39
+ readonly options: readonly {
40
+ readonly label: string;
41
+ }[];
42
+ };
43
+ /** The option label that means "proceed". */
44
+ readonly confirmLabel: string;
45
+ }
46
+ /**
47
+ * Ask the human to confirm one irreversible skill write.
48
+ *
49
+ * MUST be total: an implementation resolves every unavailability — no question service, no live root
50
+ * agent, a failing ask — to `true`, because this gate is an operator convenience and no failure of the
51
+ * question service may block the operator's own write. `false` means the human answered something
52
+ * other than the confirm label.
53
+ * @param request - the question to put to the operator and the label that means "proceed".
54
+ * @returns true to proceed with the write.
55
+ */
56
+ export type WriteConfirm = (request: WriteConfirmRequest) => Promise<boolean>;
57
+ /** What every gate reads. */
58
+ export interface WriteGateContext {
59
+ /** The point asking: the tool's admission of a call, or its execution at the bytes. */
60
+ readonly point: WriteGatePoint;
61
+ /** The parsed (admission) or stored (replay) arguments — unvalidated, hence the shape gate. */
62
+ readonly args: unknown;
63
+ /** The library write origin: `foreground` is the operator's own session. */
64
+ readonly origin: WriteOrigin;
65
+ /** The policy snapshot's protected list; `undefined` when no policy row is mounted. */
66
+ readonly protectedNames: readonly string[] | undefined;
67
+ /** Names this session read successfully; `undefined` when the session log is not readable. */
68
+ readonly readNames: ReadonlySet<string> | undefined;
69
+ /** The human confirm seam — read only by the gates that apply to `'admission'`. */
70
+ readonly confirm: WriteConfirm | undefined;
71
+ /** Report a degraded gate; must not throw. The implementation decides how often it speaks — the
72
+ * shipped seam latches once per PROCESS, because the conditions it reports (no question service,
73
+ * an unreadable session log) belong to the deployment, not to one write. */
74
+ readonly warn: (message: string) => void;
75
+ }
76
+ /**
77
+ * Run the sequence for one write attempt.
78
+ * @param context - the asking point, the write, and the seams the human-facing gate needs.
79
+ * @returns the FIRST refusal, or null when every applicable gate passed. Both points return the
80
+ * message as the tool result unchanged, so the two entries refuse with identical wording.
81
+ */
82
+ export declare function runWriteGates(context: WriteGateContext): Promise<string | null>;
83
+ //# sourceMappingURL=write-gates.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-tool-skill-manage",
3
3
  "description": "Model-facing skill_manage tool (community build)",
4
- "version": "0.8.0",
4
+ "version": "0.10.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,24 +27,24 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@deepseek-ai/schemastery": "^3.18.1",
30
- "@lmzhen/dsh-evolution-approval": "^0.8.0",
31
- "@lmzhen/dsh-evolution-core": "^0.8.0"
30
+ "@lmzhen/dsh-evolution-approval": "^0.10.0",
31
+ "@lmzhen/dsh-evolution-core": "^0.10.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/cordis": "^4.0.1",
35
35
  "@deepseek-ai/dsh-skill": "^0.1.5-rc.2",
36
36
  "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
37
37
  "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
38
- "@lmzhen/dsh-evolution-io": "^0.8.0",
39
- "@lmzhen/dsh-skill-usage": "^0.8.0"
38
+ "@lmzhen/dsh-evolution-io": "^0.10.0",
39
+ "@lmzhen/dsh-skill-usage": "^0.10.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@deepseek-ai/dsh-agent-loop-testkit": "^0.1.5-rc.2",
43
43
  "@deepseek-ai/dsh-skill": "^0.1.5-rc.2",
44
44
  "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
45
45
  "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
46
- "@lmzhen/dsh-evolution-core": "^0.8.0",
47
- "@lmzhen/dsh-evolution-io": "^0.8.0",
48
- "@lmzhen/dsh-skill-usage": "^0.8.0"
46
+ "@lmzhen/dsh-evolution-core": "^0.10.0",
47
+ "@lmzhen/dsh-evolution-io": "^0.10.0",
48
+ "@lmzhen/dsh-skill-usage": "^0.10.0"
49
49
  }
50
50
  }