@rubytech/taskmaster 1.5.1 → 1.5.3

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.
@@ -243,10 +243,26 @@ export async function syncSkillsToWorkspace(params) {
243
243
  }
244
244
  });
245
245
  }
246
+ /**
247
+ * Bundled skills that must be overwritten in existing workspaces.
248
+ *
249
+ * Normally, bundled skill sync preserves existing workspace skills so user
250
+ * modifications are not lost. When a bundled skill changes in a way that
251
+ * affects functionality (e.g. referencing a new tool), add its directory
252
+ * name here so it is force-copied on the next gateway restart.
253
+ *
254
+ * Remove entries after they have shipped in at least one release.
255
+ */
256
+ const FORCE_RESYNC_SKILLS = new Set([
257
+ // v1.6: skill-builder updated to use skill_draft_save tool instead of write
258
+ "skill-builder",
259
+ ]);
246
260
  /**
247
261
  * Copy bundled skills into a workspace's `skills/` directory.
248
262
  * Only copies skills whose target directory does not already exist,
249
- * preserving any user modifications to existing workspace skills.
263
+ * preserving any user modifications to existing workspace skills
264
+ * unless the skill is in {@link FORCE_RESYNC_SKILLS}, in which case
265
+ * the workspace copy is replaced with the bundled version.
250
266
  */
251
267
  export async function syncBundledSkillsToWorkspace(workspaceDir, opts) {
252
268
  const bundledDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir();
@@ -266,9 +282,14 @@ export async function syncBundledSkillsToWorkspace(workspaceDir, opts) {
266
282
  const synced = [];
267
283
  for (const name of entries) {
268
284
  const dest = path.join(targetSkillsDir, name);
269
- if (fs.existsSync(dest))
285
+ const exists = fs.existsSync(dest);
286
+ if (exists && !FORCE_RESYNC_SKILLS.has(name))
270
287
  continue;
271
288
  try {
289
+ if (exists) {
290
+ await fsp.rm(dest, { recursive: true, force: true });
291
+ skillsLogger.info(`force-resyncing bundled skill "${name}" (updated in this version)`);
292
+ }
272
293
  await fsp.cp(path.join(bundledDir, name), dest, { recursive: true });
273
294
  synced.push(name);
274
295
  }
@@ -29,6 +29,7 @@ import { createFileListTool } from "./tools/file-list-tool.js";
29
29
  import { createContactLookupTool } from "./tools/contact-lookup-tool.js";
30
30
  import { createContactUpdateTool } from "./tools/contact-update-tool.js";
31
31
  import { createRelayMessageTool } from "./tools/relay-message-tool.js";
32
+ import { createSkillDraftSaveTool } from "./tools/skill-draft-save-tool.js";
32
33
  import { createSkillReadTool } from "./tools/skill-read-tool.js";
33
34
  import { createApiKeysTool } from "./tools/apikeys-tool.js";
34
35
  import { createImageGenerateTool } from "./tools/image-generate-tool.js";
@@ -162,6 +163,13 @@ export function createTaskmasterTools(options) {
162
163
  if (skillDirs.length > 0) {
163
164
  tools.push(createSkillReadTool({ allowedSkillDirs: skillDirs }));
164
165
  }
166
+ // Add skill draft save tool
167
+ const skillDraftSaveTool = createSkillDraftSaveTool({
168
+ config: options?.config,
169
+ agentSessionKey: options?.agentSessionKey,
170
+ });
171
+ if (skillDraftSaveTool)
172
+ tools.push(skillDraftSaveTool);
165
173
  // Add memory tools (conditionally based on config)
166
174
  const memoryToolOptions = {
167
175
  config: options?.config,
@@ -33,6 +33,8 @@ export const TOOL_GROUPS = {
33
33
  "group:messaging": ["message"],
34
34
  // Nodes + device tools
35
35
  "group:nodes": ["nodes"],
36
+ // Skill management
37
+ "group:skills": ["skill_read", "skill_draft_save"],
36
38
  // Contact record management
37
39
  "group:contacts": ["contact_create", "contact_delete", "contact_lookup", "contact_update"],
38
40
  // Admin management tools
@@ -68,6 +70,7 @@ export const TOOL_GROUPS = {
68
70
  "api_keys",
69
71
  "file_delete",
70
72
  "file_list",
73
+ "skill_draft_save",
71
74
  ],
72
75
  };
73
76
  // Tools that are never granted by profiles — must be explicitly added to the
@@ -110,6 +113,7 @@ const TOOL_PROFILES = {
110
113
  "tts",
111
114
  "license_generate",
112
115
  "group:contacts",
116
+ "group:skills",
113
117
  "relay_message",
114
118
  "memory_save_media",
115
119
  "image_generate",
@@ -0,0 +1,110 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { resolveAgentWorkspaceRoot, resolveSessionAgentId } from "../agent-scope.js";
5
+ import { jsonResult, readStringParam } from "./common.js";
6
+ const ReferenceSchema = Type.Object({
7
+ name: Type.String({ description: "Filename for the reference (e.g. escalation.md)" }),
8
+ content: Type.String({ description: "Markdown content of the reference file" }),
9
+ });
10
+ const SkillDraftSaveSchema = Type.Object({
11
+ name: Type.String({
12
+ description: "Skill name — lowercase, hyphen-separated (e.g. inventory-management). " +
13
+ "Used as the directory name under .skill-drafts/.",
14
+ }),
15
+ skill_content: Type.String({
16
+ description: "Full content of SKILL.md including YAML frontmatter (name, description) " +
17
+ "and all behaviour rules.",
18
+ }),
19
+ references: Type.Optional(Type.Array(ReferenceSchema, {
20
+ description: "Optional reference files to save under references/. " +
21
+ "Each entry needs a name (filename) and content (markdown).",
22
+ })),
23
+ });
24
+ /**
25
+ * Create a tool that saves skill drafts to the workspace `.skill-drafts/` directory.
26
+ *
27
+ * This is the directory the Control Panel scans when showing available drafts
28
+ * (via the `skills.drafts` gateway method). Agents that lack `group:fs` can
29
+ * still create skill drafts through this purpose-built tool.
30
+ */
31
+ export function createSkillDraftSaveTool(options) {
32
+ const cfg = options?.config;
33
+ if (!cfg)
34
+ return null;
35
+ const agentId = resolveSessionAgentId({
36
+ sessionKey: options?.agentSessionKey,
37
+ config: cfg,
38
+ });
39
+ return {
40
+ label: "Skill Draft Save",
41
+ name: "skill_draft_save",
42
+ description: "Save a skill draft so the business owner can review and install it from the Control Panel. " +
43
+ "Writes SKILL.md and optional reference files to the .skill-drafts/ directory. " +
44
+ "The draft will appear under Skills → Add Skill in the Control Panel.",
45
+ parameters: SkillDraftSaveSchema,
46
+ execute: async (_toolCallId, params) => {
47
+ const p = params;
48
+ const name = readStringParam(p, "name", { required: true });
49
+ const skillContent = readStringParam(p, "skill_content", { required: true });
50
+ // Validate skill name: lowercase, hyphens, no path traversal
51
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) {
52
+ return jsonResult({
53
+ success: false,
54
+ error: "Invalid skill name. Use lowercase letters, numbers, and hyphens " +
55
+ "(e.g. inventory-management). Must start and end with a letter or number.",
56
+ });
57
+ }
58
+ const workspaceRoot = resolveAgentWorkspaceRoot(cfg, agentId);
59
+ const draftDir = path.join(workspaceRoot, ".skill-drafts", name);
60
+ try {
61
+ // Create the draft directory
62
+ fs.mkdirSync(draftDir, { recursive: true });
63
+ // Write SKILL.md
64
+ const skillPath = path.join(draftDir, "SKILL.md");
65
+ fs.writeFileSync(skillPath, skillContent, "utf-8");
66
+ // Write reference files if provided
67
+ const refs = p.references;
68
+ const refsSaved = [];
69
+ if (Array.isArray(refs) && refs.length > 0) {
70
+ const refsDir = path.join(draftDir, "references");
71
+ fs.mkdirSync(refsDir, { recursive: true });
72
+ for (const ref of refs) {
73
+ if (typeof ref !== "object" ||
74
+ ref === null ||
75
+ typeof ref.name !== "string" ||
76
+ typeof ref.content !== "string") {
77
+ continue;
78
+ }
79
+ const refName = ref.name.trim();
80
+ const refContent = ref.content;
81
+ // Sanitise reference filename — no path traversal
82
+ if (!refName || refName.includes("/") || refName.includes("\\") || refName === "..") {
83
+ continue;
84
+ }
85
+ const refPath = path.join(refsDir, refName);
86
+ fs.writeFileSync(refPath, refContent, "utf-8");
87
+ refsSaved.push(refName);
88
+ }
89
+ }
90
+ return jsonResult({
91
+ success: true,
92
+ name,
93
+ path: draftDir,
94
+ skillFile: "SKILL.md",
95
+ references: refsSaved,
96
+ message: `Skill draft "${name}" saved. ` +
97
+ "The business owner can install it from the Control Panel → Skills → Add Skill.",
98
+ });
99
+ }
100
+ catch (err) {
101
+ const message = err instanceof Error ? err.message : String(err);
102
+ return jsonResult({
103
+ success: false,
104
+ name,
105
+ error: `Failed to save skill draft: ${message}`,
106
+ });
107
+ }
108
+ },
109
+ };
110
+ }
@@ -3,10 +3,33 @@ export const SILENT_REPLY_TOKEN = "NO_REPLY";
3
3
  function escapeRegExp(value) {
4
4
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
5
  }
6
+ /**
7
+ * Maximum text length for prefix/suffix silent-reply matching.
8
+ * Short texts (under this limit) may be model artifacts like
9
+ * "NO_REPLY -- I have nothing to add" and are safely suppressed.
10
+ * Longer texts contain substantive content that must not be silently
11
+ * dropped just because the token appears at an edge.
12
+ */
13
+ const MAX_SILENT_ARTIFACT_LENGTH = 200;
6
14
  export function isSilentReplyText(text, token = SILENT_REPLY_TOKEN) {
7
15
  if (!text)
8
16
  return false;
17
+ const trimmed = text.trim();
18
+ if (!trimmed)
19
+ return false;
9
20
  const escaped = escapeRegExp(token);
21
+ // Exact match: the entire text is just the token (with optional
22
+ // surrounding whitespace and trailing punctuation). Always silent.
23
+ if (new RegExp(`^\\s*${escaped}\\W*$`).test(trimmed))
24
+ return true;
25
+ // Long text contains real content — never suppress it, even if the
26
+ // token appears at the edges. This prevents silently dropping
27
+ // substantive agent output (e.g. a multi-paragraph report ending
28
+ // with NO_REPLY).
29
+ if (trimmed.length > MAX_SILENT_ARTIFACT_LENGTH)
30
+ return false;
31
+ // Short text: allow prefix/suffix matching to catch model artifacts
32
+ // like "NO_REPLY -- I have nothing to add" or "interject.NO_REPLY".
10
33
  const prefix = new RegExp(`^\\s*${escaped}(?=$|\\W)`);
11
34
  if (prefix.test(text))
12
35
  return true;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.5.1",
3
- "commit": "c41ff963c35a771e2926c6229418a18568bdd3bb",
4
- "builtAt": "2026-02-25T16:02:37.451Z"
2
+ "version": "1.5.3",
3
+ "commit": "1449370d37952ff73891d8e8d1427a3ac5fd2200",
4
+ "builtAt": "2026-02-26T11:37:00.318Z"
5
5
  }
@@ -144,6 +144,7 @@ export function buildDefaultAgentList(workspaceRoot) {
144
144
  "relay_message",
145
145
  "browser",
146
146
  "skill_read",
147
+ "skill_draft_save",
147
148
  ],
148
149
  deny: ["exec", "process", "group:runtime", "canvas"],
149
150
  },
@@ -325,6 +325,38 @@ export function applyContextPruningDefaults(cfg) {
325
325
  },
326
326
  };
327
327
  }
328
+ /**
329
+ * Default model fallback chain — used when the primary model fails and no
330
+ * explicit fallbacks are configured. Candidates that lack an API key are
331
+ * silently skipped at runtime, so listing them here is always safe.
332
+ */
333
+ const DEFAULT_MODEL_FALLBACKS = [
334
+ "google/gemini-3-pro-preview",
335
+ "openai/gpt-5.2",
336
+ ];
337
+ export function applyModelFallbackDefaults(cfg) {
338
+ const model = cfg.agents?.defaults?.model;
339
+ // Already has explicit fallbacks — respect the user's choice.
340
+ // An empty array is treated as "no fallbacks configured" (migration artifact),
341
+ // not as "user deliberately disabled fallbacks".
342
+ if (model && Array.isArray(model.fallbacks) && model.fallbacks.length > 0)
343
+ return cfg;
344
+ const defaults = cfg.agents?.defaults ?? {};
345
+ const existing = (model && typeof model === "object" ? model : {});
346
+ return {
347
+ ...cfg,
348
+ agents: {
349
+ ...cfg.agents,
350
+ defaults: {
351
+ ...defaults,
352
+ model: {
353
+ ...existing,
354
+ fallbacks: [...DEFAULT_MODEL_FALLBACKS],
355
+ },
356
+ },
357
+ },
358
+ };
359
+ }
328
360
  export function applyCompactionDefaults(cfg) {
329
361
  const defaults = cfg.agents?.defaults;
330
362
  if (!defaults)
package/dist/config/io.js CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import JSON5 from "json5";
6
6
  import { loadShellEnvFallback, resolveShellEnvFallbackTimeoutMs, shouldDeferShellEnvFallback, shouldEnableShellEnvFallback, } from "../infra/shell-env.js";
7
7
  import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
8
- import { applyApiKeys, applyCompactionDefaults, applyContextPruningDefaults, applyAgentDefaults, applyLoggingDefaults, applyMessageDefaults, applyModelDefaults, applySessionDefaults, applyTalkApiKey, } from "./defaults.js";
8
+ import { applyApiKeys, applyCompactionDefaults, applyContextPruningDefaults, applyAgentDefaults, applyLoggingDefaults, applyMessageDefaults, applyModelDefaults, applyModelFallbackDefaults, applySessionDefaults, applyTalkApiKey, } from "./defaults.js";
9
9
  import { VERSION } from "../version.js";
10
10
  import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js";
11
11
  import { collectConfigEnvVars } from "./env-vars.js";
@@ -203,7 +203,7 @@ export function createConfigIO(overrides = {}) {
203
203
  deps.logger.warn(`Config warnings:\\n${details}`);
204
204
  }
205
205
  warnIfConfigFromFuture(validated.config, deps.logger);
206
- const cfg = applyApiKeys(applyModelDefaults(applyCompactionDefaults(applyContextPruningDefaults(applyAgentDefaults(applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))))))));
206
+ const cfg = applyApiKeys(applyModelFallbackDefaults(applyModelDefaults(applyCompactionDefaults(applyContextPruningDefaults(applyAgentDefaults(applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config)))))))));
207
207
  normalizeConfigPaths(cfg);
208
208
  const duplicates = findDuplicateAgentDirs(cfg, {
209
209
  env: deps.env,
@@ -242,7 +242,7 @@ export function createConfigIO(overrides = {}) {
242
242
  const exists = deps.fs.existsSync(configPath);
243
243
  if (!exists) {
244
244
  const hash = hashConfigRaw(null);
245
- const config = applyApiKeys(applyTalkApiKey(applyModelDefaults(applyCompactionDefaults(applyContextPruningDefaults(applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))))))));
245
+ const config = applyApiKeys(applyTalkApiKey(applyModelFallbackDefaults(applyModelDefaults(applyCompactionDefaults(applyContextPruningDefaults(applyAgentDefaults(applySessionDefaults(applyMessageDefaults({})))))))));
246
246
  const legacyIssues = [];
247
247
  return {
248
248
  path: configPath,
@@ -350,7 +350,7 @@ export function createConfigIO(overrides = {}) {
350
350
  raw,
351
351
  parsed: parsedRes.parsed,
352
352
  valid: true,
353
- config: normalizeConfigPaths(applyApiKeys(applyTalkApiKey(applyModelDefaults(applyAgentDefaults(applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config)))))))),
353
+ config: normalizeConfigPaths(applyApiKeys(applyTalkApiKey(applyModelFallbackDefaults(applyModelDefaults(applyAgentDefaults(applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))))))))),
354
354
  hash,
355
355
  issues: [],
356
356
  warnings: validated.warnings,
@@ -216,6 +216,57 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3 = [
216
216
  }
217
217
  },
218
218
  },
219
+ {
220
+ id: "admin-agent-tools-add-skill_draft_save",
221
+ describe: "Add skill_draft_save to admin agent tools.allow",
222
+ apply: (raw, changes) => {
223
+ const agents = getRecord(raw.agents);
224
+ const list = getAgentsList(agents);
225
+ for (const entry of list) {
226
+ if (!isRecord(entry))
227
+ continue;
228
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
229
+ if (!id)
230
+ continue;
231
+ const isAdmin = id === "admin" || id.endsWith("-admin");
232
+ if (!isAdmin)
233
+ continue;
234
+ const tools = getRecord(entry.tools);
235
+ if (!tools)
236
+ continue;
237
+ if (!Array.isArray(tools.allow))
238
+ continue;
239
+ const allow = tools.allow;
240
+ if (!allow.includes("skill_draft_save") && !allow.includes("group:skills")) {
241
+ allow.push("skill_draft_save");
242
+ changes.push(`Added skill_draft_save to agent "${id}" tools.allow.`);
243
+ }
244
+ }
245
+ },
246
+ },
247
+ {
248
+ id: "agents.defaults.model.fallbacks-defaults",
249
+ describe: "Set default model fallback chain (Google Gemini Pro, OpenAI GPT-5.2)",
250
+ apply: (raw, changes) => {
251
+ const agents = getRecord(raw.agents);
252
+ const defaults = getRecord(agents?.defaults);
253
+ if (!defaults)
254
+ return;
255
+ const model = getRecord(defaults.model);
256
+ if (model) {
257
+ // Already has non-empty fallbacks — respect the user's choice.
258
+ if (Array.isArray(model.fallbacks) && model.fallbacks.length > 0)
259
+ return;
260
+ model.fallbacks = ["google/gemini-3-pro-preview", "openai/gpt-5.2"];
261
+ }
262
+ else {
263
+ defaults.model = {
264
+ fallbacks: ["google/gemini-3-pro-preview", "openai/gpt-5.2"],
265
+ };
266
+ }
267
+ changes.push("Set default model fallbacks: google/gemini-3-pro-preview, openai/gpt-5.2.");
268
+ },
269
+ },
219
270
  {
220
271
  id: "agents-tools-remove-sessions_send",
221
272
  describe: "Remove sessions_send from agent tools.allow (tool removed for security)",
@@ -5,7 +5,7 @@ import { normalizePluginsConfig, resolveEnableState, resolveMemorySlotDecision,
5
5
  import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
6
6
  import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
7
7
  import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
8
- import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
8
+ import { applyAgentDefaults, applyModelDefaults, applyModelFallbackDefaults, applySessionDefaults, } from "./defaults.js";
9
9
  import { findLegacyConfigIssues } from "./legacy.js";
10
10
  import { TaskmasterSchema } from "./zod-schema.js";
11
11
  const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
@@ -102,7 +102,7 @@ export function validateConfigObject(raw) {
102
102
  }
103
103
  return {
104
104
  ok: true,
105
- config: applyModelDefaults(applyAgentDefaults(applySessionDefaults(validated.data))),
105
+ config: applyModelFallbackDefaults(applyModelDefaults(applyAgentDefaults(applySessionDefaults(validated.data)))),
106
106
  };
107
107
  }
108
108
  function isRecord(value) {