@dotdrelle/wiki-manager 0.15.42 → 0.15.48

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.
Files changed (46) hide show
  1. package/README.md +139 -27
  2. package/mcp.endpoints.example.json +1 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +290 -29
  5. package/src/agent/graph.test.js +551 -1
  6. package/src/agent/skillRecursion.test.js +98 -0
  7. package/src/cli/wiki-manager.js +209 -7
  8. package/src/cli/wiki-manager.test.js +89 -0
  9. package/src/commands/slash.js +28 -10
  10. package/src/contracts/schemas.js +1 -1
  11. package/src/core/agentEvents.js +50 -0
  12. package/src/core/agentEvents.test.js +52 -0
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/env.js +20 -1
  15. package/src/core/env.test.js +34 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/profile.js +19 -0
  18. package/src/core/runtimeLog.js +15 -0
  19. package/src/core/runtimeLog.test.js +15 -1
  20. package/src/core/skillChainView.js +84 -0
  21. package/src/core/skillChainView.test.js +50 -0
  22. package/src/core/skillCompiler.js +135 -0
  23. package/src/core/skillCompiler.test.js +91 -0
  24. package/src/core/skillInvocation.js +79 -0
  25. package/src/core/skillInvocation.test.js +73 -0
  26. package/src/core/skills.js +81 -19
  27. package/src/core/wikiWorkspace.test.js +34 -0
  28. package/src/core/workspaceProfile.test.js +55 -0
  29. package/src/runtime/client.js +45 -4
  30. package/src/runtime/controlCancellation.js +33 -0
  31. package/src/runtime/controlCancellation.test.js +49 -0
  32. package/src/runtime/controlDrain.js +50 -0
  33. package/src/runtime/controlDrain.test.js +38 -0
  34. package/src/runtime/server.js +341 -20
  35. package/src/runtime/server.test.js +344 -2
  36. package/src/runtime/skillChain.e2e.test.js +394 -0
  37. package/src/runtime/skillRun.js +104 -0
  38. package/src/runtime/skillRun.test.js +84 -0
  39. package/src/runtime/store.js +69 -0
  40. package/src/runtime/store.test.js +11 -0
  41. package/src/runtime/workspaceIsolation.test.js +178 -0
  42. package/src/shell/RightPane.tsx +3 -2
  43. package/src/shell/repl.js +51 -6
  44. package/src/shell/repl.test.js +41 -0
  45. package/src/shell/useSession.ts +43 -9
  46. package/wiki-workspace +137 -1
@@ -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
+ });
@@ -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_-]{1,80}$/;
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 parseFrontmatter(raw) {
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('&', '&amp;')
31
+ .replaceAll('<', '&lt;')
32
+ .replaceAll('>', '&gt;');
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 readSkillFile(filePath, fallbackName, scope) {
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
- if (!SKILL_NAME_RE.test(name)) return null;
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: String(meta.description || '').trim(),
41
- params: Array.isArray(meta.params) ? meta.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 collectDirectorySkills(dir, scope) {
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
- const skill = readSkillFile(filePath, basename(entry.name, '.md'), scope);
60
- return skill ? [skill] : [];
104
+ return [inspectSkillFile(filePath, basename(entry.name, '.md'), scope, root)];
61
105
  });
62
106
  }
63
107
 
64
- export function listSkills(session = {}) {
65
- const skills = [];
108
+ export function inspectSkills(session = {}) {
109
+ const inspected = [];
66
110
  if (session.workspacePath) {
67
- skills.push(...collectDirectorySkills(join(session.workspacePath, DEFAULT_UI_SKILL_DIR), 'workspace'));
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
- const byName = new Map();
71
- for (const skill of skills) byName.set(skill.name, skill);
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
- return listSkills(session).find((skill) => skill.name === name) ?? null;
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
  }
@@ -135,3 +135,37 @@ test('wiki-workspace reaches its usage text with no arguments', async () => {
135
135
  assert.match(result.stdout, /^Usage:/);
136
136
  assert.equal(result.status, 2);
137
137
  });
138
+
139
+ // `reset` deletes everything workspace_reset_keep rejects, and its very next
140
+ // statement is `run_wiki … init`, which opens with need_workspace_env. If
141
+ // `.env` ever falls out of the keep list, reset erases the workspace and then
142
+ // dies before re-scaffolding it — and recovering with `config` mints new ports
143
+ // and MCP tokens, invalidating every reference to the old ones. Exercise the
144
+ // real function rather than asserting on the source text.
145
+ test('workspace_reset_keep protects the workspace registration and method', async () => {
146
+ const { spawnSync } = await import('node:child_process');
147
+ const scriptPath = new URL('../../wiki-workspace', import.meta.url).pathname;
148
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
149
+ const start = script.indexOf('workspace_reset_keep() {');
150
+ assert.notEqual(start, -1, 'workspace_reset_keep must exist');
151
+ const body = script.slice(start, script.indexOf('\n}', start) + 2);
152
+
153
+ const ask = (name) =>
154
+ spawnSync('bash', ['-c', `${body}\nworkspace_reset_keep "$1"`, '_', name]).status === 0;
155
+
156
+ for (const kept of ['.env', '.env.local', '.wikirc.yaml', '.wikirc.yaml.prod', 'templates', 'build-context', '.git']) {
157
+ assert.equal(ask(kept), true, `${kept} must survive a reset`);
158
+ }
159
+ for (const removed of ['wiki', 'deliverables', 'raw', '.wiki', 'CLAUDE.md', '.gitignore', '.environment']) {
160
+ assert.equal(ask(removed), false, `${removed} must be reset`);
161
+ }
162
+ });
163
+
164
+ test('reset re-scaffolds through run_wiki, which requires the workspace env', async () => {
165
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
166
+
167
+ // The coupling the keep list depends on: if either half moves, revisit it.
168
+ assert.match(script, /run_wiki\(\) \{\n local workspace="\$1"\n shift\n need_workspace_env "\$workspace"/);
169
+ assert.match(script, /run_wiki "\$workspace" init/);
170
+ assert.match(script, /workspace_env_file\(\) \{\n local workspace="\$1"\n printf '%s\/%s\/\.env\\n'/);
171
+ });
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { loadWorkspaceProfile } from './profile.js';
7
+ import { buildDirectChatSystemPrompt } from '../shell/repl.js';
8
+
9
+ function workspaceWithProfile(content) {
10
+ const dir = mkdtempSync(join(tmpdir(), 'workspace-profile-'));
11
+ if (content !== null) {
12
+ mkdirSync(join(dir, '.wiki'), { recursive: true });
13
+ writeFileSync(join(dir, '.wiki', 'profile.md'), content, 'utf8');
14
+ }
15
+ return dir;
16
+ }
17
+
18
+ // Une absence de profil doit dégrader la réponse, jamais la casser : le loader
19
+ // est appelé à chaque tour, y compris avant tout /use.
20
+ test('a missing workspace, file or empty profile yields null instead of throwing', () => {
21
+ assert.equal(loadWorkspaceProfile(null), null);
22
+ assert.equal(loadWorkspaceProfile(undefined), null);
23
+ assert.equal(loadWorkspaceProfile(workspaceWithProfile(null)), null);
24
+ assert.equal(loadWorkspaceProfile(workspaceWithProfile(' \n\n')), null);
25
+ assert.equal(loadWorkspaceProfile(join(tmpdir(), 'does-not-exist-ever')), null);
26
+ });
27
+
28
+ test('an existing profile is returned trimmed and capped', () => {
29
+ const dir = workspaceWithProfile('\n# Workspace Profile\n\n## Notifications\n\n- Email: ops@example.com\n\n');
30
+ const profile = loadWorkspaceProfile(dir);
31
+ assert.match(profile, /^# Workspace Profile/);
32
+ assert.match(profile, /ops@example\.com$/);
33
+
34
+ const huge = workspaceWithProfile('x'.repeat(10_000));
35
+ assert.equal(loadWorkspaceProfile(huge).length, 4000);
36
+ });
37
+
38
+ // Le profil n'était injecté qu'en mode agent : le même workspace répondait avec
39
+ // un ton différent selon le mode, et une skill lancée depuis /chat ne pouvait
40
+ // pas savoir à qui elle parlait (destinataire de notification, tutoiement…).
41
+ // L'injection est volontairement préférée à une entrée profile_read dans
42
+ // chatAccess, que le merge additif du scaffold n'aurait jamais propagée aux
43
+ // installs existantes.
44
+ test('chat mode injects the workspace profile into its system prompt', () => {
45
+ const workspacePath = workspaceWithProfile('# Workspace Profile\n\n## Notifications\n\n- Email: ops@example.com\n');
46
+ const prompt = buildDirectChatSystemPrompt({ workspace: 'demo', workspacePath, language: 'fr-FR' }, []);
47
+ assert.match(prompt, /Workspace profile \(\.wiki\/profile\.md\)/);
48
+ assert.match(prompt, /ops@example\.com/);
49
+ });
50
+
51
+ test('chat mode omits the profile block entirely when there is no profile', () => {
52
+ const prompt = buildDirectChatSystemPrompt({ workspace: 'demo', workspacePath: null, language: 'fr-FR' }, []);
53
+ assert.doesNotMatch(prompt, /Workspace profile/);
54
+ assert.match(prompt, /Reply language: fr-FR\./);
55
+ });
@@ -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 err = new Error(`Runtime run failed: HTTP ${response.status}`);
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 response.json();
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
+ });