@dotdrelle/wiki-manager 0.15.62 → 0.15.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -401,6 +401,7 @@ answer "what is this and how do I start it", and stop there.
401
401
  | [`docs/usage.md`](docs/usage.md) | The four ways to run wikiLLM, and how to configure the external agents |
402
402
  | [`docs/configuration.md`](docs/configuration.md) | Every configuration key: root `.env`, Compose overrides, `mcp.endpoints.json`, workspace `.env`, `.wikirc.yaml`, parallelism |
403
403
  | [`docs/technical-reference.md`](docs/technical-reference.md) | Workspace model, services, the `donna` shell, agent tooling, orchestration and activity contracts, security model |
404
+ | [`docs/authoring-skills.md`](docs/authoring-skills.md) | Writing a workspace skill: what splits a body into runs, chains, concurrency, parameters, and the interpretation rules |
404
405
  | [`docs/claude-desktop.md`](docs/claude-desktop.md) | Using a workspace from Claude Desktop |
405
406
  | [`CLAUDE.md`](CLAUDE.md) | Repository guidance: invariants to preserve when changing this code |
406
407
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.62",
3
+ "version": "0.15.64",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@ import {
19
19
  truncateToolResult,
20
20
  } from '../core/mcp.js';
21
21
  import { findSkill, formatSkillsForAgent } from '../core/skills.js';
22
- import { RESERVED_SLASH_COMMANDS, explicitSkillReference } from '../core/skillInvocation.js';
22
+ import { RESERVED_SLASH_COMMANDS, explicitSkillReference, objectiveNamesSkill } from '../core/skillInvocation.js';
23
23
  import { handleSlashCommand } from '../commands/slash.js';
24
24
  import { extractActivity, formatActivitySummary, parseJsonText, sessionActivities } from '../core/activity.js';
25
25
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
@@ -947,6 +947,32 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
947
947
  message: `Skill "${skillName}" is already running in this chain: execute its objective directly instead of re-invoking it.`,
948
948
  });
949
949
  }
950
+ /*
951
+ Depuis une intention compilée, une compétence se lance par son NOM, pas
952
+ par ressemblance.
953
+
954
+ La garde de cycle ci-dessus ne voit que les répétitions. Elle laissait
955
+ donc passer la cascade réellement observée sur un `/wiki-ingest` :
956
+ l'intention n°2 du corps est mot pour mot celui de
957
+ `/wiki-rebuild-concepts`, dont l'intention décrit à son tour
958
+ `/wiki-reclassify`, puis `/wiki-taxonomy`. Trois compétences distinctes,
959
+ aucun cycle, et la grille de concepts comme la taxonomie reconstruites
960
+ plusieurs fois pour une seule demande.
961
+
962
+ Une intention compilée EST déjà le travail à faire : elle se délègue.
963
+ La composition explicite reste ouverte — un corps qui nomme sa cible dit
964
+ ce qu'il veut ; une intention qui se contente de la décrire ne le dit
965
+ pas.
966
+ */
967
+ if (skillStack.length > 0 && !objectiveNamesSkill(args._userInput, skillName)) {
968
+ return JSON.stringify({
969
+ ok: false,
970
+ terminal: true,
971
+ code: 'nested_skill_match_blocked',
972
+ skillStack,
973
+ message: `The current objective does not name skill "${skillName}"; it only describes what that skill does. Execute the objective yourself with runtime__delegate instead of re-routing it to another skill.`,
974
+ });
975
+ }
950
976
  if (skillStack.length >= MAX_SKILL_DEPTH) {
951
977
  return JSON.stringify({
952
978
  ok: false,
@@ -1184,7 +1210,7 @@ export function buildAgentSystemPrompt(state) {
1184
1210
  skills,
1185
1211
  '</skill_catalog>',
1186
1212
  runningSkillStack.length > 0
1187
- ? `You are already executing the compiled objective of workspace skill ${JSON.stringify(runningSkillStack.at(-1))}. The current user message IS that objective: execute it directly${runningSkillExecution === 'direct' ? ' and stop after its requested direct mutation; delegation and nested skills are forbidden for this workflow' : ' by delegating it with runtime__delegate (or a matching direct tool)'}. Do not select or call that skill again, with or without a leading slash — the runtime refuses the re-invocation with skill_recursion_blocked, and that refusal means act on the objective yourself, not report an error. A skill run is not successful until its requested mutation has an affirmative tool result; never infer success from the runtime merely becoming idle or done, and never end the run with an empty reply or a bare "{}".`
1213
+ ? `You are already executing the compiled objective of workspace skill ${JSON.stringify(runningSkillStack.at(-1))}. The current user message IS that objective: execute it directly${runningSkillExecution === 'direct' ? ' and stop after its requested direct mutation; delegation and nested skills are forbidden for this workflow' : ' by delegating it with runtime__delegate (or a matching direct tool)'}. Do not select or call that skill again, with or without a leading slash — the runtime refuses the re-invocation with skill_recursion_blocked, and that refusal means act on the objective yourself, not report an error. Do not select ANY OTHER skill by description either: an objective necessarily reads like the description of the neighbouring skill that performs it, and re-routing it there re-runs work this objective already covers. From inside a compiled objective, runtime__run_skill is only for a skill the objective names explicitly; everything else is delegated with runtime__delegate. A skill run is not successful until its requested mutation has an affirmative tool result; never infer success from the runtime merely becoming idle or done, and never end the run with an empty reply or a bare "{}".`
1188
1214
  : null,
1189
1215
  'In interactive agent mode, call only tools actually provided to you. Any directly offered tool stays direct; never substitute an orchestration-contract tool yourself.',
1190
1216
  'When the user asks for an action that can be performed with connected MCP tools or safe primitives, do not answer with future intent such as "I will call...", "I am going to run...", or "launching..." unless you also call the tool in the same turn. Either call the tool now, ask for the exact missing required arguments, or explain the concrete blocker.',
@@ -96,3 +96,80 @@ test('borne la profondeur même sans cycle', async () => {
96
96
 
97
97
  assert.equal(result.code, 'skill_depth_exceeded');
98
98
  });
99
+
100
+ /*
101
+ Cascade observée sur un `/wiki-ingest` : deux `/wiki-rebuild-concepts`, puis
102
+ `/wiki-reclassify`, puis `/wiki-taxonomy`. Aucun cycle — trois compétences
103
+ distinctes — mais la grille de concepts et la taxonomie reconstruites
104
+ plusieurs fois pour une seule demande. La deuxième intention compilée de
105
+ `/wiki-ingest` est mot pour mot le corps de `/wiki-rebuild-concepts` : le
106
+ sélecteur par description la reconnaissait légitimement.
107
+ */
108
+ const callWithObjective = (state, skillName, objective) =>
109
+ handleRuntimeControlTool(state, 'run_skill', { skillName, _userInput: objective })
110
+ .then((raw) => JSON.parse(raw));
111
+
112
+ test('refuse une compétence voisine que l’intention décrit sans la nommer', async () => {
113
+ const objective = 'Run the production pipeline steps concepts, reclassify-concepts and taxonomy, in that order.';
114
+ const ran = [];
115
+ const result = await callWithObjective(session(['wiki-ingest'], ran), 'wiki-rebuild-concepts', objective);
116
+
117
+ assert.equal(result.ok, false);
118
+ assert.equal(result.code, 'nested_skill_match_blocked');
119
+ assert.deepEqual(ran, [], 'un refus ne doit lancer aucun travail');
120
+ assert.match(result.message, /runtime__delegate/);
121
+ });
122
+
123
+ test('laisse passer la compétence que l’intention nomme explicitement', async () => {
124
+ const ran = [];
125
+ const result = await callWithObjective(session(['wiki-sync'], ran), 'deliver', 'Then run /deliver on the produced report.');
126
+
127
+ assert.equal(result.ok, true);
128
+ assert.deepEqual(ran, ['deliver']);
129
+ });
130
+
131
+ test('ne confond pas un nom de compétence avec son préfixe', async () => {
132
+ const result = await callWithObjective(session(['pipeline']), 'wiki-build', 'Hand the result to the wiki-builder service.');
133
+
134
+ assert.equal(result.code, 'nested_skill_match_blocked');
135
+ });
136
+
137
+ test('un chemin de fichier commençant par un nom de compétence ne vaut pas invocation', async () => {
138
+ const result = await callWithObjective(
139
+ session(['wiki-ingest']),
140
+ 'wiki-build',
141
+ 'Move the leaf file /wiki-build/unclassified/x.md into place then continue.',
142
+ );
143
+
144
+ assert.equal(result.code, 'nested_skill_match_blocked');
145
+ });
146
+
147
+ test('hors de toute chaîne, la sélection par description reste permise', async () => {
148
+ const result = await callWithObjective(session(undefined), 'wiki-taxonomy', 'republish the graph taxonomy');
149
+
150
+ assert.equal(result.ok, true);
151
+ });
152
+
153
+ /*
154
+ Le nom seul ne prouve rien. Plusieurs compétences du scaffold portent un nom
155
+ qui est aussi un mot courant : « Run the production pipeline steps concepts,
156
+ reclassify-concepts and taxonomy » nomme `pipeline`, dont le lancement rejoue
157
+ ingest + build + export + polish. Une intention doit citer sa cible EN TANT QUE
158
+ compétence, pas l'employer comme mot.
159
+ */
160
+ test('un nom employé comme mot courant ne vaut pas invocation', async () => {
161
+ const objective = 'Run the production pipeline steps concepts, reclassify-concepts and taxonomy, in that order.';
162
+ const ran = [];
163
+ const result = await callWithObjective(session(['wiki-ingest'], ran), 'pipeline', objective);
164
+
165
+ assert.equal(result.code, 'nested_skill_match_blocked');
166
+ assert.deepEqual(ran, []);
167
+ });
168
+
169
+ test('la tournure « the deliver skill » vaut invocation explicite', async () => {
170
+ const ran = [];
171
+ const result = await callWithObjective(session(['wiki-sync'], ran), 'deliver', 'Hand the report to the deliver skill.');
172
+
173
+ assert.equal(result.ok, true);
174
+ assert.deepEqual(ran, ['deliver']);
175
+ });
@@ -24,7 +24,7 @@ import { applySessionWikircProfile } from '../core/sessionConfig.js';
24
24
  import { listWikircProfiles } from '../core/wikirc.js';
25
25
  import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
26
26
  import { deleteManagedMcpEndpoint, listManagedMcpEndpoints, upsertManagedMcpEndpoint } from '../core/mcpEndpoints.js';
27
- import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
27
+ import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
28
28
  import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
29
29
  import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
30
30
  import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
@@ -432,7 +432,7 @@ async function runHeadlessActivityLoop(session, log, { wait, timeoutMs }) {
432
432
  try {
433
433
  const result = await callMcpTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {});
434
434
  const payload = parseJsonText(formatMcpToolResult(result));
435
- const polledActivity = extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool });
435
+ const polledActivity = mergePolledActivity(activity, extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool }));
436
436
  if (polledActivity) {
437
437
  dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
438
438
  origin: 'poll',
@@ -104,7 +104,19 @@ export function normalizeActivity(activity, fallback = {}) {
104
104
  return normalized;
105
105
  }
106
106
 
107
- function productionActivityFromPayload(payload, context = {}) {
107
+ // A status payload can only be polled with the tool that produced it. Any
108
+ // `*_status` tool qualifies; anything else (a `*_start_job` call whose result
109
+ // carried the first snapshot) falls back to the production status tool, and
110
+ // for a non-production agent to null — the caller then keeps the poll
111
+ // descriptor the tracked activity already had.
112
+ function statusPollTool(context, source) {
113
+ const tool = String(context?.tool ?? '');
114
+ if (/status$/i.test(tool)) return tool;
115
+ return source === 'production' ? 'production_job_status' : null;
116
+ }
117
+
118
+ function activityFromStatusPayload(payload, context = {}) {
119
+ const source = String(context?.server ?? 'production');
108
120
  const progress = payload?.progress;
109
121
  const job = payload?.job;
110
122
  const jobId = payload?.jobId ?? job?.jobId;
@@ -141,11 +153,12 @@ function productionActivityFromPayload(payload, context = {}) {
141
153
  progressDetail,
142
154
  progress?.lastEvent ? `last ${progress.lastEvent}` : null,
143
155
  ].filter(Boolean).join(' · ');
156
+ const prefix = source === 'production' ? 'Production' : source;
144
157
  return normalizeActivity({
145
158
  id: jobId,
146
- source: 'production',
159
+ source,
147
160
  kind: job?.type ?? payload?.operation ?? payload?.type ?? progress?.phase ?? progress?.currentStep ?? 'job',
148
- label: detail ? `Production: ${detail}` : `Production: ${status}`,
161
+ label: detail ? `${prefix}: ${detail}` : `${prefix}: ${status}`,
149
162
  status,
150
163
  progress: {
151
164
  ...(progress ?? {}),
@@ -154,9 +167,9 @@ function productionActivityFromPayload(payload, context = {}) {
154
167
  ...(payload?.taskId ? { stepId: String(payload.taskId) } : {}),
155
168
  },
156
169
  plan: Array.isArray(progress?.steps) ? { steps: progress.steps } : null,
157
- poll: jobId ? {
158
- server: 'production',
159
- tool: context.tool === 'agent_status' ? 'agent_status' : 'production_job_status',
170
+ poll: jobId && statusPollTool(context, source) ? {
171
+ server: source,
172
+ tool: statusPollTool(context, source),
160
173
  args: { jobId },
161
174
  intervalMs: 2500,
162
175
  } : null,
@@ -170,10 +183,47 @@ export function extractActivity(payload, context = {}) {
170
183
  if (payload._activity) {
171
184
  return normalizeActivity(payload._activity, { source: context.server });
172
185
  }
173
- if (context.server === 'production') {
174
- return productionActivityFromPayload(payload, context);
175
- }
176
- return null;
186
+ // Without an explicit `_activity` opt-in, a status payload is inferred into an
187
+ // activity only where we already know how it would be polled: the production
188
+ // server (whose `*_start_job` result carries the first snapshot), or any agent
189
+ // answering through a `*_status` tool. This is what lets a job run by another
190
+ // executor (knowledge.update on agent-cme, polled via `agent_status`) update
191
+ // its percentage instead of the panel showing the dispatcher's initial 0 %
192
+ // for the whole run. An arbitrary agent-mode tool call that merely happens to
193
+ // return a `jobId`/`progress`-shaped object must NOT create or replace the run
194
+ // plan — monitoring stays opt-in (`_activity`) for it.
195
+ const source = String(context?.server ?? 'production');
196
+ if (source !== 'production' && !statusPollTool(context, source)) return null;
197
+ return activityFromStatusPayload(payload, context);
198
+ }
199
+
200
+ // A poll answers with the AGENT's view of the job: it knows the job id, the
201
+ // status and the progress, but nothing about the plan task the orchestrator
202
+ // dispatched it for. Re-normalizing that answer on its own therefore dropped
203
+ // `progress.stepId` and could re-key the activity under a different source,
204
+ // breaking the task<->activity link the Activity/Plan panels use to attach a
205
+ // live percentage to a plan step. Merge onto the tracked activity instead:
206
+ // identity and linkage come from what we already know, live values from the
207
+ // poll.
208
+ export function mergePolledActivity(tracked, polled) {
209
+ if (!polled) return null;
210
+ if (!tracked) return polled;
211
+ const stepId = polled.progress?.stepId ?? tracked.progress?.stepId ?? null;
212
+ return normalizeActivity({
213
+ ...polled,
214
+ // Identity: keep the tracked source/id so activityKey() stays stable.
215
+ id: tracked.id ?? polled.id,
216
+ source: tracked.source ?? polled.source,
217
+ // Keep polling with the descriptor that worked when the agent's answer
218
+ // does not carry one of its own.
219
+ poll: polled.poll ?? tracked.poll,
220
+ plan: polled.plan ?? tracked.plan ?? null,
221
+ startedAt: tracked.startedAt ?? polled.startedAt ?? null,
222
+ progress: {
223
+ ...(polled.progress ?? {}),
224
+ ...(stepId !== null ? { stepId } : {}),
225
+ },
226
+ });
177
227
  }
178
228
 
179
229
  export function rememberActivity(session, activity) {
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { normalizeActivity, extractActivity, isCancelledStatus, rememberActivity, rememberActivityFromPayload } from './activity.js';
3
+ import { normalizeActivity, extractActivity, mergePolledActivity, isCancelledStatus, rememberActivity, rememberActivityFromPayload } from './activity.js';
4
4
 
5
5
  test('normalizeActivity: plan.steps preserved with id and label', () => {
6
6
  const a = normalizeActivity({
@@ -171,3 +171,61 @@ test('rememberActivityFromPayload: returns null for irrelevant payload', () => {
171
171
  const session = {};
172
172
  assert.equal(rememberActivityFromPayload(session, { message: 'ok' }), null);
173
173
  });
174
+
175
+ test('extractActivity: a non-production agent status payload yields an activity', () => {
176
+ const activity = extractActivity({
177
+ jobId: 'job-know-1',
178
+ status: 'running',
179
+ progress: { percent: 42, phase: 'knowledge.update', detail: 'chunk 3/7' },
180
+ }, { server: 'cme', tool: 'agent_status' });
181
+ assert.ok(activity, 'a knowledge.update job run outside the production server must still produce an activity');
182
+ assert.equal(activity.source, 'cme');
183
+ assert.equal(activity.progress.percent, 42);
184
+ assert.equal(activity.poll.server, 'cme');
185
+ assert.equal(activity.poll.tool, 'agent_status');
186
+ });
187
+
188
+ test('extractActivity: a non-status tool result on another server is not inferred into an activity', () => {
189
+ // Agent mode: the LLM calls an arbitrary connector tool whose result happens
190
+ // to carry a jobId/progress-shaped object. Without an `_activity` opt-in and
191
+ // without a `*_status` poll tool, this must not create or replace the plan.
192
+ const activity = extractActivity({
193
+ jobId: 'export-42',
194
+ progress: { steps: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] },
195
+ }, { server: 'connectors', tool: 'gmail_export_run' });
196
+ assert.equal(activity, null);
197
+ });
198
+
199
+ test('extractActivity: production start-job result is still inferred without a status tool', () => {
200
+ const activity = extractActivity({
201
+ jobId: 'job-1',
202
+ status: 'running',
203
+ progress: { percent: 5, phase: 'ingest' },
204
+ }, { server: 'production', tool: 'production_start_job' });
205
+ assert.ok(activity);
206
+ assert.equal(activity.source, 'production');
207
+ assert.equal(activity.poll.tool, 'production_job_status');
208
+ });
209
+
210
+ test('mergePolledActivity: keeps the tracked key, poll and stepId across polls', () => {
211
+ const tracked = normalizeActivity({
212
+ id: 'job-know-1',
213
+ source: 'cme',
214
+ kind: 'knowledge.update',
215
+ label: 'Mise a jour des connaissances',
216
+ status: 'queued',
217
+ progress: { percent: 0, stepId: 'task-3' },
218
+ poll: { server: 'cme', tool: 'agent_status', args: { jobId: 'job-know-1' }, intervalMs: 1000 },
219
+ });
220
+ const polled = extractActivity({
221
+ jobId: 'job-know-1',
222
+ status: 'running',
223
+ progress: { percent: 63, detail: 'chunk 5/7' },
224
+ }, { server: 'cme', tool: 'agent_status' });
225
+ const merged = mergePolledActivity(tracked, polled);
226
+ assert.equal(merged.key, tracked.key, 'the activity must not be re-keyed by a poll');
227
+ assert.equal(merged.progress.stepId, 'task-3', 'the plan task link must survive a poll');
228
+ assert.equal(merged.progress.percent, 63);
229
+ assert.equal(merged.poll.tool, 'agent_status');
230
+ assert.equal(merged.terminal, false);
231
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.62",
3
- "commit": "399432f"
2
+ "version": "0.15.64",
3
+ "commit": "d77449c"
4
4
  }
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.62';
4
+ const WIKI_MANAGER_VERSION = '0.15.64';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -16,6 +16,18 @@ const SYMBOLS = {
16
16
  skipped: '–',
17
17
  };
18
18
 
19
+ // The selection reason is an audit enum (`explicit_name` / `description_match`);
20
+ // leaking it verbatim into a queue label read as a broken token (`[explicit_name]`).
21
+ // Humanize it for display; keep the raw value on `selectionKind` for audit.
22
+ const SELECTION_KIND_LABELS = {
23
+ explicit_name: 'explicit name',
24
+ description_match: 'description match',
25
+ };
26
+
27
+ export function selectionKindLabel(selectionKind) {
28
+ return SELECTION_KIND_LABELS[selectionKind] ?? selectionKind ?? null;
29
+ }
30
+
19
31
  export const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
20
32
 
21
33
  // Objectives are whole paragraphs; a chain view needs a line. Keep the first
@@ -61,6 +73,7 @@ export function projectSkillChains(controlQueue = []) {
61
73
  chainId,
62
74
  skillName: chainItems.find((item) => item.skillName)?.skillName ?? null,
63
75
  selectionKind: chainItems.find((item) => item.selectionKind)?.selectionKind ?? null,
76
+ selectionLabel: selectionKindLabel(chainItems.find((item) => item.selectionKind)?.selectionKind ?? null),
64
77
  steps,
65
78
  status: chainStatus(steps),
66
79
  };
@@ -79,7 +92,7 @@ function chainStatus(steps) {
79
92
  // The text form used by the Shell; serve renders the same projection as DOM.
80
93
  export function renderSkillChain(chain) {
81
94
  if (!chain?.steps?.length) return '';
82
- const selection = chain.selectionKind ? ` · ${chain.selectionKind}` : '';
95
+ const selection = chain.selectionLabel ? ` · ${chain.selectionLabel}` : '';
83
96
  const lines = [`${chain.skillName ?? 'skill'}${selection}`, ''];
84
97
  for (const step of chain.steps) {
85
98
  lines.push(`${step.symbol} ${step.label}`);
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { chainStepLabel, projectSkillChains, renderSkillChain } from './skillChainView.js';
3
+ import { chainStepLabel, projectSkillChains, renderSkillChain, selectionKindLabel } from './skillChainView.js';
4
4
 
5
5
  const WIKI_SYNC = [
6
6
  {
@@ -48,3 +48,15 @@ test('standalone control items are not chains', () => {
48
48
  assert.deepEqual(projectSkillChains([{ id: 'x', status: 'queued', input: 'do something' }]), []);
49
49
  assert.deepEqual(projectSkillChains(), []);
50
50
  });
51
+
52
+ test('the selection reason is humanized, not leaked as an audit enum', () => {
53
+ assert.equal(selectionKindLabel('explicit_name'), 'explicit name');
54
+ assert.equal(selectionKindLabel('description_match'), 'description match');
55
+ assert.equal(selectionKindLabel(null), null);
56
+ const [chain] = projectSkillChains([
57
+ { id: 'c0', chainId: 'k', chainSequence: 0, skillName: 'wiki-taxonomy', selectionKind: 'explicit_name', status: 'running', input: '/wiki-taxonomy' },
58
+ ]);
59
+ assert.equal(chain.selectionKind, 'explicit_name');
60
+ assert.equal(chain.selectionLabel, 'explicit name');
61
+ assert.equal(renderSkillChain(chain).split('\n')[0], 'wiki-taxonomy · explicit name');
62
+ });
@@ -19,6 +19,54 @@ export function explicitSkillReference(input, skillName, language = null) {
19
19
  return text.some((sentence) => patterns.some((pattern) => pattern.test(sentence)));
20
20
  }
21
21
 
22
+ /*
23
+ Une intention compilée NOMME-t-elle la compétence qu'on veut lancer depuis
24
+ elle ?
25
+
26
+ Le corps d'une compétence est compilé en intentions métier, et une intention
27
+ décrit forcément ce que fait une compétence voisine : la deuxième intention de
28
+ `/wiki-ingest` est mot pour mot le corps de `/wiki-rebuild-concepts`. Le
29
+ sélecteur par description la reconnaissait donc et relançait la compétence
30
+ voisine, qui relançait la suivante — concepts et taxonomie produits plusieurs
31
+ fois pour un seul `/wiki-ingest`.
32
+
33
+ La composition volontaire reste possible : un corps qui écrit `/deliver` ou
34
+ « the deliver skill » nomme sa cible, et se distingue ainsi d'une intention
35
+ qui se contente de la décrire. C'est le seul signal qui ne dépende pas de ce
36
+ que le modèle déclare de sa propre sélection.
37
+ */
38
+ export function objectiveNamesSkill(input, skillName) {
39
+ const raw = String(skillName ?? '').trim();
40
+ const name = escapeRegExp(raw);
41
+ if (!name) return false;
42
+ const text = String(input ?? '').trim();
43
+ // Une invocation directe : la demande EST le nom, rien d'autre.
44
+ if (text.toLowerCase() === raw.toLowerCase()) return true;
45
+ /*
46
+ Le nom seul ne suffit pas : plusieurs compétences du scaffold portent un nom
47
+ qui est aussi un mot courant. « Run the production pipeline steps concepts,
48
+ reclassify-concepts and taxonomy » nomme ainsi la compétence `pipeline`, qui
49
+ relance ingest + build + export + polish — bien pire que la cascade qu'on
50
+ corrige. Le nom doit donc être cité EN TANT QUE compétence : forme slash, ou
51
+ tournure explicite. La borne droite est écrite à la main, `\b` ne bornant pas
52
+ après un `-` final (`wiki-build` ne doit pas matcher dans `wiki-builder`).
53
+ */
54
+ const end = '(?![A-Za-z0-9_-])';
55
+ // The slash form is a command, not a path: `/wiki-build` followed by `/` is
56
+ // `wiki/concepts/...`-style text referencing a file, not an invocation of the
57
+ // `wiki-build` skill. A trailing `/` must not satisfy the right boundary here,
58
+ // or a compiled objective that merely names a path re-opens the nested-skill
59
+ // cascade this guard exists to close.
60
+ const slashEnd = '(?![A-Za-z0-9_/-])';
61
+ const keyword = '(?:skill|workflow|compétence)';
62
+ return [
63
+ new RegExp(`(?:^|[^A-Za-z0-9_-])/${name}${slashEnd}`, 'i'),
64
+ new RegExp(`\\b${keyword}\\s+/?${name}${end}`, 'i'),
65
+ new RegExp(`(?:^|[^A-Za-z0-9_-])/?${name}${end}\\s+${keyword}\\b`, 'i'),
66
+ new RegExp(`/skills\\s+run\\s+${name}${end}`, 'i'),
67
+ ].some((pattern) => pattern.test(text));
68
+ }
69
+
22
70
  export function matchSkillInvocation(session, input, { allowReserved = false } = {}) {
23
71
  const match = INVOCATION_RE.exec(String(input ?? '').trim());
24
72
  if (!match) return null;
@@ -1,7 +1,7 @@
1
1
  import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
2
2
  import { isAbsolute, join, normalize, resolve } from 'node:path';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
- import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
4
+ import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
5
5
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
6
6
  import { normalizeRuntimeLog } from '../core/runtimeLog.js';
7
7
  import { startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
@@ -107,10 +107,10 @@ export async function pollActivitiesOnce(session, {
107
107
  try {
108
108
  const result = await callTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {}, signal);
109
109
  const payload = parseJsonText(formatMcpToolResult(result));
110
- const polledActivity = extractActivity(payload, {
110
+ const polledActivity = mergePolledActivity(activity, extractActivity(payload, {
111
111
  server: activity.poll.server,
112
112
  tool: activity.poll.tool,
113
- });
113
+ }));
114
114
  if (polledActivity) {
115
115
  dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
116
116
  origin: 'runtime_poll',
@@ -20,10 +20,11 @@ type LogLineParts = { time: string | null; message: string };
20
20
  // 4 slots (was 6): items can now span up to 5 lines each (wrapped label +
21
21
  // wrapped status/error), so fewer, readable entries beat more, truncated ones.
22
22
  const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
23
- // Hauteur du panneau Plan : 6 lignes visibles au maximum. Les etapes suivantes
24
- // restent atteignables en faisant defiler la scrollbox (barre de defilement
25
- // affichee des que le plan depasse la fenetre).
26
- const PLAN_VIEWPORT_ROWS = 6;
23
+ // Hauteur MINIMALE du panneau Plan : 6 lignes visibles. Le panneau occupe
24
+ // desormais tout l'espace libre jusqu'au panneau Runtime/Agent status (il ne
25
+ // reste plus de trou de 6 lignes entre les deux) ; la scrollbox reste
26
+ // defilable des que le plan depasse cette fenetre.
27
+ const PLAN_MIN_VIEWPORT_ROWS = 6;
27
28
 
28
29
  function wrapLine(value: string, width: number) {
29
30
  const max = Math.max(8, width);
@@ -215,12 +216,11 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
215
216
  total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, stepTextWidth(step)).slice(0, 2).length, 0));
216
217
  const title = () => {
217
218
  const label = props.jobName ? `Plan : ${props.jobName}` : 'Plan';
218
- return visualRows() > PLAN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
219
+ return visualRows() > PLAN_MIN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
219
220
  };
220
- const viewportRows = () => Math.min(PLAN_VIEWPORT_ROWS, Math.max(1, visualRows()));
221
221
  const summaryLines = () => props.summary ? wrapLine(props.summary, lineWidth()).slice(0, 2) : [];
222
222
  return (
223
- <box flexShrink={0} flexDirection="column" padding={1}>
223
+ <box flexGrow={1} flexShrink={1} minHeight={PLAN_MIN_VIEWPORT_ROWS + 2} flexDirection="column" padding={1}>
224
224
  <text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
225
225
  <Show when={summaryLines().length > 0}>
226
226
  <box flexShrink={0} flexDirection="column">
@@ -230,13 +230,15 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
230
230
  </box>
231
231
  </Show>
232
232
  <scrollbox
233
- height={viewportRows()}
233
+ flexGrow={1}
234
+ flexShrink={1}
235
+ minHeight={Math.min(PLAN_MIN_VIEWPORT_ROWS, Math.max(1, visualRows()))}
234
236
  focusable={false}
235
237
  scrollY={true}
236
238
  scrollX={false}
237
239
  stickyStart="top"
238
240
  viewportCulling={true}
239
- verticalScrollbarOptions={{ visible: visualRows() > PLAN_VIEWPORT_ROWS }}
241
+ verticalScrollbarOptions={{ visible: visualRows() > PLAN_MIN_VIEWPORT_ROWS }}
240
242
  >
241
243
  <Index each={props.plan}>
242
244
  {(step) => {
@@ -385,7 +387,11 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
385
387
  .filter((line) => activeLogTab() === 'agent-status' ? isAgentStatus(line) : !isAgentStatus(line));
386
388
  const allLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()));
387
389
  return (
388
- <box flexGrow={2} flexDirection="column" paddingX={1} marginTop={6} focusable={false}>
390
+ // No hardcoded marginTop here any more: the 6 blank lines it reserved
391
+ // above the Runtime/Agent status tabs left a dead gap under a short Plan
392
+ // and made the two panels look detached. The Plan/Queue panel now grows
393
+ // into that space instead, so its bottom edge meets this panel's header.
394
+ <box flexGrow={2} flexDirection="column" paddingX={1} focusable={false}>
389
395
  <text width={lineWidth()} fg="#4B5563" content={'─'.repeat(lineWidth())} />
390
396
  <box height={1} flexDirection="row">
391
397
  <text
package/src/shell/repl.js CHANGED
@@ -11,7 +11,7 @@ import { markedTerminal } from 'marked-terminal';
11
11
  import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
12
12
  import { handleSlashCommand, rawCommandAgentPrompt } from '../commands/slash.js';
13
13
  import { serviceChoices as composeServiceChoices, serviceDescription } from '../core/compose.js';
14
- import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
14
+ import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
15
15
  import { syncActivitiesToPlan } from '../core/plan.js';
16
16
  import { buildLlmTools, callMcpTool, formatMcpToolResult, parseToolCallName, resolveToolCallName } from '../core/mcp.js';
17
17
  import { runBoundedToolLoop } from '../core/toolLoop.js';
@@ -1948,7 +1948,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1948
1948
  void callMcpTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {})
1949
1949
  .then((result) => {
1950
1950
  const payload = parseJsonText(formatMcpToolResult(result));
1951
- const polledActivity = extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool });
1951
+ const polledActivity = mergePolledActivity(activity, extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool }));
1952
1952
  if (polledActivity) {
1953
1953
  dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
1954
1954
  origin: 'poll',
@@ -3,7 +3,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
4
4
  import { formatMcpToolResult, callMcpTool } from '../core/mcp.js';
5
5
  import { versionWithBuild } from '../core/buildInfo.js';
6
- import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
6
+ import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
7
7
  import { formatPlanStatus, formatCompletedActivities, formatPlanStep } from '../core/plan.js';
8
8
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
9
9
  import { projectQueue, queueCounts, startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
@@ -277,7 +277,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
277
277
  return {
278
278
  ...item,
279
279
  id: item.id,
280
- label: `${chain.skillName ?? 'skill'}${chain.selectionKind ? ` [${chain.selectionKind}]` : ''} ${position} · ${step.label}${reason}`,
280
+ label: `${chain.skillName ?? 'skill'}${chain.selectionLabel ? ` [${chain.selectionLabel}]` : ''} ${position} · ${step.label}${reason}`,
281
281
  status: item.status,
282
282
  _runtime: true,
283
283
  _control: true,
@@ -602,10 +602,10 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
602
602
  void callMcpTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {})
603
603
  .then((result) => {
604
604
  const payload = parseJsonText(formatMcpToolResult(result));
605
- const polledActivity = extractActivity(payload, {
605
+ const polledActivity = mergePolledActivity(activity, extractActivity(payload, {
606
606
  server: activity.poll.server,
607
607
  tool: activity.poll.tool,
608
- });
608
+ }));
609
609
  if (polledActivity) {
610
610
  dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
611
611
  origin: 'poll',