@dotdrelle/wiki-manager 0.15.96 → 0.15.97

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.96",
3
+ "version": "0.15.97",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -436,6 +436,19 @@ export function bareToolCallJson(content, tools = []) {
436
436
  return hasArguments ? name : null;
437
437
  }
438
438
 
439
+ /**
440
+ * A model that has NO tool to call sometimes writes the call as text:
441
+ * `runtime__delegate{"objective":"…"}`. Unlike `bareToolCallJson` it is not
442
+ * JSON and cannot be validated against the offered set (there is none), but the
443
+ * `<namespace>__<tool>{` shape is never legitimate prose. Observed after a
444
+ * terminal failure stripped the tools from the synthesis turn: the raw call
445
+ * reached the user and nothing ran.
446
+ */
447
+ export function narratedToolCallText(content) {
448
+ const match = String(content ?? '').trim().match(/^([a-z][a-z0-9_-]*__[a-z][a-z0-9_-]*)\s*\{/i);
449
+ return match ? match[1] : null;
450
+ }
451
+
439
452
  function parseActionJson(text) {
440
453
  const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
441
454
  if (!cleaned) return null;
@@ -979,7 +992,7 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
979
992
  }
980
993
  if (tool === 'run_skill') {
981
994
  const skillName = String(args.skillName ?? '').trim();
982
- if (!skillName) return JSON.stringify({ ok: false, terminal: true, code: 'skill_not_found', availableSkills: [] });
995
+ if (!skillName) return JSON.stringify({ ok: false, terminal: false, code: 'skill_not_found', message: 'skillName is required: pass the exact skill name, or delegate the objective with runtime__delegate.', availableSkills: [] });
983
996
  const selectedSkill = findSkill(session, skillName);
984
997
  const suppliedArguments = args.arguments && typeof args.arguments === 'object' && !Array.isArray(args.arguments)
985
998
  ? args.arguments
@@ -1696,10 +1709,14 @@ export function createAgentGraph(options = {}) {
1696
1709
  // text really is a call to one of them: a legitimate answer that happens
1697
1710
  // to contain JSON (a config excerpt, an API sample) must go through
1698
1711
  // untouched, which is why this is not a "content starts with {" test.
1699
- const bareCall = tools.length > 0 ? bareToolCallJson(result.content, tools) : null;
1712
+ const bareCall = (tools.length > 0 ? bareToolCallJson(result.content, tools) : null)
1713
+ ?? narratedToolCallText(result.content);
1700
1714
  if (bareCall) {
1701
1715
  const retries = Number(state.invalidToolCallRetries ?? 0);
1702
- if (retries < 2) {
1716
+ // Retry only when a tool can still be called; when none are offered (the
1717
+ // synthesis turn after a terminal failure) the call is unexecutable and
1718
+ // the honest failure below is the whole answer.
1719
+ if (tools.length > 0 && retries < 2) {
1703
1720
  state.session._onStreamReset?.();
1704
1721
  state.session._onStep?.('Agent: tool call written as JSON text rejected; retrying…');
1705
1722
  return {
@@ -2014,6 +2031,11 @@ export function createAgentGraph(options = {}) {
2014
2031
  ),
2015
2032
  objectives: Number(skillResult.objectiveCount ?? skillResult.objectives ?? 1) || 1,
2016
2033
  };
2034
+ } else if (skillResult?.ok === false) {
2035
+ // A recoverable refusal (guessed skill, missing input): keep the
2036
+ // turn alive so the model can correct itself or delegate, but do
2037
+ // not let the progress note call it a success.
2038
+ ok = false;
2017
2039
  }
2018
2040
  }
2019
2041
  if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
@@ -626,7 +626,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
626
626
  const result = await createAgentGraph().invoke({ input: 'lance le skill deliver avec le template Quarterly report', session });
627
627
  // Launching a skill ends the turn: the acknowledgement is generated once and
628
628
  // the model is not given a second chance to re-delegate or contradict it.
629
- assert.equal(result.response, 'Started /deliver deliverable="Quarterly report" — 1 step(s) in progress.');
629
+ assert.equal(result.response, 'Started /deliver deliverable="Quarterly report" — 1 step(s) queued.');
630
630
  assert.equal(mainCalls, 1);
631
631
  // `skillStack` accompagne désormais la demande : le run imbriqué démarre après
632
632
  // le nettoyage de celui-ci, et c'est le seul canal par lequel il peut savoir
@@ -709,6 +709,73 @@ test('a terminal skill refusal stops the whole turn before a delegate fallback',
709
709
  assert.equal(delegated, false);
710
710
  });
711
711
 
712
+ test('a narrated tool call on the tool-less synthesis turn is never shown, and nothing runs', async () => {
713
+ // After a terminal failure the synthesis turn offers no tools; a model that
714
+ // still wants to act writes the call as text (`runtime__delegate{"…"}`) and
715
+ // the turn did nothing. That raw call must never reach the user.
716
+ let delegated = false;
717
+ const narrated = 'runtime__delegate{"objective":"nettoyer le wiki"}';
718
+ const session = sessionBase({
719
+ runtime: { url: 'http://runtime.test' },
720
+ _runSkillWithinRun: async () => ({ ok: false, terminal: true, code: 'skill_not_found', availableSkills: [] }),
721
+ _delegateWithinRun: async () => { delegated = true; return { runId: 'bad' }; },
722
+ llm: {
723
+ async completeWithTools({ tools }) {
724
+ if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
725
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
726
+ }
727
+ if (tools.length > 0) {
728
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [
729
+ { id: 'missing', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"missing","selectionKind":"explicit_name"}' } },
730
+ ] };
731
+ }
732
+ return { content: narrated, message: { role: 'assistant', content: narrated }, tool_calls: null };
733
+ },
734
+ },
735
+ });
736
+ const result = await createAgentGraph().invoke({ input: 'nettoie le wiki', session });
737
+ assert.equal(delegated, false);
738
+ assert.match(result.response, /printed an internal tool request/);
739
+ assert.doesNotMatch(result.response, /runtime__delegate/);
740
+ });
741
+
742
+ test('a recoverable skill refusal lets the delegate fallback run in the same turn', async () => {
743
+ // The observed defect: the model guessed `/diagnose`, the skill runner
744
+ // answered skill_not_found, and because that was terminal the tools were
745
+ // stripped from the next turn — the model then wrote
746
+ // `runtime__delegate{...}` as plain text and the turn did nothing. A guessed
747
+ // skill must be recoverable so the fallback can actually run.
748
+ let delegated = false;
749
+ const session = sessionBase({
750
+ runtime: { url: 'http://runtime.test' },
751
+ _runSkillWithinRun: async () => ({
752
+ ok: false,
753
+ terminal: false,
754
+ code: 'skill_not_found',
755
+ message: 'No skill named "/diagnose". Pass the exact name without a leading slash, or delegate the objective with runtime__delegate.',
756
+ availableSkills: ['diagnose'],
757
+ }),
758
+ _delegateWithinRun: async () => { delegated = true; return { runId: 'run-1', summary: { tasks: 1, agent: 'gateway' } }; },
759
+ llm: {
760
+ async completeWithTools({ tools }) {
761
+ if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
762
+ return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
763
+ }
764
+ return {
765
+ content: null, message: { role: 'assistant', content: null },
766
+ tool_calls: [
767
+ { id: 'guess', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"/diagnose","arguments":{}}' } },
768
+ { id: 'fallback', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"nettoyer le wiki, corriger les doublons et les affirmations non sourcées"}' } },
769
+ ],
770
+ };
771
+ },
772
+ },
773
+ });
774
+ const result = await createAgentGraph().invoke({ input: 'nettoie le wiki, corrige les doublons et les affirmations non sourcées', session });
775
+ assert.equal(delegated, true);
776
+ assert.notEqual(result.terminalToolFailure, true);
777
+ });
778
+
712
779
  test('tool argument normalization repairs only an unambiguous schema-compatible field name', () => {
713
780
  const schema = {
714
781
  type: 'object',
@@ -6,7 +6,7 @@ import { formatRuntimeLogPayload, isDispatchPlumbingLine, normalizeRuntimeLog, s
6
6
  import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
7
7
  import { projectWorkflow } from './workflow.js';
8
8
  import { validateContractInDev } from '../contracts/schemas.js';
9
- import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
9
+ import { isActive, isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
10
10
 
11
11
  const SESSION_PROJECTION_EVENTS = new Set([
12
12
  'run_started',
@@ -292,6 +292,11 @@ export function applyAgentProjectionToSession(session, projection) {
292
292
  } : session.productionActivity ?? null;
293
293
  }
294
294
 
295
+ function hasRunningPlanStep(state) {
296
+ return (Array.isArray(state.plan) ? state.plan : [])
297
+ .some((step) => isActive(step?.status));
298
+ }
299
+
295
300
  function applyEvent(state, event) {
296
301
  switch (event.type) {
297
302
  case 'run_started':
@@ -598,6 +603,13 @@ function applyEvent(state, event) {
598
603
  reason: event.payload?.reason ?? null,
599
604
  createdAt: event.ts,
600
605
  });
606
+ // A run waiting for a human is not "running": showing it as running is
607
+ // how the chat could claim a rebuild was executing before anyone
608
+ // approved it. Mirror `run_pending_approval` (AGENTS.md: the run status
609
+ // is pending_approval while the decision is outstanding), but only when
610
+ // no task is actually executing — a parallel run may have work in flight
611
+ // while one branch waits.
612
+ if (!hasRunningPlanStep(state)) state.status = 'pending_approval';
601
613
  return;
602
614
  case 'approval.granted': {
603
615
  const grant = {
@@ -617,6 +629,13 @@ function applyEvent(state, event) {
617
629
  };
618
630
  upsertApproval(state, grant);
619
631
  markCoveredApprovalsApproved(state.approvals, grant, event.ts);
632
+ // The decision is in: the run goes back to running unless another
633
+ // approval is still outstanding (a run-scoped grant clears its covered
634
+ // ones, markCoveredApprovalsApproved above).
635
+ if (state.status === 'pending_approval'
636
+ && !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
637
+ state.status = 'running';
638
+ }
620
639
  return;
621
640
  }
622
641
  case 'approval.rejected':
@@ -127,6 +127,40 @@ test('reduceAgentEvents: interactive (user) run_started clears state but is not
127
127
  assert.notEqual(projection.status, 'running');
128
128
  });
129
129
 
130
+ test('reduceAgentEvents: a run blocked on approval is not shown as running', () => {
131
+ // The chat claimed a rebuild was executing while its only task was still
132
+ // waiting for a human: the run status stayed 'running' through the per-task
133
+ // approval request. It must mirror run_pending_approval.
134
+ const projection = reduceAgentEvents([
135
+ createAgentEvent('run_started', { origin: 'runtime' }),
136
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
137
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
138
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
139
+ ]);
140
+ assert.equal(projection.status, 'pending_approval');
141
+ });
142
+
143
+ test('reduceAgentEvents: granting the approval puts the run back to running', () => {
144
+ const projection = reduceAgentEvents([
145
+ createAgentEvent('run_started', { origin: 'runtime' }),
146
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
147
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
148
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
149
+ createAgentEvent('approval.granted', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
150
+ ]);
151
+ assert.equal(projection.status, 'running');
152
+ });
153
+
154
+ test('reduceAgentEvents: an approval request does not hide a genuinely running task', () => {
155
+ const projection = reduceAgentEvents([
156
+ createAgentEvent('run_started', { origin: 'runtime' }),
157
+ createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Export', 'Build'] } }),
158
+ createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'running' } }),
159
+ createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't2' } }),
160
+ ]);
161
+ assert.equal(projection.status, 'running');
162
+ });
163
+
130
164
  test('reduceAgentEvents: tracks manual plan and step updates', () => {
131
165
  const projection = reduceAgentEvents([
132
166
  createAgentEvent('plan_set', {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.96",
3
- "commit": "fbeddf2"
2
+ "version": "0.15.97",
3
+ "commit": "b90e735"
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.96';
4
+ const WIKI_MANAGER_VERSION = '0.15.97';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -7,6 +7,7 @@ import { validateContractInDev } from '../contracts/schemas.js';
7
7
  import { runtimeTokenFromEnv } from './auth.js';
8
8
  import { controlMessage } from './controlMessages.js';
9
9
  import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
10
+ import { isActive, isCancelled, isFailed, isSuccessful } from '../orchestrator/taskStatuses.js';
10
11
  import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
11
12
  import { RUNTIME_SHUTDOWN_ABORT_REASON } from '../orchestrator/dispatcher.js';
12
13
  import { matchSkillInvocation } from '../core/skillInvocation.js';
@@ -502,7 +503,7 @@ export function startRuntimeServer({
502
503
  }
503
504
  if (request.method === 'POST' && url.pathname === '/turn') {
504
505
  const { body, context } = await resolveBodyContext(request, url);
505
- const input = String(body.input ?? body.prompt ?? '').trim();
506
+ let input = String(body.input ?? body.prompt ?? '').trim();
506
507
  if (!input) {
507
508
  sendJson(response, 400, { error: 'Missing input.' });
508
509
  return;
@@ -529,19 +530,15 @@ export function startRuntimeServer({
529
530
  }
530
531
  return;
531
532
  }
532
- // A run/job status question is answered by the runtime itself, whatever
533
- // the mode and whether or not a run is active. Left to the model it
534
- // confused the runtime runId with a production job id ("job not
535
- // found"); in chat mode it had no runtime status tool at all.
533
+ // A run/job status question must NEVER surface the raw system text in
534
+ // the thread: the runtime collects the facts and hands them to Donna,
535
+ // who synthesizes them in the session language. `readOnlyChat` so the
536
+ // turn is a conversation, not a control decision — and the facts are
537
+ // supplied here so the model cannot mistake the runtime run id for a
538
+ // production job id ("job not found").
536
539
  if (asksForRunStatus(input)) {
537
- const status = controlStatus(context, store);
538
- sendJson(response, 200, {
539
- accepted: true,
540
- kind: 'observe',
541
- ...status,
542
- explanation: explainControlState(status),
543
- });
544
- return;
540
+ input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
541
+ readOnlyChat = true;
545
542
  }
546
543
  if (context.running && !readOnlyChat) {
547
544
  // Agent-mode message while a run is active. Classify once: control
@@ -552,7 +549,13 @@ export function startRuntimeServer({
552
549
  llm: context?.session?.llm,
553
550
  session: context?.session,
554
551
  });
555
- if (classification.kind !== 'converse') {
552
+ if (classification.kind === 'observe') {
553
+ // An observation is system facts, not a control action: Donna gets
554
+ // them and answers in the session language, never a raw English
555
+ // line pushed into the thread.
556
+ input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
557
+ readOnlyChat = true;
558
+ } else if (classification.kind !== 'converse') {
556
559
  const result = await handleControlMessage(context, store, input, {
557
560
  intent: body.intent,
558
561
  startNextControlRequest,
@@ -561,8 +564,9 @@ export function startRuntimeServer({
561
564
  });
562
565
  sendJson(response, result.statusCode, result.body);
563
566
  return;
567
+ } else {
568
+ readOnlyChat = true;
564
569
  }
565
- readOnlyChat = true;
566
570
  }
567
571
  if (typeof turn !== 'function') {
568
572
  sendJson(response, 501, { error: 'Runtime interactive turns are unavailable.' });
@@ -580,7 +584,10 @@ export function startRuntimeServer({
580
584
  llm: context?.session?.llm,
581
585
  session: context?.session,
582
586
  });
583
- if (classification.kind !== 'converse') {
587
+ if (classification.kind === 'observe') {
588
+ input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
589
+ readOnlyChat = true;
590
+ } else if (classification.kind !== 'converse') {
584
591
  const result = await handleControlMessage(context, store, input, {
585
592
  intent: body.intent,
586
593
  startNextControlRequest,
@@ -867,12 +874,24 @@ export function startRuntimeServer({
867
874
  if (context?.session) {
868
875
  context.session._runSkillWithinRun = async (skillName, args = {}, metadata = {}) => {
869
876
  const skill = findSkill(context.session, skillName);
870
- if (!skill) return {
871
- ok: false,
872
- terminal: true,
873
- code: 'skill_not_found',
874
- availableSkills: listSkills(context.session).map((item) => item.name),
875
- };
877
+ if (!skill) {
878
+ const available = listSkills(context.session).map((item) => item.name);
879
+ /*
880
+ A skill the model GUESSED is recoverable, not terminal: the observed
881
+ failure was `/diagnose` (leading slash copied from the catalogue) →
882
+ skill_not_found → the terminal path stripped the tools from the
883
+ synthesis turn → the model wrote `runtime__delegate{...}` as plain
884
+ text and the turn did nothing. An explicitly user-named missing skill
885
+ stays terminal: there is nothing to fall back to.
886
+ */
887
+ return {
888
+ ok: false,
889
+ terminal: metadata.selectionKind === 'explicit_name',
890
+ code: 'skill_not_found',
891
+ message: `No skill named "${skillName}". Pass the exact name without a leading slash (${available.join(', ') || 'none'}), or delegate the objective with runtime__delegate.`,
892
+ availableSkills: available,
893
+ };
894
+ }
876
895
  try {
877
896
  const idempotencyKey = metadata.idempotencyKey
878
897
  ? String(metadata.idempotencyKey)
@@ -1161,22 +1180,109 @@ export function runtimeState(context, store, { workspace = null, session = null
1161
1180
  // own history) so those replies surface. The log is a superset of the
1162
1181
  // canonical run conversation, so run rendering is unaffected.
1163
1182
  conversation: reduceAgentEvents(store.listEvents({ workspace })).conversation,
1164
- status: context?.running ? 'running' : state.status ?? 'idle',
1183
+ // `context.running` keeps the process alive while the scheduler waits for an
1184
+ // approval, but the run is then NOT running — the reducer already says
1185
+ // `pending_approval`. Prefer it over the blanket override.
1186
+ status: context?.running
1187
+ ? (state.status === 'pending_approval' ? 'pending_approval' : 'running')
1188
+ : state.status ?? 'idle',
1165
1189
  running: Boolean(context?.running),
1166
1190
  runId: context?.currentRunId ?? state.runId ?? null,
1167
1191
  workspace: context?.currentRunWorkspace ?? context?.workspace ?? state.workspace ?? workspace ?? null,
1168
1192
  };
1169
1193
  }
1170
1194
 
1195
+ // The live figures of the activity the run is on, in one line: percent, plan
1196
+ // step, build batch, instruction count and the stabilize counters. A status
1197
+ // that only named the current step could not tell 5% from 95%, nor what the
1198
+ // running batch had actually done.
1199
+ function describeActivityProgress(activity) {
1200
+ const progress = activity?.progress ?? {};
1201
+ const bits = [];
1202
+ if (Number.isFinite(Number(progress.percent))) bits.push(`${Number(progress.percent)}%`);
1203
+ if (progress.stepIndex != null && progress.stepTotal != null) bits.push(`step ${progress.stepIndex}/${progress.stepTotal}`);
1204
+ if (progress.batchIndex != null && progress.batchCount != null) bits.push(`batch ${Number(progress.batchIndex) + 1}/${progress.batchCount}`);
1205
+ if (progress.instructionCount != null) bits.push(`${progress.instructionCount} instruction${Number(progress.instructionCount) > 1 ? 's' : ''}`);
1206
+ const stabilize = [progress.stabilizeKept, progress.stabilizeMerged, progress.stabilizeInserted, progress.stabilizeRemoved];
1207
+ if (stabilize.some((value) => value != null)) {
1208
+ bits.push(`kept ${progress.stabilizeKept ?? 0}, merged ${progress.stabilizeMerged ?? 0}, inserted ${progress.stabilizeInserted ?? 0}, removed ${progress.stabilizeRemoved ?? 0}`);
1209
+ }
1210
+ const detail = progress.detail && !bits.includes(String(progress.detail)) ? String(progress.detail) : null;
1211
+ return { bits: bits.join(' · '), detail, label: activity?.label ?? null };
1212
+ }
1213
+
1214
+ // The facts a status answer is built from, rendered server-side ONCE. A status
1215
+ // question is answered by Donna, never by pushing this text into the thread:
1216
+ // the runtime supplies the figures, she phrases them in the session language.
1217
+ function runtimeStatusFacts(status) {
1218
+ const plan = Array.isArray(status.plan) ? status.plan : [];
1219
+ const approvals = Array.isArray(status.approvals) ? status.approvals : [];
1220
+ const queue = Array.isArray(status.controlQueue) ? status.controlQueue : [];
1221
+ const activities = Array.isArray(status.activities)
1222
+ ? status.activities
1223
+ : Object.values(status.activities ?? {});
1224
+ const lines = [
1225
+ `Runtime status: ${status.status ?? 'idle'}`,
1226
+ `Workspace: ${status.workspace ?? '-'}`,
1227
+ ];
1228
+ if (status.runId) lines.push(`Run id: ${status.runId}`);
1229
+ for (const activity of activities.filter((entry) => !entry?.terminal).slice(0, 8)) {
1230
+ const info = describeActivityProgress(activity);
1231
+ const detail = [info.bits, info.detail].filter(Boolean).join(' · ');
1232
+ lines.push(
1233
+ `Activity: ${info.label ?? activity.label ?? activity.id ?? '-'} — ${activity.status ?? '-'}${detail ? ` (${detail})` : ''}`,
1234
+ );
1235
+ }
1236
+ for (const [index, step] of plan.slice(0, 60).entries()) {
1237
+ lines.push(`Task ${step.step ?? index + 1}: ${step.status ?? 'pending'} - ${step.description ?? step.label ?? step.id ?? 'step'}`);
1238
+ }
1239
+ for (const approval of approvals.filter((entry) => entry.status === 'pending_approval')) {
1240
+ lines.push(`Pending approval: ${approval.reason ?? approval.taskId ?? approval.id ?? '-'}`);
1241
+ }
1242
+ for (const item of queue.filter((entry) => entry.status === 'queued')) {
1243
+ lines.push(`Queued: ${item.label ?? item.input ?? item.id ?? '-'}`);
1244
+ }
1245
+ return lines.join('\n');
1246
+ }
1247
+
1248
+ function runtimeStatusSynthesisPrompt(asked, status) {
1249
+ return [
1250
+ 'The runtime facts below are the authoritative status the system just collected (this is a runtime run, not a production job — do not look up a job id).',
1251
+ `User question: ${asked}`,
1252
+ 'Answer in the session language with a concise, natural status: name the requested target first, then progress, blockers (pending approvals), queued items and the next step.',
1253
+ 'Keep every figure (percent, step, batch, instruction and stabilize counts) and every task status accurate; never invent, drop or round away a figure. Do not paste the fact block verbatim; summarize it into prose.',
1254
+ '',
1255
+ 'Runtime facts:',
1256
+ runtimeStatusFacts(status),
1257
+ ].join('\n');
1258
+ }
1259
+
1171
1260
  function explainControlState(status) {
1172
1261
  const plan = Array.isArray(status.plan) ? status.plan : [];
1262
+ const activities = Array.isArray(status.activities)
1263
+ ? status.activities
1264
+ : Object.values(status.activities ?? {});
1265
+ // A run whose only outstanding work is a human decision is not "running":
1266
+ // `status.running` mirrors the process, which stays alive while the scheduler
1267
+ // waits. Mirrors the reducer's rule — pending_approval, or a pending approval
1268
+ // with no step actually executing.
1269
+ const approvals = Array.isArray(status.approvals) ? status.approvals : [];
1270
+ const pendingApproval = approvals.find((approval) => approval.status === 'pending_approval');
1271
+ const awaitingApproval = pendingApproval
1272
+ && (status.status === 'pending_approval' || !plan.some((step) => isActive(step?.status)));
1273
+ if (awaitingApproval) {
1274
+ return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
1275
+ }
1173
1276
  if (status.running) {
1174
1277
  const runningStep = plan.find((step) => step.status === 'running');
1278
+ const activity = activities.find((entry) => !entry?.terminal) ?? activities[0] ?? null;
1279
+ const info = activity ? describeActivityProgress(activity) : { bits: '', detail: null, label: null };
1280
+ const detailText = [info.bits, info.detail].filter(Boolean).join(' · ');
1281
+ const suffix = detailText ? ` (${detailText})` : '';
1175
1282
  return runningStep
1176
- ? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.`
1177
- : 'Runtime run is active. No current plan step is available yet.';
1283
+ ? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.${suffix}`
1284
+ : `Runtime run is active. No current plan step is available yet.${suffix}`;
1178
1285
  }
1179
- const pendingApproval = status.approvals.find((approval) => approval.status === 'pending_approval');
1180
1286
  if (pendingApproval) {
1181
1287
  return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
1182
1288
  }
@@ -1187,6 +1293,15 @@ function explainControlState(status) {
1187
1293
  if (plan.some((step) => step.status === 'pending')) {
1188
1294
  return 'Runtime is idle with pending plan steps visible from the last run.';
1189
1295
  }
1296
+ // Idle at the end of a run: say what the last run did, not just "idle" — that
1297
+ // is the question the operator actually asks when the thread goes quiet.
1298
+ const failed = plan.filter((step) => isFailed(step.status) || isCancelled(step.status)).length;
1299
+ const done = plan.filter((step) => isSuccessful(step.status)).length;
1300
+ if (plan.length > 0) {
1301
+ return failed > 0
1302
+ ? `Runtime is idle. Last run: ${done}/${plan.length} task(s) succeeded, ${failed} failed or cancelled.`
1303
+ : `Runtime is idle. Last run: ${done}/${plan.length} task(s) succeeded.`;
1304
+ }
1190
1305
  return 'Runtime is idle.';
1191
1306
  }
1192
1307
 
@@ -1646,13 +1761,22 @@ function rejectPlanPatch(context, store, patchId, reason) {
1646
1761
  };
1647
1762
  }
1648
1763
 
1649
- // A question about the run/job currently executing. Deliberately narrow — a
1650
- // status word AND a run/job noun — so it never hijacks an ordinary "explain how
1651
- // X works" question. Such a question must be answered by the runtime itself:
1652
- // left to the model, a runtime runId was mistaken for a production job id and
1653
- // reported as "not found", and a read-only chat turn had no runtime status tool.
1764
+ // A question about the run/job currently executing. The free-text form is
1765
+ // deliberately narrow — a status word AND a run/job noun — so it never hijacks
1766
+ // an ordinary "explain how X works" question. Such a question must be answered
1767
+ // by the runtime itself: left to the model, a runtime runId was mistaken for a
1768
+ // production job id and reported as "not found", and a read-only chat turn had
1769
+ // no runtime status tool.
1770
+ //
1771
+ // The reserved built-in `/status` is ALWAYS a runtime status, in every surface:
1772
+ // `RESERVED_SLASH_COMMANDS` keeps the homonymous workspace skill out of
1773
+ // `matchSkillInvocation`, but without this branch `/turn` still handed the
1774
+ // literal command to the model, which ran the skill (English "status" output) or
1775
+ // an unrelated review instead of reporting anything. Serve types `/status` into
1776
+ // this endpoint; the ShellUI answers it locally.
1654
1777
  function asksForRunStatus(input) {
1655
- const text = String(input ?? '');
1778
+ const text = String(input ?? '').trim();
1779
+ if (/^\/status(?:\s|$)/i.test(text)) return true;
1656
1780
  const statusWord = /\b(status|statut|progression|progress|avancement|o[uù] en est|o[uù] en sont)\b/i;
1657
1781
  const runNoun = /\b(job|run|t[aâ]che|task|build|ingest|pipeline|export|polish|traitement)\b/i;
1658
1782
  return statusWord.test(text) && runNoun.test(text);
@@ -1733,7 +1733,7 @@ test('POST /turn keeps informational skill and build questions conversational',
1733
1733
  }
1734
1734
  });
1735
1735
 
1736
- test('POST /turn answers a run status question from the runtime instead of the model', async (t) => {
1736
+ test('POST /turn hands a run status question to Donna with the runtime facts', async (t) => {
1737
1737
  const session = { workspace: 'acme', controlQueue: [] };
1738
1738
  const context = { workspace: 'acme', session, running: true, currentAbortController: null };
1739
1739
  const status = {
@@ -1746,6 +1746,8 @@ test('POST /turn answers a run status question from the runtime instead of the m
1746
1746
  conversation: [],
1747
1747
  };
1748
1748
  let turns = 0;
1749
+ let turnInput = '';
1750
+ let turnMode = null;
1749
1751
  let handle;
1750
1752
  try {
1751
1753
  handle = await startRuntimeServer({
@@ -1753,24 +1755,84 @@ test('POST /turn answers a run status question from the runtime instead of the m
1753
1755
  store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1754
1756
  getContext: async () => context,
1755
1757
  run: async () => new Promise(() => {}),
1756
- turn: async () => { turns += 1; return { ok: true }; },
1758
+ turn: async (_context, options) => { turns += 1; turnInput = options.input; turnMode = options.mode; return { ok: true }; },
1757
1759
  });
1758
1760
  } catch (err) {
1759
1761
  if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1760
1762
  throw err;
1761
1763
  }
1762
1764
  try {
1763
- // The model once mistook the runtime runId for a production job id and
1764
- // answered "job not found". The runtime answers its own status.
1765
+ // System facts never reach the thread as raw text: the runtime supplies
1766
+ // them to Donna, who synthesizes the answer. The facts also prevent the
1767
+ // model mistaking the runtime runId for a production job id.
1765
1768
  const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1766
1769
  method: 'POST', headers: { 'content-type': 'application/json' },
1767
1770
  body: JSON.stringify({ input: 'donne le status du job en cours', mode: 'agent' }),
1768
1771
  });
1769
1772
  const body = await response.json();
1770
- assert.equal(response.status, 200);
1771
- assert.equal(body.kind, 'observe');
1772
- assert.match(body.explanation, /Build TechSections/);
1773
- assert.equal(turns, 0);
1773
+ assert.equal(response.status, 202);
1774
+ assert.equal(body.kind, 'turn');
1775
+ // The turn is dispatched asynchronously after the 202.
1776
+ await new Promise((resolve) => setTimeout(resolve, 25));
1777
+ assert.equal(turns, 1);
1778
+ assert.equal(turnMode, 'chat');
1779
+ assert.match(turnInput, /Build TechSections/);
1780
+ assert.match(turnInput, /runtime run, not a production job/i);
1781
+ } finally {
1782
+ context.currentAbortController?.abort();
1783
+ await handle.close();
1784
+ }
1785
+ });
1786
+
1787
+ test('a run blocked on approval is described as waiting, not as running', async (t) => {
1788
+ const session = { workspace: 'acme', controlQueue: [] };
1789
+ const context = { workspace: 'acme', session, running: true, currentAbortController: null };
1790
+ const status = {
1791
+ status: 'pending_approval',
1792
+ running: true,
1793
+ plan: [{ step: 1, description: 'Rebuild the concepts', status: 'pending_approval' }],
1794
+ queue: [],
1795
+ controlQueue: [],
1796
+ approvals: [{ id: 'a1', status: 'pending_approval', reason: 'a mutating task needs approval' }],
1797
+ conversation: [],
1798
+ };
1799
+ let turns = 0;
1800
+ let turnInput = '';
1801
+ let handle;
1802
+ try {
1803
+ handle = await startRuntimeServer({
1804
+ host: '127.0.0.1', port: 0,
1805
+ store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1806
+ getContext: async () => context,
1807
+ run: async () => new Promise(() => {}),
1808
+ turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
1809
+ });
1810
+ } catch (err) {
1811
+ if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1812
+ throw err;
1813
+ }
1814
+ try {
1815
+ // The controller (`explainControlState`) must not call a pending approval
1816
+ // "running": the scheduler keeps `context.running` true while it waits.
1817
+ const control = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
1818
+ method: 'POST', headers: { 'content-type': 'application/json' },
1819
+ body: JSON.stringify({ action: 'explain' }),
1820
+ });
1821
+ const controlBody = await control.json();
1822
+ assert.match(controlBody.explanation, /waiting for approval/i);
1823
+ assert.doesNotMatch(controlBody.explanation, /is active/i);
1824
+
1825
+ // And the turn hands the same facts to Donna rather than dumping them.
1826
+ const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1827
+ method: 'POST', headers: { 'content-type': 'application/json' },
1828
+ body: JSON.stringify({ input: 'donne le status du run en cours', mode: 'agent' }),
1829
+ });
1830
+ const body = await response.json();
1831
+ assert.equal(response.status, 202);
1832
+ assert.equal(body.kind, 'turn');
1833
+ await new Promise((resolve) => setTimeout(resolve, 25));
1834
+ assert.equal(turns, 1);
1835
+ assert.match(turnInput, /Pending approval: a mutating task needs approval/);
1774
1836
  } finally {
1775
1837
  context.currentAbortController?.abort();
1776
1838
  await handle.close();
@@ -1790,6 +1852,7 @@ test('POST /turn treats a bare confirmation during a run as a status check', asy
1790
1852
  conversation: [],
1791
1853
  };
1792
1854
  let turns = 0;
1855
+ let turnInput = '';
1793
1856
  let handle;
1794
1857
  try {
1795
1858
  handle = await startRuntimeServer({
@@ -1797,23 +1860,79 @@ test('POST /turn treats a bare confirmation during a run as a status check', asy
1797
1860
  store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1798
1861
  getContext: async () => context,
1799
1862
  run: async () => new Promise(() => {}),
1800
- turn: async () => { turns += 1; return { ok: true }; },
1863
+ turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
1801
1864
  });
1802
1865
  } catch (err) {
1803
1866
  if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1804
1867
  throw err;
1805
1868
  }
1806
1869
  try {
1807
- // "oui" answers the launch acknowledgement. It must reach the runtime's
1808
- // status, not a read-only chat turn that lectures about switching modes.
1870
+ // "oui" answers the launch acknowledgement. It is an observation, so it
1871
+ // reaches Donna with the runtime facts — not a deterministic English line
1872
+ // and not a read-only chat turn that lectures about switching modes.
1809
1873
  const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1810
1874
  method: 'POST', headers: { 'content-type': 'application/json' },
1811
1875
  body: JSON.stringify({ input: 'oui', mode: 'agent' }),
1812
1876
  });
1813
1877
  const body = await response.json();
1814
- assert.equal(response.status, 200);
1815
- assert.equal(body.kind, 'observe');
1816
- assert.equal(turns, 0);
1878
+ assert.equal(response.status, 202);
1879
+ assert.equal(body.kind, 'turn');
1880
+ await new Promise((resolve) => setTimeout(resolve, 25));
1881
+ assert.equal(turns, 1);
1882
+ assert.match(turnInput, /Runtime facts:/);
1883
+ assert.match(turnInput, /Rebuild the wiki/);
1884
+ } finally {
1885
+ context.currentAbortController?.abort();
1886
+ await handle.close();
1887
+ }
1888
+ });
1889
+
1890
+ test('POST /turn answers the reserved /status command itself, never the homonymous skill', async (t) => {
1891
+ // A workspace skill named `status` exists precisely to prove the built-in
1892
+ // wins: `/status` was handed to the model, which ran that skill (English
1893
+ // output) or an unrelated review instead of reporting anything. Serve types
1894
+ // `/status` into /turn; only `/skills run status` may reach the skill.
1895
+ const root = mkdtempSync(join(tmpdir(), 'runtime-status-builtin-'));
1896
+ mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
1897
+ writeFileSync(join(root, '.wiki', 'skills', 'status.md'), '---\nname: status\n---\nInspect services.');
1898
+ const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
1899
+ const context = { workspace: 'acme', session, running: false, currentAbortController: null };
1900
+ const status = {
1901
+ status: 'idle',
1902
+ running: false,
1903
+ plan: [{ step: 1, description: 'Rebuild the wiki', status: 'done' }],
1904
+ queue: [],
1905
+ controlQueue: [],
1906
+ approvals: [],
1907
+ conversation: [],
1908
+ };
1909
+ let turns = 0;
1910
+ let turnInput = '';
1911
+ let handle;
1912
+ try {
1913
+ handle = await startRuntimeServer({
1914
+ host: '127.0.0.1', port: 0,
1915
+ store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1916
+ getContext: async () => context,
1917
+ run: async () => new Promise(() => {}),
1918
+ turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
1919
+ });
1920
+ } catch (err) {
1921
+ if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1922
+ throw err;
1923
+ }
1924
+ try {
1925
+ const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1926
+ method: 'POST', headers: { 'content-type': 'application/json' },
1927
+ body: JSON.stringify({ input: '/status', mode: 'agent' }),
1928
+ });
1929
+ const body = await response.json();
1930
+ assert.equal(response.status, 202);
1931
+ assert.equal(body.kind, 'turn');
1932
+ await new Promise((resolve) => setTimeout(resolve, 25));
1933
+ assert.equal(turns, 1, 'the built-in status reaches Donna, never the homonymous skill');
1934
+ assert.match(turnInput, /Runtime facts:/);
1935
+ assert.doesNotMatch(turnInput, /Inspect services/);
1817
1936
  } finally {
1818
1937
  context.currentAbortController?.abort();
1819
1938
  await handle.close();
@@ -165,7 +165,7 @@ export async function generateSkillAcknowledgment(session, { publicInput, object
165
165
  // slow provider must not block the skill-launch HTTP response forever.
166
166
  const reply = await llm.complete({
167
167
  system: 'You are Donna, the workspace assistant. You acknowledge a launched workflow in the user\'s language. Be concise: exactly one short sentence.',
168
- input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and is now running.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Do not ask a question, do not propose options, and do not offer to check, monitor or cancel anything: the runtime reports progress on its own and this acknowledgement is not a decision point. Return only that sentence, nothing else.`,
168
+ input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and has been queued.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Do not claim the work is running, executing or done: a mutating step waits for the user's approval before it runs. Do not ask a question, do not propose options, and do not offer to check, monitor or cancel anything: the runtime reports progress on its own and this acknowledgement is not a decision point. Return only that sentence, nothing else.`,
169
169
  signal: AbortSignal.timeout(8_000),
170
170
  });
171
171
  const text = String(reply ?? '').trim();
@@ -179,7 +179,7 @@ export async function generateSkillAcknowledgment(session, { publicInput, object
179
179
  emitRuntimeLog(session, `skill-acknowledgment: LLM call failed, using the neutral fallback — ${err instanceof Error ? err.message : String(err)}`);
180
180
  }
181
181
  }
182
- return `Started ${publicInput} — ${count} step(s) in progress.`;
182
+ return `Started ${publicInput} — ${count} step(s) queued.`;
183
183
  }
184
184
 
185
185
  function argumentError(message) {
@@ -95,19 +95,21 @@ test('generateSkillAcknowledgment asks Donna in the session language and echoes
95
95
  assert.match(calls[0].input, /es/);
96
96
  assert.match(calls[0].input, /\/deliver deliverable="Informe"/);
97
97
  // The acknowledgement is not a decision point: it must not invite the user
98
- // into a dialog the runtime cannot act on.
98
+ // into a dialog the runtime cannot act on, and it must not claim the work is
99
+ // already executing — a mutating step waits for approval.
99
100
  assert.match(calls[0].input, /Do not ask a question/);
101
+ assert.match(calls[0].input, /Do not claim the work is running/);
100
102
  });
101
103
 
102
104
  test('generateSkillAcknowledgment degrades to a neutral message without an LLM client', async () => {
103
105
  const reply = await generateSkillAcknowledgment({ language: 'fr' }, { publicInput: '/wiki-ingest docs', objectives: 2 });
104
- assert.equal(reply, 'Started /wiki-ingest docs — 2 step(s) in progress.');
106
+ assert.equal(reply, 'Started /wiki-ingest docs — 2 step(s) queued.');
105
107
  });
106
108
 
107
109
  test('generateSkillAcknowledgment falls back when the LLM call fails', async () => {
108
110
  const session = { language: 'en', llm: { complete: async () => { throw new Error('down'); } } };
109
111
  const reply = await generateSkillAcknowledgment(session, { publicInput: '/deliver', objectives: 1 });
110
- assert.equal(reply, 'Started /deliver — 1 step(s) in progress.');
112
+ assert.equal(reply, 'Started /deliver — 1 step(s) queued.');
111
113
  });
112
114
 
113
115
  test('generateSkillAcknowledgment announces an LLM failure instead of degrading silently', async () => {