@dotdrelle/wiki-manager 0.15.43 → 0.15.49
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 +92 -24
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +288 -22
- package/src/agent/graph.test.js +551 -1
- package/src/agent/skillRecursion.test.js +98 -0
- package/src/cli/wiki-manager.js +209 -7
- package/src/cli/wiki-manager.test.js +89 -0
- package/src/commands/slash.js +28 -10
- package/src/contracts/schemas.js +1 -1
- package/src/core/agentEvents.js +50 -0
- package/src/core/agentEvents.test.js +52 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/env.js +20 -1
- package/src/core/env.test.js +34 -0
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeLog.js +15 -0
- package/src/core/runtimeLog.test.js +15 -1
- package/src/core/skillChainView.js +84 -0
- package/src/core/skillChainView.test.js +50 -0
- package/src/core/skillCompiler.js +135 -0
- package/src/core/skillCompiler.test.js +91 -0
- package/src/core/skillInvocation.js +79 -0
- package/src/core/skillInvocation.test.js +73 -0
- package/src/core/skills.js +81 -19
- package/src/runtime/approvals.js +13 -1
- package/src/runtime/approvals.test.js +44 -0
- package/src/runtime/client.js +45 -4
- package/src/runtime/controlCancellation.js +33 -0
- package/src/runtime/controlCancellation.test.js +49 -0
- package/src/runtime/controlDrain.js +50 -0
- package/src/runtime/controlDrain.test.js +38 -0
- package/src/runtime/server.js +333 -15
- package/src/runtime/server.test.js +392 -0
- package/src/runtime/skillChain.e2e.test.js +394 -0
- package/src/runtime/skillRun.js +104 -0
- package/src/runtime/skillRun.test.js +84 -0
- package/src/runtime/store.js +69 -0
- package/src/runtime/store.test.js +11 -0
- package/src/shell/RightPane.tsx +3 -2
- package/src/shell/repl.js +40 -5
- package/src/shell/repl.test.js +41 -0
- package/src/shell/useSession.ts +43 -9
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { compileSkillObjectives, createSkillCompilerFallback, deterministicObjectives, validateCompiledObjectives } from './skillCompiler.js';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { parseFrontmatter } from './skills.js';
|
|
7
|
+
|
|
8
|
+
test('numbered and bullet lists are strong objective boundaries', () => {
|
|
9
|
+
assert.equal(deterministicObjectives('1. Export source\n2. Ingest files').objectives.length, 2);
|
|
10
|
+
assert.equal(deterministicObjectives('- Export source\n- Ingest files').objectives.length, 2);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('paragraphs preserve one delegable intention while Puis splits', async () => {
|
|
14
|
+
assert.equal((await compileSkillObjectives({ body: 'Run the complete pipeline.\n\nInclude indexing.\n\nReport it.' })).length, 1);
|
|
15
|
+
assert.equal((await compileSkillObjectives({ body: 'Exporter la source.\n\nPuis ingérer les fichiers.' })).length, 2);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('optional objectives continue on failure', async () => {
|
|
19
|
+
const result = await compileSkillObjectives({ body: 'Export source.\n\nOptionnellement, envoyer une notification.' });
|
|
20
|
+
assert.equal(result[1].optional, true);
|
|
21
|
+
assert.equal(result[1].continueOnFailure, true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('compiler appends natural parameters without teaching placeholders', async () => {
|
|
25
|
+
const [objective] = await compileSkillObjectives({ body: 'Build the deliverable.' }, { template: 'architecture' });
|
|
26
|
+
assert.match(objective.text, /User parameters:\ntemplate: architecture/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('validation rejects technical routing details', () => {
|
|
30
|
+
assert.throws(() => validateCompiledObjectives([{ text: 'agent: cme' }]), { code: 'skill_compile_failed' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('scaffold skills preserve existing capabilities and split only wiki-sync', async () => {
|
|
34
|
+
const expected = { pipeline: 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
|
|
35
|
+
for (const [name, count] of Object.entries(expected)) {
|
|
36
|
+
const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
|
|
37
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
38
|
+
assert.equal((await compileSkillObjectives({ ...meta, body })).length, count, name);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('LLM fallback degrades from missing tool calls to validated JSON text', async () => {
|
|
43
|
+
let calls = 0;
|
|
44
|
+
const fallback = createSkillCompilerFallback({ completeWithTools: async ({ tools }) => {
|
|
45
|
+
calls += 1;
|
|
46
|
+
return tools.length ? { content: null, tool_calls: null } : { content: '{"objectives":[{"text":"Do the coherent workflow."}]}' };
|
|
47
|
+
} });
|
|
48
|
+
assert.equal(await fallback({ body: 'ambiguous', maxObjectives: 12 }), '{"objectives":[{"text":"Do the coherent workflow."}]}');
|
|
49
|
+
assert.equal(calls, 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('every objective of a chain carries the user parameters, not just the last', async () => {
|
|
53
|
+
// Appending the parameters before splitting attached `source` to the ingest
|
|
54
|
+
// step and left the export step — the one that consumes it — without it.
|
|
55
|
+
const skill = {
|
|
56
|
+
name: 'wiki-sync',
|
|
57
|
+
params: ['source'],
|
|
58
|
+
body: 'Export the requested source.\n\nThen ingest what was exported.',
|
|
59
|
+
};
|
|
60
|
+
const objectives = await compileSkillObjectives(skill, { source: 'ESPACE-CONF' });
|
|
61
|
+
assert.equal(objectives.length, 2);
|
|
62
|
+
for (const objective of objectives) {
|
|
63
|
+
assert.match(objective.text, /User parameters:\nsource: ESPACE-CONF/);
|
|
64
|
+
}
|
|
65
|
+
assert.match(objectives[0].text, /^Export the requested source\./);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a parameter named after a routing field does not fail validation', async () => {
|
|
69
|
+
// The routing guard judges the authored intention, not what the caller typed.
|
|
70
|
+
const objectives = await compileSkillObjectives(
|
|
71
|
+
{ name: 'x', params: ['agent'], body: 'Do the thing.' },
|
|
72
|
+
{ agent: 'production' },
|
|
73
|
+
);
|
|
74
|
+
assert.equal(objectives.length, 1);
|
|
75
|
+
assert.match(objectives[0].text, /agent: production/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('LLM fallback sees authored prose and parameters are appended exactly once', async () => {
|
|
79
|
+
let fallbackBody = null;
|
|
80
|
+
const objectives = await compileSkillObjectives(
|
|
81
|
+
{ body: 'Please export the source. Please build the result. Please send the report.' },
|
|
82
|
+
{ source: 'SPACE' },
|
|
83
|
+
{ llmFallback: async ({ body }) => {
|
|
84
|
+
fallbackBody = body;
|
|
85
|
+
return [{ text: body }];
|
|
86
|
+
} },
|
|
87
|
+
);
|
|
88
|
+
assert.doesNotMatch(fallbackBody, /User parameters:/);
|
|
89
|
+
assert.equal(objectives[0].text.match(/User parameters:/g)?.length, 1);
|
|
90
|
+
assert.equal(objectives[0].text.match(/source: SPACE/g)?.length, 1);
|
|
91
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { findSkill } from './skills.js';
|
|
2
|
+
|
|
3
|
+
const INVOCATION_RE = /^\/([A-Za-z0-9_-]+)(?:\s+([\s\S]*))?$/;
|
|
4
|
+
export const RESERVED_SLASH_COMMANDS = new Set(['status', 'stop', 'run', 'queue', 'skills', 'help', 'exit', 'quit', 'chat', 'agent']);
|
|
5
|
+
|
|
6
|
+
export function explicitSkillReference(input, skillName, language = null) {
|
|
7
|
+
const name = escapeRegExp(String(skillName ?? '').trim());
|
|
8
|
+
if (!name) return false;
|
|
9
|
+
const primary = String(language ?? '').toLowerCase().split(/[-_]/)[0];
|
|
10
|
+
if (!['en', 'fr'].includes(primary)) return false;
|
|
11
|
+
const text = String(input ?? '').split(/[.!?\n]/).map((part) => part.trim()).filter(Boolean);
|
|
12
|
+
const keyword = '(?:skill|workflow)';
|
|
13
|
+
const patterns = [
|
|
14
|
+
new RegExp(`\\b${keyword}\\s+${name}\\b`, 'i'),
|
|
15
|
+
new RegExp(`\\b${name}\\s+${keyword}\\b`, 'i'),
|
|
16
|
+
new RegExp(`/skills\\s+run\\s+${name}\\b`, 'i'),
|
|
17
|
+
new RegExp(`/${name}\\s+(?:comme|as)\\s+${keyword}\\b`, 'i'),
|
|
18
|
+
];
|
|
19
|
+
return text.some((sentence) => patterns.some((pattern) => pattern.test(sentence)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function matchSkillInvocation(session, input, { allowReserved = false } = {}) {
|
|
23
|
+
const match = INVOCATION_RE.exec(String(input ?? '').trim());
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
if (!allowReserved && RESERVED_SLASH_COMMANDS.has(match[1].toLowerCase())) return null;
|
|
26
|
+
const skill = findSkill(session, match[1]);
|
|
27
|
+
return skill ? { skill, rawArgs: String(match[2] ?? '').trim(), input: String(input ?? '').trim() } : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseSkillArguments(skill, rawArgs = '') {
|
|
31
|
+
const params = Array.isArray(skill?.params) ? skill.params : [];
|
|
32
|
+
if (params.length === 0) return {};
|
|
33
|
+
if (params.length === 1) return { [params[0]]: unquote(String(rawArgs).trim()) };
|
|
34
|
+
const tokens = tokenizeArguments(rawArgs);
|
|
35
|
+
return Object.fromEntries(params.map((param, index) => [param, index === params.length - 1 ? tokens.slice(index).join(' ') : (tokens[index] ?? '')]));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function applyLegacySkillPlaceholders(body, args) {
|
|
39
|
+
let text = String(body ?? '');
|
|
40
|
+
const used = [];
|
|
41
|
+
for (const [name, value] of Object.entries(args ?? {})) {
|
|
42
|
+
const marker = `{${name}}`;
|
|
43
|
+
if (!text.includes(marker)) continue;
|
|
44
|
+
text = text.replaceAll(marker, String(value ?? ''));
|
|
45
|
+
used.push(name);
|
|
46
|
+
}
|
|
47
|
+
return { body: text, deprecatedPlaceholders: used };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tokenizeArguments(raw) {
|
|
51
|
+
const tokens = [];
|
|
52
|
+
let current = '';
|
|
53
|
+
let quote = null;
|
|
54
|
+
let escaped = false;
|
|
55
|
+
for (const char of String(raw ?? '').trim()) {
|
|
56
|
+
if (escaped) { current += char; escaped = false; }
|
|
57
|
+
else if (char === '\\') escaped = true;
|
|
58
|
+
else if (quote) { if (char === quote) quote = null; else current += char; }
|
|
59
|
+
else if (char === '"' || char === "'") quote = char;
|
|
60
|
+
else if (/\s/.test(char)) { if (current) { tokens.push(current); current = ''; } }
|
|
61
|
+
else current += char;
|
|
62
|
+
}
|
|
63
|
+
if (escaped) current += '\\';
|
|
64
|
+
if (quote) {
|
|
65
|
+
const error = new Error('Unterminated quoted skill argument. Close the quote and retry.');
|
|
66
|
+
error.code = 'skill_arguments_invalid';
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
if (current) tokens.push(current);
|
|
70
|
+
return tokens;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function unquote(value) {
|
|
74
|
+
return value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ? value.slice(1, -1) : value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function escapeRegExp(value) {
|
|
78
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
79
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { applyLegacySkillPlaceholders, explicitSkillReference, matchSkillInvocation, parseSkillArguments } from './skillInvocation.js';
|
|
7
|
+
import { formatSkillsForAgent, inspectSkills } from './skills.js';
|
|
8
|
+
|
|
9
|
+
test('matchSkillInvocation resolves only a real workspace skill', () => {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), 'skill-invocation-'));
|
|
11
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
12
|
+
writeFileSync(join(root, '.wiki', 'skills', 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver.');
|
|
13
|
+
const match = matchSkillInvocation({ workspacePath: root }, '/deliver "architecture" "improve security"');
|
|
14
|
+
assert.equal(match.skill.name, 'deliver');
|
|
15
|
+
assert.equal(match.rawArgs, '"architecture" "improve security"');
|
|
16
|
+
assert.equal(matchSkillInvocation({ workspacePath: root }, '/status'), null);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('parseSkillArguments preserves one free-form argument and parses quoted multi params', () => {
|
|
20
|
+
assert.deepEqual(parseSkillArguments({ params: ['files'] }, 'document A.md document B.md'), { files: 'document A.md document B.md' });
|
|
21
|
+
assert.deepEqual(parseSkillArguments({ params: ['template', 'polish'] }, '"architecture-juno" "améliorer la sécurité réseau"'), { template: 'architecture-juno', polish: 'améliorer la sécurité réseau' });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('legacy placeholders remain supported and are reported', () => {
|
|
25
|
+
assert.deepEqual(applyLegacySkillPlaceholders('Sync {source}.', { source: 'CME' }), { body: 'Sync CME.', deprecatedPlaceholders: ['source'] });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('reserved skill references require an explicit skill or workflow designation', () => {
|
|
29
|
+
assert.equal(explicitSkillReference('lance le skill status', 'status', 'fr-FR'), true);
|
|
30
|
+
assert.equal(explicitSkillReference('/status comme skill', 'status', 'fr-FR'), true);
|
|
31
|
+
assert.equal(explicitSkillReference('/skills run status', 'status', 'en-US'), true);
|
|
32
|
+
assert.equal(explicitSkillReference('What is the status of the current run?', 'status', 'en-US'), false);
|
|
33
|
+
assert.equal(explicitSkillReference('lance /status', 'status', 'fr-FR'), false);
|
|
34
|
+
assert.equal(explicitSkillReference('lance le skill status', 'status', 'de-DE'), false);
|
|
35
|
+
assert.equal(explicitSkillReference('run the status skill', 'status'), false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('inspectSkills rejects invalid parameters and case-insensitive name collisions with relative paths', () => {
|
|
39
|
+
const root = mkdtempSync(join(tmpdir(), 'skill-inspection-'));
|
|
40
|
+
const dir = join(root, '.wiki', 'skills');
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
writeFileSync(join(dir, 'one.md'), '---\nname: Build\ndescription: First\n---\nOne.');
|
|
43
|
+
writeFileSync(join(dir, 'two.md'), '---\nname: build\ndescription: Second\n---\nTwo.');
|
|
44
|
+
writeFileSync(join(dir, 'bad.md'), '---\nname: bad\nparams:\n - __proto__\n---\nBad.');
|
|
45
|
+
const result = inspectSkills({ workspacePath: root });
|
|
46
|
+
assert.equal(result.skills.length, 0);
|
|
47
|
+
assert.deepEqual(result.rejected.map((item) => item.reason).sort(), ['duplicate_name', 'duplicate_name', 'invalid_param']);
|
|
48
|
+
assert.equal(result.rejected.every((item) => !item.relativePath.startsWith('/')), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('skills declare whether execution is orchestrated or direct', () => {
|
|
52
|
+
const root = mkdtempSync(join(tmpdir(), 'skill-execution-'));
|
|
53
|
+
const dir = join(root, '.wiki', 'skills');
|
|
54
|
+
mkdirSync(dir, { recursive: true });
|
|
55
|
+
writeFileSync(join(dir, 'direct.md'), '---\nname: direct\ndescription: Direct action\nexecution: direct\n---\nAct directly.');
|
|
56
|
+
writeFileSync(join(dir, 'default.md'), '---\nname: default\ndescription: Delegated action\n---\nDelegate when needed.');
|
|
57
|
+
writeFileSync(join(dir, 'invalid.md'), '---\nname: invalid\ndescription: Invalid policy\nexecution: magical\n---\nNo.');
|
|
58
|
+
|
|
59
|
+
const result = inspectSkills({ workspacePath: root });
|
|
60
|
+
assert.equal(result.skills.find((skill) => skill.name === 'direct')?.execution, 'direct');
|
|
61
|
+
assert.equal(result.skills.find((skill) => skill.name === 'default')?.execution, 'orchestrated');
|
|
62
|
+
assert.equal(result.rejected.find((item) => item.name === 'invalid')?.reason, 'invalid_execution');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('catalog renders declared parameters and marks a missing description explicit-only', () => {
|
|
66
|
+
const root = mkdtempSync(join(tmpdir(), 'skill-catalog-'));
|
|
67
|
+
const dir = join(root, '.wiki', 'skills');
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
writeFileSync(join(dir, 'deliver.md'), '---\nname: deliver\nparams:\n - template\n - polish\n---\nDeliver.');
|
|
70
|
+
const inspection = inspectSkills({ workspacePath: root });
|
|
71
|
+
assert.deepEqual(inspection.warnings.map((item) => item.reason), ['missing_description']);
|
|
72
|
+
assert.match(formatSkillsForAgent({ workspacePath: root }), /\/deliver \[<template> <polish>\]: workflow skill \[explicit name only\]/);
|
|
73
|
+
});
|
package/src/core/skills.js
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { basename, join } from 'node:path';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
3
|
|
|
4
|
-
const SKILL_NAME_RE = /^[a-zA-Z0-9_-]{
|
|
4
|
+
const SKILL_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
5
|
+
const SKILL_PARAM_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
6
|
+
const DANGEROUS_PARAM_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
|
|
5
7
|
const DEFAULT_UI_SKILL_DIR = '.wiki/skills';
|
|
8
|
+
// The CSI branch must come FIRST. `[` is 0x5B, inside the `[@-_]` range, so the
|
|
9
|
+
// two-character alternative would otherwise consume `ESC [` alone and leave the
|
|
10
|
+
// parameter bytes behind as literal text: "\x1B[31m" would become "31m".
|
|
11
|
+
const ANSI_ESCAPE_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g;
|
|
12
|
+
const CONTROL_CHARACTER_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
13
|
+
const MAX_SKILL_DESCRIPTION_LENGTH = 200;
|
|
6
14
|
|
|
7
|
-
function
|
|
15
|
+
function descriptionMetadata(value) {
|
|
16
|
+
const normalized = String(value || '')
|
|
17
|
+
.replace(ANSI_ESCAPE_RE, '')
|
|
18
|
+
.replace(CONTROL_CHARACTER_RE, '')
|
|
19
|
+
.replace(/\s+/g, ' ')
|
|
20
|
+
.trim();
|
|
21
|
+
return {
|
|
22
|
+
value: normalized.slice(0, MAX_SKILL_DESCRIPTION_LENGTH),
|
|
23
|
+
missing: normalized.length === 0,
|
|
24
|
+
truncated: normalized.length > MAX_SKILL_DESCRIPTION_LENGTH,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapePromptData(value) {
|
|
29
|
+
return String(value)
|
|
30
|
+
.replaceAll('&', '&')
|
|
31
|
+
.replaceAll('<', '<')
|
|
32
|
+
.replaceAll('>', '>');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseFrontmatter(raw) {
|
|
8
36
|
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
9
37
|
if (!match) return { meta: {}, body: raw.trim() };
|
|
10
38
|
const meta = {};
|
|
@@ -30,19 +58,36 @@ function parseFrontmatter(raw) {
|
|
|
30
58
|
return { meta, body: match[2].trim() };
|
|
31
59
|
}
|
|
32
60
|
|
|
33
|
-
function
|
|
61
|
+
function inspectSkillFile(filePath, fallbackName, scope, root) {
|
|
34
62
|
const raw = readFileSync(filePath, 'utf8');
|
|
35
63
|
const { meta, body } = parseFrontmatter(raw);
|
|
36
64
|
const name = String(meta.name || fallbackName).trim();
|
|
37
|
-
|
|
38
|
-
return {
|
|
65
|
+
const relativePath = relative(root, filePath);
|
|
66
|
+
if (!SKILL_NAME_RE.test(name)) return { rejected: { relativePath, name: name || undefined, reason: 'invalid_name' } };
|
|
67
|
+
const params = Array.isArray(meta.params) ? meta.params.map((item) => String(item).trim()) : [];
|
|
68
|
+
if (params.length > 12) return { rejected: { relativePath, name, reason: 'too_many_params' } };
|
|
69
|
+
if (new Set(params).size !== params.length) return { rejected: { relativePath, name, reason: 'duplicate_param' } };
|
|
70
|
+
if (params.some((param) => !SKILL_PARAM_RE.test(param) || DANGEROUS_PARAM_NAMES.has(param))) {
|
|
71
|
+
return { rejected: { relativePath, name, reason: 'invalid_param' } };
|
|
72
|
+
}
|
|
73
|
+
const description = descriptionMetadata(meta.description);
|
|
74
|
+
const execution = String(meta.execution || 'orchestrated').trim().toLowerCase();
|
|
75
|
+
if (!['orchestrated', 'direct'].includes(execution)) {
|
|
76
|
+
return { rejected: { relativePath, name, reason: 'invalid_execution' } };
|
|
77
|
+
}
|
|
78
|
+
const skill = {
|
|
39
79
|
name,
|
|
40
|
-
description:
|
|
41
|
-
params
|
|
80
|
+
description: description.value,
|
|
81
|
+
params,
|
|
42
82
|
body,
|
|
83
|
+
execution,
|
|
43
84
|
scope,
|
|
44
85
|
path: filePath,
|
|
45
86
|
};
|
|
87
|
+
const warnings = [];
|
|
88
|
+
if (description.missing) warnings.push({ relativePath, name, reason: 'missing_description' });
|
|
89
|
+
else if (description.truncated) warnings.push({ relativePath, name, reason: 'description_truncated' });
|
|
90
|
+
return { skill, warnings };
|
|
46
91
|
}
|
|
47
92
|
|
|
48
93
|
export function readOptionalText(filePath) {
|
|
@@ -50,36 +95,53 @@ export function readOptionalText(filePath) {
|
|
|
50
95
|
return readFileSync(filePath, 'utf8').trim();
|
|
51
96
|
}
|
|
52
97
|
|
|
53
|
-
function
|
|
98
|
+
function inspectDirectorySkills(dir, scope, root) {
|
|
54
99
|
if (!existsSync(dir)) return [];
|
|
55
100
|
return readdirSync(dir, { withFileTypes: true })
|
|
56
101
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
57
102
|
.flatMap((entry) => {
|
|
58
103
|
const filePath = join(dir, entry.name);
|
|
59
|
-
|
|
60
|
-
return skill ? [skill] : [];
|
|
104
|
+
return [inspectSkillFile(filePath, basename(entry.name, '.md'), scope, root)];
|
|
61
105
|
});
|
|
62
106
|
}
|
|
63
107
|
|
|
64
|
-
export function
|
|
65
|
-
const
|
|
108
|
+
export function inspectSkills(session = {}) {
|
|
109
|
+
const inspected = [];
|
|
66
110
|
if (session.workspacePath) {
|
|
67
|
-
|
|
111
|
+
const root = join(session.workspacePath, DEFAULT_UI_SKILL_DIR);
|
|
112
|
+
inspected.push(...inspectDirectorySkills(root, 'workspace', root));
|
|
68
113
|
}
|
|
114
|
+
const rejected = inspected.flatMap((item) => item.rejected ? [item.rejected] : []);
|
|
115
|
+
const warnings = inspected.flatMap((item) => item.warnings ?? []);
|
|
116
|
+
const candidates = inspected.flatMap((item) => item.skill ? [item.skill] : []);
|
|
117
|
+
const groups = Map.groupBy(candidates, (skill) => skill.name.toLowerCase());
|
|
118
|
+
const duplicatePaths = new Set();
|
|
119
|
+
for (const group of groups.values()) {
|
|
120
|
+
if (group.length < 2) continue;
|
|
121
|
+
for (const skill of group) {
|
|
122
|
+
const relativePath = relative(join(session.workspacePath, DEFAULT_UI_SKILL_DIR), skill.path);
|
|
123
|
+
duplicatePaths.add(skill.path);
|
|
124
|
+
rejected.push({ relativePath, name: skill.name, reason: 'duplicate_name' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const skills = candidates.filter((skill) => !duplicatePaths.has(skill.path))
|
|
128
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
129
|
+
return { skills, rejected, warnings };
|
|
130
|
+
}
|
|
69
131
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
132
|
+
export function listSkills(session = {}) {
|
|
133
|
+
return inspectSkills(session).skills;
|
|
73
134
|
}
|
|
74
135
|
|
|
75
136
|
export function findSkill(session, name) {
|
|
76
|
-
|
|
137
|
+
const normalized = String(name ?? '').toLowerCase();
|
|
138
|
+
return listSkills(session).find((skill) => skill.name.toLowerCase() === normalized) ?? null;
|
|
77
139
|
}
|
|
78
140
|
|
|
79
141
|
export function formatSkillsForAgent(session) {
|
|
80
142
|
const skills = listSkills(session);
|
|
81
143
|
if (!skills.length) return 'No skills discovered.';
|
|
82
144
|
return skills
|
|
83
|
-
.map((skill) => `/${skill.name}: ${skill.description || 'workflow skill'} (${skill.scope})`)
|
|
145
|
+
.map((skill) => `/${skill.name}${skill.params.length ? ` [${skill.params.map((param) => `<${param}>`).join(' ')}]` : ''}: ${escapePromptData(skill.description || 'workflow skill')}${skill.description ? '' : ' [explicit name only]'} (${skill.scope})`)
|
|
84
146
|
.join('\n');
|
|
85
147
|
}
|
package/src/runtime/approvals.js
CHANGED
|
@@ -134,11 +134,23 @@ export function createApprovalManager(session, {
|
|
|
134
134
|
function approve(request = {}) {
|
|
135
135
|
const { approvalId = null, runId = null, itemId = null } = request;
|
|
136
136
|
if (request.scope || request.planRevision != null || request.approvalClasses || request.approvalClass || request.taskId || request.groupId) {
|
|
137
|
+
const scope = normalizeGrantScope(request.scope);
|
|
138
|
+
// A run-scope grant must be bounded to a run. Without a runId it would
|
|
139
|
+
// read as a permanent, workspace-wide grant in `grantCoversTask` (its
|
|
140
|
+
// runId/revision/class checks all skip on null) — one bare `{scope:'run'}`
|
|
141
|
+
// could then unlock every mutating task of every run, forever. Fall back
|
|
142
|
+
// to the active run identity and refuse rather than mint an unbounded
|
|
143
|
+
// grant when there is none.
|
|
144
|
+
const effectiveRunId = request.runId ?? session._currentRunIdentity?.runId ?? null;
|
|
145
|
+
if (scope === 'run' && !effectiveRunId) {
|
|
146
|
+
return { approved: false, reason: 'run_scope_requires_runId' };
|
|
147
|
+
}
|
|
137
148
|
const grant = normalizeApprovalGrant({
|
|
138
149
|
...request,
|
|
139
150
|
id: request.id ?? request.approvalId ?? randomUUID(),
|
|
140
151
|
workspaceId: request.workspaceId ?? request.workspace ?? session.workspace ?? null,
|
|
141
|
-
scope
|
|
152
|
+
scope,
|
|
153
|
+
runId: effectiveRunId,
|
|
142
154
|
itemId: request.itemId ?? request.taskId ?? null,
|
|
143
155
|
status: 'approved',
|
|
144
156
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createApprovalManager } from './approvals.js';
|
|
5
|
+
|
|
6
|
+
test('approve() refuses an unbounded run-scope grant', () => {
|
|
7
|
+
const manager = createApprovalManager({ workspace: 'docs' });
|
|
8
|
+
const result = manager.approve({ scope: 'run' });
|
|
9
|
+
|
|
10
|
+
assert.equal(result.approved, false);
|
|
11
|
+
assert.equal(result.reason, 'run_scope_requires_runId');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('approve() bounds a bare run-scope grant to the active run identity', () => {
|
|
15
|
+
const manager = createApprovalManager({
|
|
16
|
+
workspace: 'docs',
|
|
17
|
+
_currentRunIdentity: { runId: 'run-1', workspace: 'docs' },
|
|
18
|
+
});
|
|
19
|
+
const result = manager.approve({ scope: 'run' });
|
|
20
|
+
|
|
21
|
+
assert.equal(result.approved, true);
|
|
22
|
+
assert.equal(result.runId, 'run-1');
|
|
23
|
+
assert.equal(result.scope, 'run');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('approve() keeps an explicit runId even without a live run identity', () => {
|
|
27
|
+
const manager = createApprovalManager({ workspace: 'docs' });
|
|
28
|
+
const result = manager.approve({ scope: 'run', runId: 'run-2' });
|
|
29
|
+
|
|
30
|
+
assert.equal(result.approved, true);
|
|
31
|
+
assert.equal(result.runId, 'run-2');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('approve() leaves task/tool/group grants unblocked by the run guard', () => {
|
|
35
|
+
const manager = createApprovalManager({ workspace: 'docs' });
|
|
36
|
+
|
|
37
|
+
const task = manager.approve({ scope: 'task', taskId: 'run-1:build' });
|
|
38
|
+
assert.equal(task.approved, true);
|
|
39
|
+
assert.equal(task.scope, 'task');
|
|
40
|
+
|
|
41
|
+
const group = manager.approve({ scope: 'group', groupId: 'publish' });
|
|
42
|
+
assert.equal(group.approved, true);
|
|
43
|
+
assert.equal(group.scope, 'group');
|
|
44
|
+
});
|
package/src/runtime/client.js
CHANGED
|
@@ -55,6 +55,7 @@ export async function postRuntimeRun(input, {
|
|
|
55
55
|
evaluate = undefined,
|
|
56
56
|
replans = undefined,
|
|
57
57
|
capabilityPlan = undefined,
|
|
58
|
+
skillName = undefined,
|
|
58
59
|
} = {}) {
|
|
59
60
|
const response = await fetch(runtimeEndpoint(url, '/run', workspace), {
|
|
60
61
|
method: 'POST',
|
|
@@ -62,14 +63,53 @@ export async function postRuntimeRun(input, {
|
|
|
62
63
|
...runtimeHeaders(token),
|
|
63
64
|
'Content-Type': 'application/json',
|
|
64
65
|
},
|
|
65
|
-
body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans }, capabilityPlan !== undefined && { capabilityPlan })),
|
|
66
|
+
body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans }, capabilityPlan !== undefined && { capabilityPlan }, skillName !== undefined && { skillName })),
|
|
66
67
|
});
|
|
68
|
+
const payload = await response.json().catch(() => ({}));
|
|
67
69
|
if (!response.ok) {
|
|
68
|
-
const
|
|
70
|
+
const detail = payload.error ?? payload.message ?? payload.detail;
|
|
71
|
+
const err = new Error(detail
|
|
72
|
+
? `Runtime run failed: HTTP ${response.status} — ${detail}`
|
|
73
|
+
: `Runtime run failed: HTTP ${response.status}`);
|
|
69
74
|
err.status = response.status;
|
|
75
|
+
err.code = payload.code;
|
|
70
76
|
throw err;
|
|
71
77
|
}
|
|
72
|
-
return
|
|
78
|
+
return payload;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function postRuntimeSkill(skillName, args = {}, {
|
|
82
|
+
url = runtimeUrlFromEnv(),
|
|
83
|
+
token = runtimeToken(),
|
|
84
|
+
workspace = null,
|
|
85
|
+
idempotencyKey = undefined,
|
|
86
|
+
turnId = undefined,
|
|
87
|
+
selectionKind = undefined,
|
|
88
|
+
/** Compétences déjà en cours au-dessus de l'appelant, pour la détection de cycle. */
|
|
89
|
+
skillStack = undefined,
|
|
90
|
+
} = {}) {
|
|
91
|
+
const response = await fetch(runtimeEndpoint(url, '/run', workspace), {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { ...runtimeHeaders(token), 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
input: `/${skillName}`,
|
|
96
|
+
workspace,
|
|
97
|
+
skillName,
|
|
98
|
+
skillArguments: args,
|
|
99
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
100
|
+
...(turnId ? { turnId } : {}),
|
|
101
|
+
...(selectionKind ? { selectionKind } : {}),
|
|
102
|
+
...(Array.isArray(skillStack) && skillStack.length ? { skillStack } : {}),
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
const payload = await response.json().catch(() => ({}));
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
const error = new Error(payload.error ?? `Runtime skill failed: HTTP ${response.status}`);
|
|
108
|
+
error.status = response.status;
|
|
109
|
+
error.code = payload.code;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return payload;
|
|
73
113
|
}
|
|
74
114
|
|
|
75
115
|
export async function postRuntimeTurn(input, {
|
|
@@ -120,6 +160,7 @@ export async function postRuntimeControl(action, {
|
|
|
120
160
|
workspace = null,
|
|
121
161
|
input = undefined,
|
|
122
162
|
intent = undefined,
|
|
163
|
+
id = undefined,
|
|
123
164
|
} = {}) {
|
|
124
165
|
const response = await fetch(runtimeEndpoint(url, '/control', workspace), {
|
|
125
166
|
method: 'POST',
|
|
@@ -127,7 +168,7 @@ export async function postRuntimeControl(action, {
|
|
|
127
168
|
...runtimeHeaders(token),
|
|
128
169
|
'Content-Type': 'application/json',
|
|
129
170
|
},
|
|
130
|
-
body: JSON.stringify(Object.assign({ action }, input !== undefined && { input }, intent !== undefined && { intent })),
|
|
171
|
+
body: JSON.stringify(Object.assign({ action }, input !== undefined && { input }, intent !== undefined && { intent }, id !== undefined && { id })),
|
|
131
172
|
});
|
|
132
173
|
if (!response.ok) {
|
|
133
174
|
const err = new Error(`Runtime control failed: HTTP ${response.status}`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function cancelControlChain(session, { runId = null, itemId = null, reason = 'chain_cancelled', cancelItem } = {}) {
|
|
2
|
+
const queue = Array.isArray(session?.controlQueue) ? session.controlQueue : [];
|
|
3
|
+
const active = queue.find((item) => (itemId && item.id === itemId) || (runId && item.runId === runId));
|
|
4
|
+
if (!active) return { active: null, skipped: 0 };
|
|
5
|
+
// A standalone control item carries no chainId at all. Without this guard the
|
|
6
|
+
// sibling test below compares undefined to undefined, matches every queued
|
|
7
|
+
// standalone request, and turns `/run cancel` into a full queue wipe.
|
|
8
|
+
if (!active.chainId) return { active, skipped: 0 };
|
|
9
|
+
if (active.optional === true || active.continueOnFailure === true) return { active, skipped: 0 };
|
|
10
|
+
let skipped = 0;
|
|
11
|
+
for (const item of queue) {
|
|
12
|
+
if (item.id === active.id || item.chainId !== active.chainId || item.status !== 'queued') continue;
|
|
13
|
+
cancelItem?.(item, reason);
|
|
14
|
+
skipped += 1;
|
|
15
|
+
}
|
|
16
|
+
return { active, skipped };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function cancelQueuedControlItem(session, itemId, { cancelItem, skipItem } = {}) {
|
|
20
|
+
const queue = Array.isArray(session?.controlQueue) ? session.controlQueue : [];
|
|
21
|
+
const item = queue.find((entry) => String(entry.id) === String(itemId));
|
|
22
|
+
if (!item || item.status !== 'queued') return { cancelled: false, reason: item ? 'not_queued' : 'not_found' };
|
|
23
|
+
cancelItem?.(item, 'item_cancelled');
|
|
24
|
+
let skipped = 0;
|
|
25
|
+
if (item.chainId && item.optional !== true && item.continueOnFailure !== true) {
|
|
26
|
+
for (const sibling of queue) {
|
|
27
|
+
if (sibling.chainId !== item.chainId || sibling.status !== 'queued' || Number(sibling.chainSequence) <= Number(item.chainSequence)) continue;
|
|
28
|
+
skipItem?.(sibling, 'required_predecessor_cancelled');
|
|
29
|
+
skipped += 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { cancelled: true, item, skipped };
|
|
33
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
|
|
4
|
+
|
|
5
|
+
test('run cancellation skips queued siblings of the same chain only', () => {
|
|
6
|
+
const session = { controlQueue: [
|
|
7
|
+
{ id: 'a1', runId: 'run-a', chainId: 'a', status: 'running' },
|
|
8
|
+
{ id: 'a2', chainId: 'a', status: 'queued' },
|
|
9
|
+
{ id: 'b1', chainId: 'b', status: 'queued' },
|
|
10
|
+
] };
|
|
11
|
+
const result = cancelControlChain(session, { runId: 'run-a', cancelItem: (item) => { item.status = 'skipped'; } });
|
|
12
|
+
assert.equal(result.skipped, 1);
|
|
13
|
+
assert.equal(session.controlQueue[2].status, 'queued');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('targeted cancellation propagates only after a required item', () => {
|
|
17
|
+
const session = { controlQueue: [
|
|
18
|
+
{ id: 'a1', chainId: 'a', chainSequence: 0, status: 'queued' },
|
|
19
|
+
{ id: 'a2', chainId: 'a', chainSequence: 1, status: 'queued' },
|
|
20
|
+
] };
|
|
21
|
+
const result = cancelQueuedControlItem(session, 'a1', {
|
|
22
|
+
cancelItem: (item) => { item.status = 'cancelled'; },
|
|
23
|
+
skipItem: (item) => { item.status = 'skipped'; },
|
|
24
|
+
});
|
|
25
|
+
assert.deepEqual({ cancelled: result.cancelled, skipped: result.skipped }, { cancelled: true, skipped: 1 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('run cancellation of a standalone item never touches the rest of the queue', () => {
|
|
29
|
+
const session = { controlQueue: [
|
|
30
|
+
{ id: 'a', runId: 'run-1', status: 'running', optional: false, continueOnFailure: false },
|
|
31
|
+
{ id: 'b', status: 'queued', optional: false, continueOnFailure: false },
|
|
32
|
+
{ id: 'c', status: 'queued', optional: false, continueOnFailure: false },
|
|
33
|
+
] };
|
|
34
|
+
const cancelled = [];
|
|
35
|
+
const result = cancelControlChain(session, { runId: 'run-1', cancelItem: (item) => cancelled.push(item.id) });
|
|
36
|
+
assert.deepEqual(cancelled, []);
|
|
37
|
+
assert.equal(result.skipped, 0);
|
|
38
|
+
assert.deepEqual(session.controlQueue.map((item) => item.status), ['running', 'queued', 'queued']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('run cancellation on an optional active item preserves later chain steps', () => {
|
|
42
|
+
const session = { controlQueue: [
|
|
43
|
+
{ id: 'optional', runId: 'run-optional', chainId: 'a', status: 'running', optional: true },
|
|
44
|
+
{ id: 'next', chainId: 'a', status: 'queued' },
|
|
45
|
+
] };
|
|
46
|
+
const result = cancelControlChain(session, { runId: 'run-optional', cancelItem: (item) => { item.status = 'skipped'; } });
|
|
47
|
+
assert.equal(result.skipped, 0);
|
|
48
|
+
assert.equal(session.controlQueue[1].status, 'queued');
|
|
49
|
+
});
|