@lmzhen/dsh-tool-skill-manage 0.7.0 → 0.9.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,20 @@ 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
+
32
75
  ## Known limitations
33
76
 
34
77
  - 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, PARAM_NAMESPACES, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, authoringFeedback, callingScope, clampedNumber, computeDedupGroups, contentHash, evolutionIoAdapter, installParamSection, isPresent, isUnknown, newSkillLibrary, 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_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, 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),
@@ -41,8 +215,6 @@ const Config = z.object({
41
215
  threatExemptLabels: z.array(z.string()).default([]),
42
216
  strictCrossSource: z.boolean().default(false)
43
217
  });
44
- /** Namespace the write caps and the four stage policies live in (core's PARAM_NAMESPACES). */
45
- const SKILLS_SETTINGS_NAMESPACE = "evolution-skills";
46
218
  /** Schema the platform validates the user layer against; defaults mirror the core
47
219
  * constants and the row schema, so an empty document resolves to today's behaviour. */
48
220
  const SKILLS_SETTINGS_SCHEMA = z.object({
@@ -72,31 +244,126 @@ const SKILL_SETTINGS_CAPS = [
72
244
  function validateSkillSettings(value, ceilings) {
73
245
  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]}`);
74
246
  }
247
+ /** The platform error code a question-service failure carries, when it carries one. */
248
+ function questionErrorCode(error) {
249
+ const code = error?.code;
250
+ return typeof code === "string" ? code : void 0;
251
+ }
252
+ /** The message of a caught platform error, for the one warning a degraded gate emits. */
253
+ function reasonOfError(error) {
254
+ return error instanceof Error ? error.message : String(error);
255
+ }
256
+ /**
257
+ * The registry's live ROOT agent for this call's session, or `undefined`.
258
+ *
259
+ * The registry keys agents by their shared agent/session id (platform `agent/src/index.ts:567`), and
260
+ * the platform's `ask()` accepts only an agent that is BOTH its exact live instance and a root
261
+ * (platform `user-questions/src/index.ts:96-106`). An id that resolves to an agent owned by another
262
+ * agent is not a root: the caller then proceeds unconfirmed rather than blocking a human question on
263
+ * an agent no human is watching.
264
+ * @param probe - the context, read through the optional-service probe.
265
+ * @param exec - this call's execution context.
266
+ * @returns the live root agent, when this session has one.
267
+ */
268
+ function liveRootAgentOf(probe, exec) {
269
+ const agents = probe.get("agents");
270
+ const sessionId = exec.agent?.session?.id;
271
+ if (agents === void 0 || typeof sessionId !== "string") return void 0;
272
+ const candidate = agents.get(sessionId);
273
+ return candidate !== void 0 && agents.roots().includes(candidate) ? candidate : void 0;
274
+ }
275
+ /** The one thing the operator's answer decides: was the confirm label selected? */
276
+ function confirmedBy(answer, questionId, confirmLabel) {
277
+ return (answer.answers.find((entry) => entry.id === questionId)?.selected ?? []).includes(confirmLabel);
278
+ }
75
279
  /** v30 REV-02: read the protected-skill list off the (optional) policy
76
280
  * snapshot through an `unknown` boundary — the Context augmentation types the
77
281
  * getter non-optionally, but at runtime the row can be absent. */
78
282
  function policySnapshotOf(source) {
79
283
  return source?.get?.();
80
284
  }
81
- function missingRequiredArgs(args) {
82
- const action = args.action;
83
- const scalarArgs = args;
84
- 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);
85
- }
86
- function missingArgsRefusal(action, missing) {
87
- return {
88
- ok: false,
89
- message: `skill_manage ${action} requires ${missing.join(", ")}; the tool description lists the arguments per action.`,
90
- skills: []
91
- };
92
- }
93
285
  function apply(ctx, rawConfig = {}) {
94
286
  const systemPrompt = ctx.get("systemPrompt");
95
- if (systemPrompt) ctx.effect(() => systemPrompt.section({
96
- name: "evolution-skills-guidance",
97
- order: SKILLS_GUIDANCE_SECTION_ORDER,
98
- text: SKILLS_GUIDANCE
99
- }), "tool-skill-manage.skills-guidance");
287
+ if (systemPrompt) ctx.effect(() => {
288
+ const disposeVariable = systemPrompt.variable(PROTECTED_SKILLS_VARIABLE, () => protectedSkillsLine());
289
+ const disposeSection = systemPrompt.section({
290
+ name: "evolution-skills-guidance",
291
+ order: SKILLS_GUIDANCE_SECTION_ORDER,
292
+ text: GUIDANCE_WITH_GUARD
293
+ });
294
+ return () => {
295
+ disposeSection();
296
+ disposeVariable();
297
+ };
298
+ }, "tool-skill-manage.skills-guidance");
299
+ /** The policy snapshot's protected list, or `undefined` when the row is not mounted — read by
300
+ * the write gates at BOTH points, through the one unknown-boundary helper above. */
301
+ const protectedSkillNamesOf = () => policySnapshotOf(ctx.get("evolutionPolicy"))?.protectedSkillNames;
302
+ /**
303
+ * The protected-skill line appended to the skills guidance (B4, design §4.3).
304
+ *
305
+ * The list is otherwise visible only to the REVIEW prompts, so the tool-path model could learn a
306
+ * name was protected only by having a write refused — this line is what lets it avoid the call.
307
+ * It returns `''` — never `undefined`, which the platform refuses to render — when no policy
308
+ * row is mounted or nothing is protected, and the empty value leaves the section byte-identical
309
+ * to the guidance alone, so the guidance's prefix-cache stability is untouched.
310
+ * @returns the line including the newline that joins it to the guidance.
311
+ */
312
+ const protectedSkillsLine = () => {
313
+ try {
314
+ const names = protectedSkillNamesOf() ?? [];
315
+ if (names.length === 0) return "";
316
+ const shown = names.slice(0, PROTECTED_SKILLS_INLINE_MAX);
317
+ const rest = names.length - shown.length;
318
+ return `\nProtected skills (do not edit): ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}`;
319
+ } catch {
320
+ return "";
321
+ }
322
+ };
323
+ let writeGateWarned = false;
324
+ const warnWriteGateOnce = (message) => {
325
+ if (writeGateWarned) return;
326
+ writeGateWarned = true;
327
+ ctx.logger.warn(message);
328
+ };
329
+ const probe = ctx;
330
+ let confirmWarned = false;
331
+ const confirmUnavailable = (reason) => {
332
+ if (!confirmWarned) {
333
+ confirmWarned = true;
334
+ ctx.logger.warn(`skill_manage: the confirmation prompt could not be shown (${reason}) — the write proceeds unconfirmed.`);
335
+ }
336
+ return true;
337
+ };
338
+ const isDismissed = (error) => questionErrorCode(error) === "ASK_ABORTED";
339
+ const confirmSkillWrite = async (request, exec) => {
340
+ const questions = probe.get("userQuestions");
341
+ if (questions === void 0) return confirmUnavailable("no user-questions service is mounted");
342
+ const ask = (agent) => questions.ask({
343
+ questions: [{
344
+ id: request.question.id,
345
+ header: request.question.header,
346
+ question: request.question.question,
347
+ options: request.question.options.map((option) => ({ label: option.label }))
348
+ }],
349
+ agent,
350
+ ...exec.signal !== void 0 ? { signal: exec.signal } : {}
351
+ });
352
+ const declared = exec.agent;
353
+ if (declared !== void 0) try {
354
+ return confirmedBy(await ask(declared), request.question.id, request.confirmLabel);
355
+ } catch (error) {
356
+ if (isDismissed(error)) return false;
357
+ if (questionErrorCode(error) !== "CALLER_NOT_LIVE") return confirmUnavailable(reasonOfError(error));
358
+ }
359
+ const located = liveRootAgentOf(probe, exec);
360
+ if (located === void 0) return confirmUnavailable("the calling session has no live root agent");
361
+ try {
362
+ return confirmedBy(await ask(located), request.question.id, request.confirmLabel);
363
+ } catch (error) {
364
+ return isDismissed(error) ? false : confirmUnavailable(reasonOfError(error));
365
+ }
366
+ };
100
367
  const io = evolutionIoAdapter(() => ctx.evolutionIo.provider());
101
368
  const numericClamped = [];
102
369
  const limit = (name, value, fallback) => {
@@ -160,7 +427,7 @@ function apply(ctx, rawConfig = {}) {
160
427
  supportFileCharPolicy: resolved.supportFileCharPolicy
161
428
  });
162
429
  };
163
- section.overrides = installParamSection(ctx, PARAM_NAMESPACES["tool-skill-manage"] ?? "evolution-skills", SKILLS_SETTINGS_SCHEMA, settingsBase, {
430
+ section.overrides = installParamSection(ctx, paramNamespace("tool-skill-manage"), SKILLS_SETTINGS_SCHEMA, settingsBase, {
164
431
  warn: (message) => {
165
432
  ctx.logger.warn("dsh-evolution-skills: " + message);
166
433
  },
@@ -240,32 +507,20 @@ function apply(ctx, rawConfig = {}) {
240
507
  skills: []
241
508
  };
242
509
  }
243
- const scalarArgs = args;
244
- for (const field of [
245
- "name",
246
- "content",
247
- "old_string",
248
- "new_string",
249
- "file_path",
250
- "file_content",
251
- "absorbed_into"
252
- ]) {
253
- const value = scalarArgs[field];
254
- if (value !== void 0 && value !== null && typeof value !== "string") return {
255
- ok: false,
256
- message: `skill_manage: "${field}" must be a string (got ${typeof value}); refusing the write.`,
257
- skills: []
258
- };
259
- }
260
- if (origin !== "foreground") {
261
- if ((policySnapshotOf(ctx.get("evolutionPolicy"))?.protectedSkillNames)?.includes(name)) return {
262
- ok: false,
263
- message: `skill_manage: "${name}" is protected by the current policy (protectedSkillNames); replayed/autonomous writes are refused.`,
264
- skills: []
265
- };
266
- }
267
- const missing = missingRequiredArgs(args);
268
- if (missing.length > 0) return missingArgsRefusal(action, missing);
510
+ const refusal = await runWriteGates({
511
+ point: "execution",
512
+ args,
513
+ origin,
514
+ protectedNames: protectedSkillNamesOf(),
515
+ readNames: void 0,
516
+ confirm: void 0,
517
+ warn: warnWriteGateOnce
518
+ });
519
+ if (refusal !== null) return {
520
+ ok: false,
521
+ message: refusal,
522
+ skills: []
523
+ };
269
524
  let feedbackLines = [];
270
525
  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;
271
526
  if ((action === "create" || action === "edit" || action === "update") && args.content) {
@@ -447,8 +702,20 @@ function apply(ctx, rawConfig = {}) {
447
702
  skills: []
448
703
  };
449
704
  }
450
- const missing = missingRequiredArgs(args);
451
- if (missing.length > 0) return missingArgsRefusal(args.action, missing);
705
+ const refusal = await runWriteGates({
706
+ point: "admission",
707
+ args,
708
+ origin: libraryOrigin,
709
+ protectedNames: protectedSkillNamesOf(),
710
+ readNames: sessionReadSkillNames(exec.agent?.session),
711
+ confirm: async (request) => confirmSkillWrite(request, exec),
712
+ warn: warnWriteGateOnce
713
+ });
714
+ if (refusal !== null) return {
715
+ ok: false,
716
+ message: refusal,
717
+ skills: []
718
+ };
452
719
  const approval = ctx.get("evolutionApproval");
453
720
  if (approval && args.action !== "list" && args.action !== "review" && args.action !== "pin" && args.action !== "unpin") {
454
721
  if ((args.action === "update" || args.action === "edit") && typeof args.name === "string" && args.name !== "") {
@@ -507,4 +774,4 @@ function apply(ctx, rawConfig = {}) {
507
774
  });
508
775
  }
509
776
  //#endregion
510
- export { Config, SKILLS_SETTINGS_NAMESPACE, SKILLS_SETTINGS_SCHEMA, SKILL_SETTINGS_CAPS, apply, inject, name, validateSkillSettings };
777
+ export { Config, SKILLS_SETTINGS_SCHEMA, SKILL_SETTINGS_CAPS, apply, inject, name, validateSkillSettings };
@@ -48,8 +48,6 @@ export interface Config {
48
48
  strictCrossSource?: boolean;
49
49
  }
50
50
  export declare const Config: z<Config>;
51
- /** Namespace the write caps and the four stage policies live in (core's PARAM_NAMESPACES). */
52
- export declare const SKILLS_SETTINGS_NAMESPACE = "evolution-skills";
53
51
  /** Write behaviour a user may change (G3/S3.4). Field names are the CANONICAL
54
52
  * parameter ids from the registry. The four caps are TIGHTEN-ONLY: the settings
55
53
  * `validate` hook refuses a value above what the deployment allocated, because
@@ -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.7.0",
4
+ "version": "0.9.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.7.0",
31
- "@lmzhen/dsh-evolution-core": "^0.7.0"
30
+ "@lmzhen/dsh-evolution-approval": "^0.9.0",
31
+ "@lmzhen/dsh-evolution-core": "^0.9.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.7.0",
39
- "@lmzhen/dsh-skill-usage": "^0.7.0"
38
+ "@lmzhen/dsh-evolution-io": "^0.9.0",
39
+ "@lmzhen/dsh-skill-usage": "^0.9.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.7.0",
47
- "@lmzhen/dsh-evolution-io": "^0.7.0",
48
- "@lmzhen/dsh-skill-usage": "^0.7.0"
46
+ "@lmzhen/dsh-evolution-core": "^0.9.0",
47
+ "@lmzhen/dsh-evolution-io": "^0.9.0",
48
+ "@lmzhen/dsh-skill-usage": "^0.9.0"
49
49
  }
50
50
  }