@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.
Files changed (43) hide show
  1. package/README.md +92 -24
  2. package/mcp.endpoints.example.json +1 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +288 -22
  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/runtimeLog.js +15 -0
  18. package/src/core/runtimeLog.test.js +15 -1
  19. package/src/core/skillChainView.js +84 -0
  20. package/src/core/skillChainView.test.js +50 -0
  21. package/src/core/skillCompiler.js +135 -0
  22. package/src/core/skillCompiler.test.js +91 -0
  23. package/src/core/skillInvocation.js +79 -0
  24. package/src/core/skillInvocation.test.js +73 -0
  25. package/src/core/skills.js +81 -19
  26. package/src/runtime/approvals.js +13 -1
  27. package/src/runtime/approvals.test.js +44 -0
  28. package/src/runtime/client.js +45 -4
  29. package/src/runtime/controlCancellation.js +33 -0
  30. package/src/runtime/controlCancellation.test.js +49 -0
  31. package/src/runtime/controlDrain.js +50 -0
  32. package/src/runtime/controlDrain.test.js +38 -0
  33. package/src/runtime/server.js +333 -15
  34. package/src/runtime/server.test.js +392 -0
  35. package/src/runtime/skillChain.e2e.test.js +394 -0
  36. package/src/runtime/skillRun.js +104 -0
  37. package/src/runtime/skillRun.test.js +84 -0
  38. package/src/runtime/store.js +69 -0
  39. package/src/runtime/store.test.js +11 -0
  40. package/src/shell/RightPane.tsx +3 -2
  41. package/src/shell/repl.js +40 -5
  42. package/src/shell/repl.test.js +41 -0
  43. package/src/shell/useSession.ts +43 -9
@@ -0,0 +1,98 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { handleRuntimeControlTool } from './graph.js';
4
+
5
+ /*
6
+ Récursion observée en headless : `wiki-ingest` se réinvoquait au lieu de
7
+ lancer la production, quatre fois avant interruption manuelle.
8
+
9
+ La cause n'est pas le modèle. Le corps d'une compétence est compilé en
10
+ intentions MÉTIER, et une intention ressemble forcément à la description de la
11
+ compétence dont elle sort — « ingérer les fichiers en attente » est à la fois
12
+ l'objectif de /wiki-ingest et sa raison d'être. Le sélecteur la reconnaissait
13
+ donc légitimement. Ce qui manquait, c'est que le run ne savait pas d'où il
14
+ venait : `chainId` et `skillName` s'arrêtaient à l'item de contrôle.
15
+ */
16
+
17
+ function session(skillStack, ran = []) {
18
+ return {
19
+ // La garde d'entrée exige une URL de runtime ; le chemin run_skill passe
20
+ // ensuite par _runSkillWithinRun sans jamais l'appeler.
21
+ runtime: { url: 'http://runtime.invalid' },
22
+ _skillStack: skillStack,
23
+ _runSkillWithinRun: async (name) => { ran.push(name); return { ok: true, ran: name }; },
24
+ };
25
+ }
26
+
27
+ const call = (state, skillName) =>
28
+ handleRuntimeControlTool(state, 'run_skill', { skillName, _userInput: skillName })
29
+ .then((raw) => JSON.parse(raw));
30
+
31
+ test('refuse de relancer une compétence déjà dans la pile', async () => {
32
+ const result = await call(session(['wiki-ingest']), 'wiki-ingest');
33
+
34
+ assert.equal(result.ok, false);
35
+ assert.equal(result.terminal, true);
36
+ assert.equal(result.code, 'skill_recursion_blocked');
37
+ // Le diagnostic doit dire quoi faire à la place, sinon le modèle réessaie
38
+ // avec une autre formulation du même appel.
39
+ assert.match(result.message, /execute its objective directly/i);
40
+ });
41
+
42
+ test('ignore la casse : le nom est le même concept', async () => {
43
+ const result = await call(session(['wiki-ingest']), 'Wiki-Ingest');
44
+
45
+ assert.equal(result.code, 'skill_recursion_blocked');
46
+ });
47
+
48
+ test('détecte un cycle indirect, pas seulement l’auto-appel', async () => {
49
+ // a → b → a : chacun des deux appels est légitime pris isolément.
50
+ const result = await call(session(['wiki-sync', 'wiki-ingest']), 'wiki-sync');
51
+
52
+ assert.equal(result.code, 'skill_recursion_blocked');
53
+ });
54
+
55
+ test('laisse passer une composition sans cycle', async () => {
56
+ // Le refus porte sur les cycles, pas sur la composition : une compétence a
57
+ // le droit d'en appeler une autre.
58
+ const result = await call(session(['wiki-sync']), 'deliver');
59
+
60
+ assert.equal(result.ok, true);
61
+ assert.equal(result.ran, 'deliver');
62
+ });
63
+
64
+ test('laisse passer une invocation hors de toute chaîne', async () => {
65
+ const result = await call(session(undefined), 'wiki-ingest');
66
+
67
+ assert.equal(result.ok, true);
68
+ });
69
+
70
+ /*
71
+ `/new-template` observé en conditions réelles : la compétence se rappelait
72
+ elle-même, le run finissait `done`, et rien n'avait été créé. Les tests
73
+ ci-dessus vérifiaient le code de refus ; celui-ci vérifie qu'AUCUN travail
74
+ n'est lancé — c'est ce qui distingue un refus d'un simple avertissement.
75
+ */
76
+ test('un refus n’exécute rien du tout', async () => {
77
+ const ran = [];
78
+ const result = await handleRuntimeControlTool(
79
+ session(['new-template'], ran),
80
+ 'run_skill',
81
+ { skillName: 'new-template', _userInput: 'crée un modèle de présentation' },
82
+ ).then((raw) => JSON.parse(raw));
83
+
84
+ assert.equal(result.code, 'skill_recursion_blocked');
85
+ assert.deepEqual(ran, [], 'aucun run imbriqué ne doit démarrer');
86
+ // La pile revient au modèle : sans elle il ne peut pas savoir laquelle des
87
+ // compétences ouvertes le bloque, et il reformule le même appel.
88
+ assert.deepEqual(result.skillStack, ['new-template']);
89
+ assert.match(result.message, /new-template/);
90
+ });
91
+
92
+ test('borne la profondeur même sans cycle', async () => {
93
+ // La détection de cycle ne voit pas une chaîne longue de compétences
94
+ // distinctes, qui épuiserait le budget aussi sûrement.
95
+ const result = await call(session(['a', 'b', 'c']), 'd');
96
+
97
+ assert.equal(result.code, 'skill_depth_exceeded');
98
+ });
@@ -31,6 +31,7 @@ import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
31
31
  import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
32
32
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
33
33
  import { listWorkspaces } from '../core/workspaces.js';
34
+ import { findSkill } from '../core/skills.js';
34
35
  // Runtime modules use node:sqlite (Node.js built-in unavailable in Bun).
35
36
  // They are imported dynamically so the shell / TUI path never loads them.
36
37
 
@@ -288,6 +289,7 @@ export function createInteractiveSession(context, { runtimeUrl, turnId, signal =
288
289
  'workspace', 'workspacePath', 'workspaceEnvFile', 'workspaceEnv',
289
290
  'wikirc', 'wikircConfig', 'language', 'llm', 'mcp', 'commands',
290
291
  'packageJson', 'queueStore', 'systemPrompt',
292
+ '_runSkillWithinRun',
291
293
  ]) {
292
294
  if (source[key] !== undefined) session[key] = source[key];
293
295
  }
@@ -599,7 +601,14 @@ async function runHeadless(argv, agent) {
599
601
  const maxTurnsArg = valueAfter(argv, '--max-turns');
600
602
  const timeoutMs = (Number.isFinite(Number(timeoutArg)) ? Math.max(1, Number(timeoutArg)) : 3600) * 1000;
601
603
  const maxTurns = Number.isFinite(Number(maxTurnsArg)) ? Math.max(1, Number(maxTurnsArg)) : 20;
604
+ // --skill accepts its arguments inline, exactly like the Shell and serve
605
+ // invocation: --skill "deliver rapport polish".
606
+ const skillArgv = String(skillName ?? '').trim().split(/\s+/).filter(Boolean);
607
+ const skillId = skillArgv[0] ?? null;
608
+ const skillArgs = skillArgv.slice(1).join(' ');
602
609
  // --skill uses the agentic loop (multi-turn); --prompt uses a single turn unless --wait is set.
610
+ // The loop is the legacy local path only: when the runtime answers, the skill
611
+ // is compiled and executed server-side and there is no local loop to run.
603
612
  const useAgenticLoop = Boolean(skillName) && !argv.includes('--no-wait');
604
613
  const wait = !useAgenticLoop && (argv.includes('--wait'));
605
614
  const log = [`wiki-manager ${packageJson.version} headless`, `startedAt=${new Date().toISOString()}`];
@@ -653,7 +662,51 @@ async function runHeadless(argv, agent) {
653
662
  session._onStep = step;
654
663
 
655
664
  let input = prompt;
665
+ // Plan §24: headless must resolve executable skills through the same
666
+ // runtime resolver as the Shell and serve. Injecting the skill body into a
667
+ // local prompt bypasses the compiler, so a multi-capability skill such as
668
+ // wiki-sync would collapse into a single run here while producing two
669
+ // everywhere else — and the rewritten bodies are business intentions, not
670
+ // the step-by-step procedures the local prompt still expects.
671
+ if (skillId && session.runtime?.url) {
672
+ if (prompt) {
673
+ step('headless: --prompt is ignored when --skill runs through the runtime.');
674
+ }
675
+ const invocation = `/${skillId}${skillArgs ? ` ${skillArgs}` : ''}`;
676
+ log.push(`skill=${invocation}`);
677
+ const { postRuntimeRun } = await import('../runtime/client.js');
678
+ let accepted;
679
+ try {
680
+ accepted = await postRuntimeRun(invocation, {
681
+ url: session.runtime.url,
682
+ workspace: session.workspace ?? null,
683
+ skillName: skillId,
684
+ });
685
+ } catch (err) {
686
+ throw new Error(`Skill invocation failed: ${err instanceof Error ? err.message : String(err)}`);
687
+ }
688
+ if (accepted?.kind !== 'skill_chain') {
689
+ throw new Error(`Skill not found: ${skillId}`);
690
+ }
691
+ step(`skill: ${accepted.skill} compiled into ${accepted.objectives} objective(s), chain ${accepted.chainId}`);
692
+ const { exitCode: chainExit } = await waitForRuntimeChain(session, log, {
693
+ chainId: accepted.chainId,
694
+ timeoutMs,
695
+ autoApprove: argv.includes('--auto-approve'),
696
+ });
697
+ const saved = await writeHeadlessLog(session, log, logFile);
698
+ console.log(`Headless log: ${saved}`);
699
+ if (chainExit !== 0) process.exitCode = chainExit;
700
+ return;
701
+ }
656
702
  if (skillName) {
703
+ const legacySkill = findSkill(session, skillId);
704
+ if (!legacySkill) throw new Error(`Skill not found: ${skillId}`);
705
+ if (!argv.includes('--auto-approve')) {
706
+ throw new Error('Headless --no-runtime skill execution requires explicit --auto-approve before any mutation can run.');
707
+ }
708
+ session._skillStack = [legacySkill.name];
709
+ session._skillExecution = legacySkill.execution === 'direct' ? 'direct' : 'orchestrated';
657
710
  const skillResult = await handleSlashCommand(`/skills run ${skillName}`, { packageJson, session, onStep: step });
658
711
  if (skillResult.output && !skillResult.rawOutput) log.push(skillResult.output);
659
712
  if (String(skillResult.output ?? '').startsWith('Skill not found')) throw new Error(`Skill not found: ${skillName}`);
@@ -717,6 +770,115 @@ async function runHeadless(argv, agent) {
717
770
  }
718
771
  }
719
772
 
773
+ // A skill may compile into several sequential runs, so waiting on "the run this
774
+ // turn created" is not enough: wiki-sync would report success as soon as the
775
+ // export finished, before the ingest had even started. The control queue is the
776
+ // only place where the whole chain is observable, so the wait is scoped to
777
+ // chainId and ends when every item of that chain is terminal.
778
+ const CHAIN_TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'skipped']);
779
+
780
+ export async function waitForRuntimeChain(session, log, {
781
+ chainId,
782
+ timeoutMs,
783
+ pollMs = 1500,
784
+ autoApprove = false,
785
+ client = null,
786
+ } = {}) {
787
+ const { fetchRuntimeState, postRuntimeApprove } = client ?? await import('../runtime/client.js');
788
+ const url = session.runtime?.url;
789
+ const workspace = session.workspace ?? null;
790
+ if (!url || !chainId) return { exitCode: 0 };
791
+ const deadline = Date.now() + timeoutMs;
792
+ const approvedRevisions = new Set();
793
+ const reported = new Map();
794
+ let lastLogCount = 0;
795
+ while (Date.now() < deadline) {
796
+ let state;
797
+ try {
798
+ state = await fetchRuntimeState({ url, workspace });
799
+ } catch (err) {
800
+ const line = `chain-wait: state fetch failed (${err instanceof Error ? err.message : String(err)})`;
801
+ log.push(line); console.error(line);
802
+ return { exitCode: 1 };
803
+ }
804
+ const logs = Array.isArray(state?.logs) ? state.logs : [];
805
+ for (const entry of logs.slice(lastLogCount)) {
806
+ const text = typeof entry === 'string' ? entry : String(entry?.message ?? JSON.stringify(entry));
807
+ log.push(`runtime: ${text}`); console.log(`[runtime] ${text}`);
808
+ }
809
+ lastLogCount = logs.length;
810
+
811
+ const items = (Array.isArray(state?.controlQueue) ? state.controlQueue : [])
812
+ .filter((item) => item?.chainId === chainId)
813
+ .sort((a, b) => Number(a.chainSequence ?? 0) - Number(b.chainSequence ?? 0));
814
+ if (items.length === 0) {
815
+ const line = `chain-wait: chain ${chainId} is not visible in the runtime queue.`;
816
+ log.push(line); console.error(line);
817
+ return { exitCode: 1 };
818
+ }
819
+ for (const item of items) {
820
+ const status = String(item.status ?? 'queued');
821
+ if (reported.get(item.id) === status) continue;
822
+ reported.set(item.id, status);
823
+ const line = `chain-step ${Number(item.chainSequence ?? 0) + 1}/${items.length}: ${status}${item.skipReason ? ` (${item.skipReason})` : ''}`;
824
+ log.push(line); console.log(`[runtime] ${line}`);
825
+ }
826
+
827
+ const active = items.find((item) => item.status === 'running');
828
+ if (autoApprove && active?.runId) {
829
+ const pending = (Array.isArray(state?.approvals) ? state.approvals : [])
830
+ .filter((approval) => approval.status === 'pending_approval'
831
+ && (approval.runId == null || String(approval.runId) === String(active.runId)));
832
+ const planRevision = state?.planRevision ?? 0;
833
+ const revisionKey = `${active.runId}:${planRevision}`;
834
+ if (pending.length > 0 && !approvedRevisions.has(revisionKey)) {
835
+ approvedRevisions.add(revisionKey);
836
+ const approvalClasses = [...new Set(pending.flatMap((approval) => {
837
+ const value = approval.approvalClasses ?? approval.approvalClass ?? [];
838
+ return Array.isArray(value) ? value : [value];
839
+ }).map(String).filter(Boolean))];
840
+ try {
841
+ await postRuntimeApprove({
842
+ url,
843
+ workspace,
844
+ runId: active.runId,
845
+ scope: 'run',
846
+ planRevision,
847
+ approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
848
+ });
849
+ const line = `chain-wait: auto-approved run ${active.runId} (revision ${planRevision})`;
850
+ log.push(line); console.log(line);
851
+ } catch (err) {
852
+ const line = `chain-wait: auto-approve failed (${err instanceof Error ? err.message : String(err)})`;
853
+ log.push(line); console.error(line);
854
+ return { exitCode: 1 };
855
+ }
856
+ }
857
+ }
858
+ if (!autoApprove) {
859
+ const blocked = (Array.isArray(state?.approvals) ? state.approvals : [])
860
+ .some((approval) => approval.status === 'pending_approval');
861
+ if (blocked && active) {
862
+ const line = `chain-wait: chain ${chainId} is waiting for approval; re-run with --auto-approve to drive it through.`;
863
+ log.push(line); console.log(line);
864
+ return { exitCode: 0 };
865
+ }
866
+ }
867
+
868
+ if (items.every((item) => CHAIN_TERMINAL_STATUSES.has(String(item.status)))) {
869
+ const failed = items.filter((item) => item.status === 'failed');
870
+ const skipped = items.filter((item) => item.status === 'skipped');
871
+ const summary = `chain ${chainId}: ${items.length} step(s), ${failed.length} failed, ${skipped.length} skipped`;
872
+ log.push(summary); console.log(`[runtime] ${summary}`);
873
+ return { exitCode: failed.length > 0 ? 1 : 0 };
874
+ }
875
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
876
+ }
877
+ const line = `chain-wait: timeout waiting for chain ${chainId} to finish.`;
878
+ log.push(line); console.error(line);
879
+ return { exitCode: 1 };
880
+ }
881
+
720
882
  async function runRuntime(argv, agent) {
721
883
  if (argv.includes('--help') || argv.includes('-h')) {
722
884
  console.log([
@@ -1013,7 +1175,7 @@ async function runRuntime(argv, agent) {
1013
1175
  }
1014
1176
  emitRuntimeLog(context.session, manual ? 'runtime: manual resume completed' : 'runtime: recovery completed');
1015
1177
  const controlStarted = pollingActivities.length === 0
1016
- ? serverHandle?.drainControl?.(context) === true
1178
+ ? await serverHandle?.drainControl?.(context) === true
1017
1179
  : false;
1018
1180
  return {
1019
1181
  workspace: context.workspace ?? workspace ?? null,
@@ -1155,27 +1317,62 @@ async function runRuntime(argv, agent) {
1155
1317
  const maxTurns = Number.isFinite(Number(body.maxTurns)) ? Math.max(1, Number(body.maxTurns)) : 20;
1156
1318
  const maxReplans = Number.isFinite(Number(body.replans)) ? Math.max(0, Math.floor(Number(body.replans))) : undefined;
1157
1319
  const runId = String(body.runId);
1320
+ // Déclarée hors du try : le finally doit pouvoir restaurer la pile même
1321
+ // quand le run échoue avant de l'avoir installée.
1322
+ const parentStack = Array.isArray(session._skillStack) ? session._skillStack : [];
1158
1323
  try {
1159
1324
  if (workspace && session.workspace !== workspace) {
1160
1325
  const result = await handleSlashCommand(`/use ${workspace}`, { packageJson, session });
1161
1326
  if (!session.workspacePath) throw new Error(result.output || `Workspace not loaded: ${workspace}`);
1162
1327
  }
1163
1328
  context.workspace = session.workspace ?? workspace ?? context.workspace ?? null;
1329
+ /*
1330
+ Pile des compétences en cours d'exécution.
1331
+
1332
+ Une intention compilée depuis une compétence ressemble, par
1333
+ construction, à la description de cette compétence : le sélecteur de
1334
+ l'agent la reconnaît et la relance. Garder la pile permet de refuser un
1335
+ cycle sans interdire une composition légitime.
1336
+ */
1337
+ const skillChain = body.skillChain ?? null;
1338
+ /*
1339
+ La pile vient de l'ÉLÉMENT quand il en porte une.
1340
+
1341
+ Elle était reconstruite à partir de `session._skillStack`, c'est-à-dire
1342
+ de l'état laissé par le run précédent sur cette session. Mais un run de
1343
+ compétence imbriquée démarre après le `finally` de son parent, qui a déjà
1344
+ tout restauré : la pile héritée était donc celle du grand-parent, pas
1345
+ celle du parent. A→B→A traversait sans être vu.
1346
+
1347
+ Le repli sur `parentStack` couvre les éléments d'avant ce changement,
1348
+ encore en file dans un runtime qui redémarre, et l'invocation directe
1349
+ depuis une session interactive.
1350
+ */
1351
+ session._skillStack = Array.isArray(skillChain?.skillStack) && skillChain.skillStack.length
1352
+ ? skillChain.skillStack.map((entry) => String(entry))
1353
+ : skillChain?.skillName
1354
+ ? [...parentStack, String(skillChain.skillName)]
1355
+ : parentStack;
1164
1356
  session._currentRunIdentity = {
1165
1357
  runId,
1166
1358
  turnId: `${runId}:turn-0`,
1167
1359
  workspace: context.workspace,
1360
+ ...(skillChain ? { skillChain } : {}),
1168
1361
  };
1169
1362
  dispatchAgentEvent(session, createAgentEvent('run_started', {
1170
1363
  origin: 'runtime',
1171
1364
  runId,
1172
- payload: { input, workspace: session._currentRunIdentity.workspace },
1173
- }));
1174
- dispatchAgentEvent(session, createAgentEvent('user_message', {
1175
- origin: 'user',
1176
- runId,
1177
- payload: { content: input },
1365
+ payload: { input: String(body.publicInput ?? input), workspace: session._currentRunIdentity.workspace },
1178
1366
  }));
1367
+ // Compiled skill objectives are private execution material. The
1368
+ // original slash invocation is the user-facing conversation turn.
1369
+ if (!skillChain) {
1370
+ dispatchAgentEvent(session, createAgentEvent('user_message', {
1371
+ origin: 'user',
1372
+ runId,
1373
+ payload: { content: input },
1374
+ }));
1375
+ }
1179
1376
  session._abortSignal = signal ?? null;
1180
1377
  session._runApprovalRequired = body.requireApproval === true;
1181
1378
  session._runApprovalResolved = false;
@@ -1322,6 +1519,11 @@ async function runRuntime(argv, agent) {
1322
1519
  delete session._runApprovalRequired;
1323
1520
  delete session._runApprovalResolved;
1324
1521
  delete session._approvalTimeoutMs;
1522
+ // La session survit au run : une pile laissée en place bloquerait une
1523
+ // invocation parfaitement légitime au run suivant, et le diagnostic
1524
+ // serait incompréhensible.
1525
+ if (parentStack.length) session._skillStack = parentStack;
1526
+ else delete session._skillStack;
1325
1527
  }
1326
1528
  }
1327
1529
 
@@ -8,8 +8,23 @@ import {
8
8
  resolveExecutorArguments,
9
9
  resolvePreparedDelegationApproval,
10
10
  startupWizardGaps,
11
+ waitForRuntimeChain,
11
12
  } from './wiki-manager.js';
12
13
 
14
+ const CHAIN_SESSION = { runtime: { url: 'http://127.0.0.1:7788' }, workspace: 'demo' };
15
+
16
+ function chainClient(states, { onApprove } = {}) {
17
+ let call = 0;
18
+ return {
19
+ fetchRuntimeState: async () => states[Math.min(call++, states.length - 1)],
20
+ postRuntimeApprove: async (args) => { onApprove?.(args); return { approved: true }; },
21
+ };
22
+ }
23
+
24
+ function chainItem(sequence, status, extra = {}) {
25
+ return { id: `control-${sequence}`, chainId: 'chain-1', chainSequence: sequence, status, ...extra };
26
+ }
27
+
13
28
  const COLLECT_CAPABILITY = {
14
29
  description: 'Collect content from an external connector source.',
15
30
  inputSchema: {
@@ -348,3 +363,77 @@ test('argument extraction keeps a value the vocabulary allows', async () => {
348
363
  { source_name: 'EAS_Avant_projet_ACPI' },
349
364
  );
350
365
  });
366
+
367
+ test('headless waits for every run of a skill chain, not just the first', async () => {
368
+ // wiki-sync compiles into two sequential runs: returning as soon as the
369
+ // export finishes would report success before the ingest had started.
370
+ const client = chainClient([
371
+ { controlQueue: [chainItem(0, 'running', { runId: 'run-a' }), chainItem(1, 'queued')] },
372
+ { controlQueue: [chainItem(0, 'done'), chainItem(1, 'running', { runId: 'run-b' })] },
373
+ { controlQueue: [chainItem(0, 'done'), chainItem(1, 'done')] },
374
+ ]);
375
+ const log = [];
376
+ const result = await waitForRuntimeChain(CHAIN_SESSION, log, {
377
+ chainId: 'chain-1', timeoutMs: 5000, pollMs: 1, client,
378
+ });
379
+ assert.equal(result.exitCode, 0);
380
+ assert.ok(log.some((line) => line.startsWith('chain-step 2/2: running')), 'second step must be observed');
381
+ assert.ok(log.some((line) => line.includes('2 step(s), 0 failed, 0 skipped')));
382
+ });
383
+
384
+ test('a failed chain step propagates a non-zero exit code and reports the skip', async () => {
385
+ const client = chainClient([
386
+ { controlQueue: [chainItem(0, 'running', { runId: 'run-a' }), chainItem(1, 'queued')] },
387
+ {
388
+ controlQueue: [
389
+ chainItem(0, 'failed'),
390
+ chainItem(1, 'skipped', { skipReason: 'required_predecessor_failed' }),
391
+ ],
392
+ },
393
+ ]);
394
+ const log = [];
395
+ const result = await waitForRuntimeChain(CHAIN_SESSION, log, {
396
+ chainId: 'chain-1', timeoutMs: 5000, pollMs: 1, client,
397
+ });
398
+ assert.equal(result.exitCode, 1);
399
+ assert.ok(log.some((line) => line.includes('required_predecessor_failed')));
400
+ });
401
+
402
+ test('a chain blocked on approval returns instead of hanging until the timeout', async () => {
403
+ const blocked = {
404
+ controlQueue: [chainItem(0, 'running', { runId: 'run-a' })],
405
+ approvals: [{ status: 'pending_approval', runId: 'run-a' }],
406
+ };
407
+ const log = [];
408
+ const result = await waitForRuntimeChain(CHAIN_SESSION, log, {
409
+ chainId: 'chain-1', timeoutMs: 5000, pollMs: 1, client: chainClient([blocked]),
410
+ });
411
+ assert.equal(result.exitCode, 0);
412
+ assert.ok(log.some((line) => line.includes('--auto-approve')));
413
+ });
414
+
415
+ test('--auto-approve grants the run-scoped approval of the active chain step once', async () => {
416
+ const approvals = [];
417
+ const client = chainClient([
418
+ {
419
+ controlQueue: [chainItem(0, 'running', { runId: 'run-a' })],
420
+ approvals: [{ status: 'pending_approval', runId: 'run-a', approvalClasses: ['mutation'] }],
421
+ planRevision: 3,
422
+ },
423
+ {
424
+ controlQueue: [chainItem(0, 'running', { runId: 'run-a' })],
425
+ approvals: [{ status: 'pending_approval', runId: 'run-a', approvalClasses: ['mutation'] }],
426
+ planRevision: 3,
427
+ },
428
+ { controlQueue: [chainItem(0, 'done')] },
429
+ ], { onApprove: (args) => approvals.push(args) });
430
+ const result = await waitForRuntimeChain(CHAIN_SESSION, [], {
431
+ chainId: 'chain-1', timeoutMs: 5000, pollMs: 1, autoApprove: true, client,
432
+ });
433
+ assert.equal(result.exitCode, 0);
434
+ assert.equal(approvals.length, 1, 'the same revision must not be approved twice');
435
+ assert.deepEqual(
436
+ { runId: approvals[0].runId, scope: approvals[0].scope, planRevision: approvals[0].planRevision },
437
+ { runId: 'run-a', scope: 'run', planRevision: 3 },
438
+ );
439
+ });
@@ -11,7 +11,7 @@ import { isTerminal } from '../orchestrator/taskStatuses.js';
11
11
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
12
12
  import { openExternalUrl } from '../shell/openExternal.js';
13
13
  import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
14
- import { join, relative } from 'node:path';
14
+ import { basename, join, relative } from 'node:path';
15
15
  import { composeServices, listServices, otherWorkspacesRunning, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
16
16
  import { agentServiceNames, profileServiceStatus } from '../core/agentsCompose.js';
17
17
  import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from '../core/googleGrants.js';
@@ -26,7 +26,7 @@ import {
26
26
  formatMcpTools,
27
27
  } from '../core/mcp.js';
28
28
  import { createWorkspace, findWorkspace, listWorkspaces } from '../core/workspaces.js';
29
- import { findSkill, listSkills } from '../core/skills.js';
29
+ import { findSkill, inspectSkills, listSkills } from '../core/skills.js';
30
30
  import { extractActivity, formatActivityError, formatActivityLine, formatActivitySummary, parseJsonText } from '../core/activity.js';
31
31
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
32
32
  import {
@@ -464,23 +464,27 @@ export function compactMcpStatus(mcpStatus) {
464
464
  }
465
465
 
466
466
  function skillsText(session) {
467
- const skills = listSkills(session);
468
- if (skills.length === 0) return 'No skills discovered.';
469
- return skills
467
+ const { skills, rejected, warnings } = inspectSkills(session);
468
+ const active = skills
470
469
  .map((skill) => {
471
470
  const description = String(skill.description || 'workflow skill').replace(/\s+/g, ' ').trim();
472
471
  const compact = description.length > 96 ? `${description.slice(0, 93)}...` : description;
473
472
  return `${skill.name}\t${skill.scope}\t${compact}`;
474
473
  })
475
474
  .join('\n');
475
+ return [
476
+ active || 'No skills discovered.',
477
+ rejected.length ? `\nRejected\n${rejected.map((item) => `${item.relativePath}\t${item.reason}${item.name ? `\t${item.name}` : ''}`).join('\n')}` : null,
478
+ warnings.length ? `\nWarnings\n${warnings.map((item) => `${item.relativePath}\t${item.reason}\t${item.name}`).join('\n')}` : null,
479
+ ].filter(Boolean).join('\n');
476
480
  }
477
481
 
478
- function skillDetailText(skill) {
482
+ function skillDetailText(skill, session) {
479
483
  return [
480
484
  `# ${skill.name}`,
481
485
  '',
482
486
  `Scope: ${skill.scope}`,
483
- `Path: ${skill.path}`,
487
+ `Path: ${session?.workspacePath ? relative(session.workspacePath, skill.path) : basename(skill.path)}`,
484
488
  skill.description ? `Description: ${skill.description}` : null,
485
489
  skill.params?.length ? `Params: ${skill.params.join(', ')}` : null,
486
490
  '',
@@ -523,7 +527,7 @@ function skillActionCommand(session, action, name) {
523
527
  agentTrigger: buildSkillRunPrompt(skill),
524
528
  };
525
529
  }
526
- return { output: skillDetailText(skill) };
530
+ return { output: skillDetailText(skill, session) };
527
531
  }
528
532
 
529
533
  function skillEditCommand(session, name) {
@@ -636,6 +640,8 @@ async function statusText(session) {
636
640
  `path: ${compactPath(session.workspacePath ?? '-')}`,
637
641
  `env: ${compactPath(session.workspaceEnvFile ?? '-')}`,
638
642
  ]);
643
+ const skillDiagnostics = inspectSkills(session);
644
+ const skillDiagnosticCount = skillDiagnostics.rejected.length + skillDiagnostics.warnings.length;
639
645
  const configColumn = sectionBlock('Config', [
640
646
  `wikirc: ${session.wikirc?.profile ?? '-'}${session.wikirc?.fileName ? ` (${session.wikirc.fileName})` : ''}`,
641
647
  `language: ${session.language ?? '-'}`,
@@ -643,6 +649,7 @@ async function statusText(session) {
643
649
  `provider: ${session.wikircConfig?.llm?.provider ?? '-'}`,
644
650
  `model: ${session.wikircConfig?.llm?.model ?? '-'}`,
645
651
  `baseUrl: ${compactBaseUrl(session.wikircConfig?.llm?.baseUrl)}`,
652
+ ...(skillDiagnosticCount ? [`skill diagnostics: ${skillDiagnostics.rejected.length} rejected, ${skillDiagnostics.warnings.length} warning(s) (/skills)`] : []),
646
653
  ]);
647
654
  const runtimeColumn = sectionBlock('Runtime', (states ? serviceStatesText(states) : 'Docker runtime not available or no workspace loaded.').split('\n'));
648
655
  const mcpColumn = sectionBlock('MCP', compactMcpStatus(session.mcp).split('\n'));
@@ -721,9 +728,10 @@ Options:
721
728
  --once <prompt> Run one agent turn and exit
722
729
  --headless Run a workspace task non-interactively
723
730
  --workspace <name> Initial workspace (interactive or --headless)
724
- --skill <name> Skill to run in --headless (implies --wait)
731
+ --skill "<name> [args]" Skill invocation for --headless (implies --wait)
725
732
  --prompt <text> Task or extra instruction for --headless
726
733
  --log-file <path> Optional headless log path
734
+ --auto-approve Approve validated headless run revisions automatically
727
735
  --wait Wait for active jobs to complete after agent turn (--prompt only)
728
736
  --no-wait Disable agentic loop for --skill (single turn)
729
737
  --timeout <seconds> Per-wave job wait timeout in seconds (default: 3600)
@@ -1431,7 +1439,17 @@ export async function handleSlashCommand(line, context) {
1431
1439
  // would silently revert the item to waiting (fake cancel).
1432
1440
  const localItem = (context.session.jobQueue ?? []).find((item) => String(item.id) === String(id));
1433
1441
  if (localItem?.origin === 'runtime' || (!localItem && runtimeManagedItemId(context, id))) {
1434
- return { output: 'Item géré par le runtime — utilisez /run kill (global) ou /run cancel au lieu de /queue cancel.' };
1442
+ if (!context.runtime?.url) return { output: 'Item géré par le runtime — reconnectez le runtime pour l’annuler, ou utilisez /run cancel ou /run kill.' };
1443
+ try {
1444
+ const result = await postRuntimeControl('cancel_item', {
1445
+ url: context.runtime.url,
1446
+ workspace: context.session.workspace ?? null,
1447
+ id,
1448
+ });
1449
+ return { output: result.cancelled ? `Cancelled runtime queue item ${id}.` : `Runtime queue item ${id} was not cancelled.` };
1450
+ } catch (err) {
1451
+ return { output: `Runtime queue cancellation failed: ${err instanceof Error ? err.message : String(err)}` };
1452
+ }
1435
1453
  }
1436
1454
  const result = await cancelQueueItem(context.session, id);
1437
1455
  return { output: result.message };
@@ -401,7 +401,7 @@ export const contractSchemas = {
401
401
  required: ['action'],
402
402
  additionalProperties: true,
403
403
  properties: {
404
- action: { type: 'string', enum: ['status', 'explain', 'message', 'enqueue', 'approve_patch', 'reject_patch'] },
404
+ action: { type: 'string', enum: ['status', 'explain', 'message', 'enqueue', 'cancel_item', 'approve_patch', 'reject_patch'] },
405
405
  input: { type: 'string' },
406
406
  message: { type: 'string' },
407
407
  prompt: { type: 'string' },