@lmzhen/dsh-tool-skill-manage 0.8.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 +45 -2
- package/lib/index.js +315 -46
- package/lib/types/write-gates.d.ts +83 -0
- package/package.json +8 -8
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
|
|
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, 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_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),
|
|
@@ -70,31 +244,126 @@ const SKILL_SETTINGS_CAPS = [
|
|
|
70
244
|
function validateSkillSettings(value, ceilings) {
|
|
71
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]}`);
|
|
72
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
|
+
}
|
|
73
279
|
/** v30 REV-02: read the protected-skill list off the (optional) policy
|
|
74
280
|
* snapshot through an `unknown` boundary — the Context augmentation types the
|
|
75
281
|
* getter non-optionally, but at runtime the row can be absent. */
|
|
76
282
|
function policySnapshotOf(source) {
|
|
77
283
|
return source?.get?.();
|
|
78
284
|
}
|
|
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
285
|
function apply(ctx, rawConfig = {}) {
|
|
92
286
|
const systemPrompt = ctx.get("systemPrompt");
|
|
93
|
-
if (systemPrompt) ctx.effect(() =>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
};
|
|
98
367
|
const io = evolutionIoAdapter(() => ctx.evolutionIo.provider());
|
|
99
368
|
const numericClamped = [];
|
|
100
369
|
const limit = (name, value, fallback) => {
|
|
@@ -238,32 +507,20 @@ function apply(ctx, rawConfig = {}) {
|
|
|
238
507
|
skills: []
|
|
239
508
|
};
|
|
240
509
|
}
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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);
|
|
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
|
+
};
|
|
267
524
|
let feedbackLines = [];
|
|
268
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;
|
|
269
526
|
if ((action === "create" || action === "edit" || action === "update") && args.content) {
|
|
@@ -445,8 +702,20 @@ function apply(ctx, rawConfig = {}) {
|
|
|
445
702
|
skills: []
|
|
446
703
|
};
|
|
447
704
|
}
|
|
448
|
-
const
|
|
449
|
-
|
|
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
|
+
};
|
|
450
719
|
const approval = ctx.get("evolutionApproval");
|
|
451
720
|
if (approval && args.action !== "list" && args.action !== "review" && args.action !== "pin" && args.action !== "unpin") {
|
|
452
721
|
if ((args.action === "update" || args.action === "edit") && typeof args.name === "string" && args.name !== "") {
|
|
@@ -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.
|
|
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.
|
|
31
|
-
"@lmzhen/dsh-evolution-core": "^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.
|
|
39
|
-
"@lmzhen/dsh-skill-usage": "^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.
|
|
47
|
-
"@lmzhen/dsh-evolution-io": "^0.
|
|
48
|
-
"@lmzhen/dsh-skill-usage": "^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
|
}
|