@dotdrelle/wiki-manager 0.15.54 → 0.15.57

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.
@@ -62,6 +62,11 @@ export function createAgentRegistry({
62
62
  } = {}) {
63
63
  const agentsByInstance = new Map();
64
64
  const instanceByServer = new Map();
65
+ // Whether the LAST probe of an instance failed. The "did not answer
66
+ // agent_describe" log is edge-triggered: it is emitted once when an instance
67
+ // stops answering, not on every re-scan while it stays down. A stopped agent
68
+ // is not an error to repeat every minute.
69
+ const lastProbeFailed = new Map();
65
70
 
66
71
  return {
67
72
  async discover(session, { signal = null } = {}) {
@@ -70,13 +75,18 @@ export function createAgentRegistry({
70
75
  const activeServers = new Set(endpoints.map(([serverName]) => serverName));
71
76
  for (const [serverName, endpoint] of endpoints) {
72
77
  const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
73
- discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer }));
78
+ discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer, lastProbeFailed }));
74
79
  }
75
80
  for (const [serverName, instanceId] of instanceByServer) {
76
81
  if (activeServers.has(serverName)) continue;
77
82
  const previous = agentsByInstance.get(instanceId);
78
83
  instanceByServer.delete(serverName);
79
84
  agentsByInstance.delete(instanceId);
85
+ // Same cleanup as the two maps above: without it, a long-running
86
+ // process that sees many renamed/reconnected connectors (the
87
+ // Connectors panel supports exactly this) accumulates one stale
88
+ // entry per retired instance for the process lifetime.
89
+ lastProbeFailed.delete(instanceId);
80
90
  if (previous) dispatchRegistryEvent(session, 'agent.unregistered', {
81
91
  agentInstanceId: instanceId,
82
92
  serverName,
@@ -140,7 +150,7 @@ async function discoverServerAgent(session, serverName, endpoint = {}, { callToo
140
150
  }
141
151
  }
142
152
 
143
- function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
153
+ function registerAgent(session, agent, { agentsByInstance, instanceByServer, lastProbeFailed }) {
144
154
  const previousInstanceId = instanceByServer.get(agent.serverName);
145
155
  const previous = previousInstanceId ? agentsByInstance.get(previousInstanceId) : null;
146
156
 
@@ -166,13 +176,21 @@ function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
166
176
  the blindness — a probe that failed is a fact worth stating, once, where
167
177
  the panels and the shell already read.
168
178
 
179
+ "Once" is the operative word: the re-scan runs every minute, and a stopped
180
+ agent is not an error to repeat each time it is scanned. The log is
181
+ edge-triggered on the transition from answering to not answering.
182
+
169
183
  Deliberately NOT a health change: the endpoint is down but the agent stays
170
184
  usable by design here, and moving `health` would make `capabilityResolver`
171
185
  refuse it — trading a silent loss for a silent refusal.
172
- */
173
- dispatchRuntimeLog(session, `agent-registry: ${agent.serverName} did not answer agent_describe`
174
- + `${agent.error ? ` (${agent.error})` : ''}; keeping its known capabilities`
175
- + ` (${(previous.description?.capabilities ?? []).map((capability) => capability.id).join(', ') || 'none'}).`);
186
+ */
187
+ const wasAnswering = lastProbeFailed.get(previous.agentInstanceId) !== true;
188
+ lastProbeFailed.set(previous.agentInstanceId, true);
189
+ if (wasAnswering) {
190
+ dispatchRuntimeLog(session, `agent-registry: ${agent.serverName} did not answer agent_describe`
191
+ + `${agent.error ? ` (${agent.error})` : ''}; keeping its known capabilities`
192
+ + ` (${(previous.description?.capabilities ?? []).map((capability) => capability.id).join(', ') || 'none'}).`);
193
+ }
176
194
  return cloneAgent(previous);
177
195
  }
178
196
 
@@ -187,6 +205,7 @@ function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
187
205
  }
188
206
  agentsByInstance.set(next.agentInstanceId, next);
189
207
  instanceByServer.set(next.serverName, next.agentInstanceId);
208
+ if (lastProbeFailed) lastProbeFailed.set(next.agentInstanceId, false);
190
209
 
191
210
  if (!previous || previous.agentInstanceId !== next.agentInstanceId) {
192
211
  dispatchRegistryEvent(session, 'agent.registered', { agent: next });
@@ -209,6 +209,47 @@ test('a failed re-discovery keeps a degraded orchestrator agent too', async () =
209
209
  assert.equal(agent.description.capabilities.length, 1);
210
210
  });
211
211
 
212
+ test('a stopped agent is reported once, not on every re-scan', async () => {
213
+ // The re-scan runs every minute; a deliberately stopped agent must not flood
214
+ // the log with the same "did not answer" line. The message is edge-triggered:
215
+ // emitted on the transition from answering to not answering, then silent
216
+ // until it answers again.
217
+ const events = [];
218
+ const session = {
219
+ workspace: 'acpi',
220
+ mcp: { production: { status: 'connected', tools: [{ name: 'agent_describe' }] } },
221
+ _onAgentEvent: (event) => events.push(event),
222
+ };
223
+ let down = false;
224
+ const registry = createAgentRegistry({
225
+ callTool: async () => {
226
+ if (down) throw new Error('fetch failed');
227
+ return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
228
+ },
229
+ });
230
+
231
+ await registry.discover(session);
232
+ down = true;
233
+ await registry.discover(session);
234
+ await registry.discover(session);
235
+ await registry.discover(session);
236
+
237
+ const reports = events.filter((event) => event.type === 'runtime_log'
238
+ && String(event.payload?.message ?? '').includes('agent-registry:'));
239
+ assert.equal(reports.length, 1, 'the down agent is reported once, not once per scan');
240
+
241
+ // It answers again, then drops again: the next failure is reported again.
242
+ down = false;
243
+ await registry.discover(session);
244
+ down = true;
245
+ await registry.discover(session);
246
+ assert.equal(
247
+ events.filter((event) => event.type === 'runtime_log'
248
+ && String(event.payload?.message ?? '').includes('agent-registry:')).length,
249
+ 2,
250
+ );
251
+ });
252
+
212
253
  test('discovery sends the workspace only to agents whose schema declares it', async () => {
213
254
  const seen = {};
214
255
  const registry = createAgentRegistry({
@@ -16,7 +16,10 @@ export class ObjectiveNotOrchestrableError extends Error {
16
16
  export async function resolveObjective(objective, session) {
17
17
  const candidates = capabilityCandidates(session);
18
18
  if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
19
- const deterministic = resolveMentionedRegistryOperation(objective, candidates);
19
+ // Resolution sees the primary intention only (notification + guardrails
20
+ // stripped). The delegated agent still receives the full objective.
21
+ const clean = objectiveForResolution(objective);
22
+ const deterministic = resolveMentionedRegistryOperation(clean, candidates);
20
23
  if (deterministic) return selectionWithProvider(session, deterministic, candidates);
21
24
  const llm = session?.llm;
22
25
  if (!llm?.completeWithTools) throw new Error('Objective resolution requires the configured workspace LLM.');
@@ -25,6 +28,7 @@ export async function resolveObjective(objective, session) {
25
28
  system: [
26
29
  'You resolve one user objective against a closed capability registry.',
27
30
  'Select exactly one listed capability and one of its supported operations.',
31
+ 'The aliases of a capability are the strongest signal: match them before the generic description.',
28
32
  // Without an explicit way out, the model has to pick SOMETHING: an
29
33
  // objective no listed capability covers ("authorize Gmail") came back as
30
34
  // workspace.diagnose/doctor and launched an unrelated job. Declining is
@@ -36,7 +40,7 @@ export async function resolveObjective(objective, session) {
36
40
  tools: [],
37
41
  messages: [{
38
42
  role: 'user',
39
- content: `Objective:\n${String(objective)}\n\nRegistry:\n${JSON.stringify(candidates, null, 2)}`,
43
+ content: `Objective:\n${clean}\n\nRegistry:\n${JSON.stringify(candidates, null, 2)}`,
40
44
  }],
41
45
  signal: session?._abortSignal,
42
46
  });
@@ -54,21 +58,72 @@ export async function resolveObjective(objective, session) {
54
58
  return selectionWithProvider(session, { capability, operation }, candidates);
55
59
  }
56
60
 
57
- // Prefer an operation explicitly named by the user when that name resolves to
58
- // exactly one entry in the live registry. This is deliberately generic: the
59
- // resolver knows neither capability ids nor business verbs. Prefix matching
60
- // covers natural inflections such as an operation name followed by a suffix.
61
- function resolveMentionedRegistryOperation(objective, candidates) {
62
- const words = String(objective ?? '')
61
+ // The best-effort notification sentence and the negative guardrails
62
+ // ("Do not …", "Never …") are execution constraints, not the thing being
63
+ // resolved. They are kept intact for the delegated agent (prepareDelegation
64
+ // passes the original objective), but stripped here so they cannot poison the
65
+ // lexical matcher or the LLM prompt.
66
+ const NOTIFICATION_RE = /\s*[^.!?]*\bnotification\b[^.!?]*[.!?]/g;
67
+ const GUARDRAIL_RE = /\s*\b(?:Do not|do not|Never|never)\b[^.]*\./g;
68
+
69
+ export function objectiveForResolution(objective) {
70
+ return String(objective ?? '')
71
+ .replace(NOTIFICATION_RE, ' ')
72
+ .replace(GUARDRAIL_RE, ' ')
73
+ .replace(/\s+/g, ' ')
74
+ .trim();
75
+ }
76
+
77
+ function normalizeText(text) {
78
+ return String(text ?? '')
63
79
  .normalize('NFKD')
64
80
  .replace(/\p{Diacritic}/gu, '')
65
- .toLowerCase()
66
- .match(/[a-z0-9]+/g) ?? [];
67
- const matches = candidates.flatMap((candidate) => candidate.operations
68
- .filter((operation) => String(operation).split(/[._-]+/).some((token) =>
69
- token.length >= 4 && words.some((word) => word.startsWith(token))))
70
- .map((operation) => ({ capability: candidate.id, operation })));
71
- return matches.length === 1 ? matches[0] : null;
81
+ .toLowerCase();
82
+ }
83
+
84
+ function normalizePhrase(value) {
85
+ return normalizeText(value).replace(/[._-]+/g, ' ').replace(/\s+/g, ' ').trim();
86
+ }
87
+
88
+ function phraseIn(phrase, words, text) {
89
+ if (!phrase) return false;
90
+ if (!phrase.includes(' ')) return words.includes(phrase);
91
+ const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
+ return new RegExp(`(?:^|\\s)${escaped}(?:\\s|$)`).test(text);
93
+ }
94
+
95
+ // Deterministic fast path, safe by construction:
96
+ // - whole-word/phrase matching only — no sub-token split (so `ingest_plan`
97
+ // never matches the generic word "plan") and no prefix stemming (so
98
+ // "exported"/"builds" never match "export"/"build");
99
+ // - aliases (declared by each agent in agent_describe) are authoritative and
100
+ // disambiguate overloaded verbs ("export" CME vs publish);
101
+ // - it fires only when exactly one capability is named, otherwise the LLM
102
+ // resolver decides. A new external agent registers simply by declaring its
103
+ // aliases; nothing here is hardcoded.
104
+ function resolveMentionedRegistryOperation(objective, candidates) {
105
+ const words = normalizeText(objective).match(/[a-z0-9]+/g) ?? [];
106
+ const text = normalizeText(objective);
107
+
108
+ const aliasHits = candidates
109
+ .filter((candidate) => (candidate.aliases ?? []).some((alias) =>
110
+ phraseIn(normalizePhrase(alias), words, text)))
111
+ .map((candidate) => ({ capability: candidate.id, operation: candidate.operations[0] }));
112
+ if (aliasHits.length === 1) return aliasHits[0];
113
+ if (aliasHits.length > 1) return null;
114
+
115
+ const opHits = [];
116
+ for (const candidate of candidates) {
117
+ const matched = candidate.operations.filter((operation) =>
118
+ phraseIn(normalizePhrase(operation), words, text));
119
+ if (matched.length === 1) opHits.push({ capability: candidate.id, operation: matched[0] });
120
+ else if (matched.length > 1) opHits.push({ capability: candidate.id, operation: matched[0], ambiguous: true });
121
+ }
122
+ if (opHits.length === 1 && !opHits[0].ambiguous) {
123
+ const { capability, operation } = opHits[0];
124
+ return { capability, operation };
125
+ }
126
+ return null;
72
127
  }
73
128
 
74
129
  function selectionWithProvider(session, selection, candidates) {
@@ -86,8 +141,9 @@ export function capabilityCandidates(session) {
86
141
  for (const [versionedId, providers] of Object.entries(snapshot)) {
87
142
  const id = versionedId.includes('@') ? versionedId.slice(0, versionedId.lastIndexOf('@')) : versionedId;
88
143
  const operations = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.supportedOperations ?? []))].sort();
144
+ const aliases = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.aliases ?? []))].sort();
89
145
  const description = (providers ?? []).map((provider) => provider?.capability?.description).find(Boolean) ?? '';
90
- byId.set(id, { id, description, operations });
146
+ byId.set(id, { id, description, operations, aliases });
91
147
  }
92
148
  return [...byId.values()].filter((item) => item.operations.length > 0).sort((a, b) => a.id.localeCompare(b.id));
93
149
  }
@@ -1,73 +1,171 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { capabilityCandidates, resolveObjective } from './objectiveResolver.js';
3
+ import {
4
+ capabilityCandidates,
5
+ objectiveForResolution,
6
+ resolveObjective,
7
+ ObjectiveNotOrchestrableError,
8
+ } from './objectiveResolver.js';
4
9
 
5
- function sessionWithSelection(selection) {
6
- const provider = {
7
- agentInstanceId: 'production-1',
8
- serverName: 'production',
9
- capability: {
10
- id: 'knowledge.update',
11
- version: '1',
12
- description: 'Update knowledge from pending sources.',
13
- supportedOperations: ['ingest'],
14
- },
15
- };
10
+ function makeCapability(id, { operations = [], aliases = [], description = '' } = {}) {
11
+ return { id, version: '1', description, supportedOperations: operations, aliases };
12
+ }
13
+
14
+ function provider(agentInstanceId, capability) {
15
+ return { agentInstanceId, serverName: agentInstanceId.split('-')[0], capability };
16
+ }
17
+
18
+ function sessionWith(providers, llmSelection = {}) {
19
+ const snapshot = {};
20
+ for (const entry of providers) {
21
+ const key = `${entry.capability.id}@${entry.capability.version ?? '1'}`;
22
+ (snapshot[key] ??= []).push(entry);
23
+ }
16
24
  return {
17
25
  capabilityRegistry: {
18
- snapshot: () => ({ 'knowledge.update@1': [provider] }),
19
- providersFor: () => [provider],
20
- },
21
- llm: {
22
- completeWithTools: async () => ({ content: JSON.stringify(selection) }),
26
+ snapshot: () => snapshot,
27
+ providersFor: (capability) => Object.entries(snapshot)
28
+ .filter(([key]) => key === capability || key.startsWith(`${capability}@`))
29
+ .flatMap(([, ps]) => ps),
23
30
  },
31
+ llm: { completeWithTools: async () => ({ content: JSON.stringify(llmSelection) }) },
24
32
  };
25
33
  }
26
34
 
27
- test('capabilityCandidates exposes only the closed live registry', () => {
28
- assert.deepEqual(capabilityCandidates(sessionWithSelection({})), [{
35
+ const knowledge = makeCapability('knowledge.update', {
36
+ operations: ['ingest', 'ingest_plan', 'ingest_apply'],
37
+ aliases: ['ingest', 'ingestion'],
38
+ description: 'Update knowledge from pending sources.',
39
+ });
40
+ const cme = makeCapability('external-source.export', {
41
+ operations: ['export'],
42
+ aliases: ['confluence', 'confluence export', 'source export'],
43
+ description: 'Export configured Confluence sources.',
44
+ });
45
+ const publish = makeCapability('document.publish', {
46
+ operations: ['export', 'polish'],
47
+ aliases: ['publish', 'export deliverable'],
48
+ description: 'Export or polish existing deliverables.',
49
+ });
50
+ const diagnose = makeCapability('workspace.diagnose', {
51
+ operations: ['doctor'],
52
+ aliases: ['diagnose', 'diagnostic', 'doctor'],
53
+ description: 'Diagnose workspace configuration.',
54
+ });
55
+ const sendEmail = makeCapability('communication.send-email', {
56
+ operations: ['send'],
57
+ description: 'Send an email.',
58
+ });
59
+
60
+ test('capabilityCandidates exposes aliases from the closed live registry', () => {
61
+ assert.deepEqual(capabilityCandidates(sessionWith([provider('production-1', knowledge)])), [{
29
62
  id: 'knowledge.update',
30
63
  description: 'Update knowledge from pending sources.',
31
- operations: ['ingest'],
64
+ operations: ['ingest', 'ingest_apply', 'ingest_plan'],
65
+ aliases: ['ingest', 'ingestion'],
32
66
  }]);
33
67
  });
34
68
 
35
69
  test('resolveObjective selects and validates one real provider', async () => {
36
- const result = await resolveObjective('Ingère tous les fichiers en attente', sessionWithSelection({
37
- capability: 'knowledge.update',
38
- operation: 'ingest',
39
- }));
70
+ const result = await resolveObjective('Ingère tous les fichiers en attente', sessionWith(
71
+ [provider('production-1', knowledge)],
72
+ { capability: 'knowledge.update', operation: 'ingest' },
73
+ ));
40
74
  assert.equal(result.capability, 'knowledge.update');
41
75
  assert.equal(result.operation, 'ingest');
42
76
  assert.equal(result.provider.agentInstanceId, 'production-1');
43
77
  });
44
78
 
45
- test('resolveObjective uses an unambiguously mentioned registry operation without asking the LLM', async () => {
46
- const session = sessionWithSelection({ capability: 'external-source.export', operation: 'export' });
47
- session.capabilityRegistry.snapshot = () => ({
48
- 'knowledge.update@1': [sessionWithSelection({}).capabilityRegistry.snapshot()['knowledge.update@1'][0]],
49
- 'external-source.export@1': [{
50
- agentInstanceId: 'cme-1',
51
- serverName: 'cme',
52
- capability: { id: 'external-source.export', version: '1', supportedOperations: ['export'] },
53
- }],
54
- });
55
- session.capabilityRegistry.providersFor = (capability) =>
56
- session.capabilityRegistry.snapshot()[`${capability}@1`] ?? [];
79
+ test('resolveObjective disambiguates "export" of a Confluence source via alias, without the LLM', async () => {
80
+ const session = sessionWith([
81
+ provider('production-1', knowledge),
82
+ provider('production-2', publish),
83
+ provider('cme-1', cme),
84
+ ]);
57
85
  session.llm.completeWithTools = async () => {
58
- throw new Error('the explicit operation must not depend on LLM selection');
86
+ throw new Error('the explicit alias must not depend on LLM selection');
59
87
  };
88
+ const result = await resolveObjective('Export the requested Confluence source', session);
89
+ assert.equal(result.capability, 'external-source.export');
90
+ assert.equal(result.operation, 'export');
91
+ assert.equal(result.provider.agentInstanceId, 'cme-1');
92
+ });
60
93
 
61
- const result = await resolveObjective("lance l'ingestion", session);
94
+ test('resolveObjective resolves the ingest step of wiki-sync deterministically despite notification and guardrails', async () => {
95
+ const session = sessionWith([
96
+ provider('production-1', knowledge),
97
+ provider('production-2', publish),
98
+ provider('cme-1', cme),
99
+ provider('connectors-1', sendEmail),
100
+ ]);
101
+ session.llm.completeWithTools = async () => {
102
+ throw new Error('the aliased intention must not depend on LLM selection');
103
+ };
104
+ const objective = 'Ingest the newly exported Markdown into the wiki. Do not build or publish deliverables. If a messaging connector and a notification recipient are available, send a short best-effort summary; otherwise skip notification silently.';
105
+ const result = await resolveObjective(objective, session);
62
106
  assert.equal(result.capability, 'knowledge.update');
63
107
  assert.equal(result.operation, 'ingest');
64
- assert.equal(result.provider.agentInstanceId, 'production-1');
108
+ });
109
+
110
+ test('resolveObjective resolves diagnose via alias despite the notification "send"', async () => {
111
+ const session = sessionWith([
112
+ provider('production-1', diagnose),
113
+ provider('connectors-1', sendEmail),
114
+ ]);
115
+ session.llm.completeWithTools = async () => {
116
+ throw new Error('the alias must resolve without the LLM');
117
+ };
118
+ const objective = 'Run a complete read-only diagnostic. If a messaging connector is available, send a short summary; otherwise skip notification silently.';
119
+ const result = await resolveObjective(objective, session);
120
+ assert.equal(result.capability, 'workspace.diagnose');
121
+ assert.equal(result.operation, 'doctor');
122
+ });
123
+
124
+ test('objectiveForResolution strips notification and negative guardrails', () => {
125
+ const clean = objectiveForResolution(
126
+ 'Ingest files. Do not build or publish deliverables. If a messaging connector is available, send a summary; otherwise skip notification silently.',
127
+ );
128
+ assert.ok(!/\bsend\b/.test(clean), 'notification "send" must be stripped');
129
+ assert.ok(!/\bbuild\b/.test(clean), 'guardrail "build" must be stripped');
130
+ assert.ok(!/\bnotification\b/.test(clean), 'the word "notification" must be stripped');
131
+ assert.match(clean, /Ingest files/);
132
+ });
133
+
134
+ test('resolveObjective never binds the generic word "plan" to ingest_plan', async () => {
135
+ const session = sessionWith(
136
+ [provider('production-1', knowledge)],
137
+ { capability: null, reason: 'no capability' },
138
+ );
139
+ await assert.rejects(
140
+ resolveObjective('Preserve the delivery capability internal execution plan', session),
141
+ (err) => {
142
+ assert.equal(err.name, 'ObjectiveNotOrchestrableError');
143
+ return true;
144
+ },
145
+ );
146
+ });
147
+
148
+ test('resolveObjective never binds the past participle "exported" to an export operation', async () => {
149
+ const session = sessionWith([
150
+ provider('production-1', publish),
151
+ provider('cme-1', cme),
152
+ ]);
153
+ session.llm.completeWithTools = async () => ({ content: JSON.stringify({ capability: null, reason: 'no capability' }) });
154
+ // "newly exported Markdown" describes state, not the action to run.
155
+ await assert.rejects(
156
+ resolveObjective('Review the newly exported Markdown', session),
157
+ (err) => {
158
+ assert.equal(err.name, 'ObjectiveNotOrchestrableError');
159
+ return true;
160
+ },
161
+ );
65
162
  });
66
163
 
67
164
  test('resolveObjective declines an objective no listed capability covers', async () => {
68
- // Forcing a pick is how "cree l'auth pour le gmail" became a doctor run on
69
- // the production agent. Declining must be reported as such, not as a defect.
70
- const session = sessionWithSelection({ capability: null, reason: 'Gmail authorization is not an orchestrable capability.' });
165
+ const session = sessionWith(
166
+ [provider('production-1', knowledge)],
167
+ { capability: null, reason: 'Gmail authorization is not an orchestrable capability.' },
168
+ );
71
169
  await assert.rejects(
72
170
  resolveObjective("cree l'auth pour le gmail", session),
73
171
  (err) => {
@@ -82,7 +180,7 @@ test('resolveObjective declines an objective no listed capability covers', async
82
180
 
83
181
  test('resolveObjective treats a malformed selection as a resolution defect, not a decline', async () => {
84
182
  await assert.rejects(
85
- resolveObjective('Traite tout', sessionWithSelection({ reason: 'missing capability' })),
183
+ resolveObjective('Traite tout', sessionWith([provider('production-1', knowledge)], { reason: 'missing capability' })),
86
184
  (err) => {
87
185
  assert.notEqual(err.name, 'ObjectiveNotOrchestrableError');
88
186
  assert.match(err.message, /unknown capability/);
@@ -93,7 +191,7 @@ test('resolveObjective treats a malformed selection as a resolution defect, not
93
191
 
94
192
  test('resolveObjective rejects invented capability and operation', async () => {
95
193
  await assert.rejects(
96
- resolveObjective('Traite tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
194
+ resolveObjective('Traite tout', sessionWith([provider('production-1', knowledge)], { capability: 'ingest', operation: 'ingest_all_pending' })),
97
195
  /unknown capability "ingest"/,
98
196
  );
99
197
  });
@@ -245,6 +245,82 @@ function task(id, overrides = {}) {
245
245
  };
246
246
  }
247
247
 
248
+ /*
249
+ Cas observé le 2026-08-22 (workspace acpi) : `/wiki-ingest` planifie 13
250
+ ingest_plan (groupe `ingest`) + 13 ingest_apply (groupe `apply`, sérialisés
251
+ sur le lock `workspace-write`, derrière la barrière `ingest`) + 1 taxonomy
252
+ (barrière `apply`). Le grant run-scope émis par le bouton Approve est « nu » :
253
+ `approvalClasses: []`, `planRevision: null`. Après la fin des 13 ingest_plan,
254
+ le scheduler a déclaré `no_ready_plan_task` au lieu de démarrer les apply.
255
+
256
+ Ce test verrouille la couverture du grant nu : les apply `waiting_approval`
257
+ DOIVENT redevenir ready quand le grant run-scope (même sans classes ni
258
+ révision) les couvre.
259
+ */
260
+ test('un grant run-scope « nu » (sans classes ni révision) débloque les apply derrière une barrière', () => {
261
+ const plan = {
262
+ runId: 'run-1',
263
+ workspace: 'acpi',
264
+ planRevision: 1,
265
+ tasks: [
266
+ // 13 ingest_plan du groupe ingest, tous done.
267
+ ...Array.from({ length: 13 }, (_, i) => task(`ingest-plan-${i}`, {
268
+ groupId: 'ingest',
269
+ status: 'done',
270
+ requiredCapability: 'knowledge.update',
271
+ operation: 'ingest_plan',
272
+ })),
273
+ // 13 apply : groupe apply, barrière ingest, lock workspace-write.
274
+ ...Array.from({ length: 13 }, (_, i) => task(`ingest-apply-${i}`, {
275
+ groupId: 'apply',
276
+ dependsOnGroup: 'ingest',
277
+ barrier: true,
278
+ dependsOn: [`ingest-plan-${i}`],
279
+ status: 'waiting_approval',
280
+ requiredCapability: 'knowledge.update',
281
+ operation: 'ingest_apply',
282
+ locks: ['workspace-write'],
283
+ parallelizable: false,
284
+ requiresApproval: true,
285
+ approvalClass: 'mutation',
286
+ priority: i + 1,
287
+ })),
288
+ task('taxonomy', {
289
+ dependsOnGroup: 'apply',
290
+ barrier: true,
291
+ status: 'waiting_approval',
292
+ requiredCapability: 'knowledge.update',
293
+ operation: 'taxonomy',
294
+ requiresApproval: true,
295
+ approvalClass: 'mutation',
296
+ }),
297
+ ],
298
+ };
299
+
300
+ // Sans grant : rien n'est ready.
301
+ assert.deepEqual(readyTasks(plan).map((item) => item.id), []);
302
+
303
+ // Grant run-scope « nu » — exactement ce que le bouton Approve émet.
304
+ // La couverture ne bloque pas : les 13 apply sont tous prêts.
305
+ const ready = readyTasks(plan, {
306
+ approvals: [{
307
+ status: 'approved',
308
+ scope: 'run',
309
+ runId: 'run-1',
310
+ workspaceId: 'acpi',
311
+ planRevision: null,
312
+ approvalClasses: [],
313
+ }],
314
+ });
315
+
316
+ assert.deepEqual(ready.map((item) => item.id), [
317
+ 'ingest-apply-0', 'ingest-apply-1', 'ingest-apply-2', 'ingest-apply-3',
318
+ 'ingest-apply-4', 'ingest-apply-5', 'ingest-apply-6', 'ingest-apply-7',
319
+ 'ingest-apply-8', 'ingest-apply-9', 'ingest-apply-10', 'ingest-apply-11',
320
+ 'ingest-apply-12',
321
+ ]);
322
+ });
323
+
248
324
  /*
249
325
  Cas observé le 2026-08-04 (workspace juno) : une ingestion de dix fichiers,
250
326
  neuf réussis, le dixième en échec sur du JSON malformé. La barrière de groupe