@dotdrelle/wiki-manager 0.15.96 → 0.15.98

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.
@@ -0,0 +1,31 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { classifyControlMessage } from './server.js';
4
+
5
+ const running = { running: true };
6
+
7
+ // A bare "yes" answers the runtime's own last prompt. Anything MORE than a
8
+ // bare yes is a request, and the rule used to be prefix-anchored only: every
9
+ // message merely STARTING on a yes was classified `observe` and answered with
10
+ // a status report, shadowing the modify_run and enqueue_run branches below it.
11
+ test('a bare confirmation during a run is a status check', async () => {
12
+ for (const input of ['oui', 'OK', 'vas-y', "d'accord", 'yes.', 'entendu !']) {
13
+ const result = await classifyControlMessage(input, running);
14
+ assert.equal(result.kind, 'observe', `expected observe for ${JSON.stringify(input)}`);
15
+ }
16
+ });
17
+
18
+ test('a plan change that merely opens on a yes is still a plan change', async () => {
19
+ const result = await classifyControlMessage(
20
+ 'oui, ajoute une étape de polish après le build',
21
+ running,
22
+ );
23
+ assert.equal(result.kind, 'modify_run');
24
+ });
25
+
26
+ test('a new task that opens on a yes reaches the model classifier, not the status branch', async () => {
27
+ const result = await classifyControlMessage("vas-y lance l'export", running, {
28
+ llm: { complete: async () => 'action' },
29
+ });
30
+ assert.equal(result.kind, 'enqueue_run');
31
+ });
@@ -9,7 +9,7 @@ import { createBudgetManager, BudgetExceededError } from '../orchestrator/budget
9
9
  import { createDispatcher } from '../orchestrator/dispatcher.js';
10
10
  import { approvalCovered, approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
11
11
  import { blockedByFailedDependency, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
12
- import { isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
12
+ import { isCancelled, isFailed, isPending, isSkipped, isSuccessful, isTerminal, isUnknownStatus } from '../orchestrator/taskStatuses.js';
13
13
  import { assertValidatedFragment } from '../orchestrator/planValidator.js';
14
14
  import { createResultAggregator } from '../orchestrator/resultAggregator.js';
15
15
  import { describePlanConcurrency, drainActive, startReadyTasks } from '../orchestrator/scheduler.js';
@@ -308,6 +308,7 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
308
308
  if (plan.length === 0) return;
309
309
  let failed = 0;
310
310
  let cancelled = 0;
311
+ let skipped = 0;
311
312
  let completed = 0;
312
313
  let pending = 0;
313
314
  let firstError = null;
@@ -319,8 +320,14 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
319
320
  step?.error?.message ?? step?.error?.code ?? step?.error
320
321
  ?? step?.result?.error?.message ?? step?.result?.error?.code ?? '',
321
322
  ).trim() || null;
322
- } else if (['cancelled', 'canceled'].includes(status)) {
323
+ } else if (isCancelled(status)) {
323
324
  cancelled += 1;
325
+ } else if (isSkipped(status)) {
326
+ // A chain step abandoned because an earlier required one failed. It fell
327
+ // through every bucket, so a 3-step chain that failed at step 1
328
+ // announced "0/3 réussie(s), 1 en erreur" and never mentioned the two
329
+ // steps nobody ran — precisely the silence the announce rule forbids.
330
+ skipped += 1;
324
331
  } else if (isSuccessful(status)) {
325
332
  completed += 1;
326
333
  } else if (isPending(status) || !isTerminal(status)) {
@@ -333,13 +340,15 @@ export async function announceRunOutcome(session, { runId, ok, signal = null } =
333
340
  }
334
341
  }
335
342
  const total = plan.length;
336
- const finished = ok && failed === 0 && cancelled === 0 && pending === 0 && completed === total;
343
+ const finished = ok && failed === 0 && cancelled === 0 && skipped === 0
344
+ && pending === 0 && completed === total;
337
345
  const factLine = finished
338
346
  ? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
339
347
  : `Plan non terminé — ${completed}/${total} tâche(s) réussie(s)` +
340
348
  `${pending ? `, ${pending} en attente (approbation ou exécution)` : ''}` +
341
349
  `${failed ? `, ${failed} en erreur` : ''}` +
342
- `${cancelled ? `, ${cancelled} annulée(s)` : ''}.` +
350
+ `${cancelled ? `, ${cancelled} annulée(s)` : ''}` +
351
+ `${skipped ? `, ${skipped} abandonnée(s) faute d'une étape précédente` : ''}.` +
343
352
  `${firstError ? ` Première erreur : ${firstError}.` : ''}`;
344
353
  let content = factLine;
345
354
  const llm = session.llm;
@@ -1306,6 +1306,26 @@ test('announceRunOutcome never calls a plan with pending tasks a success', async
1306
1306
  assert.doesNotMatch(message.payload.content, /succès/i);
1307
1307
  });
1308
1308
 
1309
+ test('announceRunOutcome names the steps a failed chain abandoned', async () => {
1310
+ // `skipped` fell through every bucket: a 3-step chain failing at step 1
1311
+ // announced "0/3 réussie(s), 1 en erreur" and never mentioned the two steps
1312
+ // nobody ran. "When something is skipped, say so where the panels read."
1313
+ const session = {
1314
+ agentEvents: [],
1315
+ agentProjection: null,
1316
+ headlessPlan: [
1317
+ { id: 'a', description: 'Sync', status: 'failed' },
1318
+ { id: 'b', description: 'Ingest', status: 'skipped' },
1319
+ { id: 'c', description: 'Build', status: 'skipped' },
1320
+ ],
1321
+ };
1322
+ await announceRunOutcome(session, { runId: 'run-3', ok: false });
1323
+ const message = session.agentEvents.find((event) => event.type === 'assistant_message');
1324
+ assert.match(message.payload.content, /non terminé/i);
1325
+ assert.match(message.payload.content, /1 en erreur/);
1326
+ assert.match(message.payload.content, /2 abandonnée\(s\)/);
1327
+ });
1328
+
1309
1329
  test('announceRunOutcome reports success only when every task finished', async () => {
1310
1330
  const session = {
1311
1331
  agentEvents: [],
@@ -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,11 +503,17 @@ 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;
509
510
  }
511
+ // What the reader actually typed. `input` below may be replaced by a
512
+ // system fact block for the model; the THREAD must still show the
513
+ // reader's own words — the replacement was persisted as the
514
+ // `user_message` and replayed as history on every later turn, which is
515
+ // exactly the "raw facts never enter the thread" rule it broke.
516
+ const displayInput = input;
510
517
  // Read-only chat turns intentionally remain available while an agent
511
518
  // run is active. Other interactive turns still become control
512
519
  // messages so they cannot start a competing agent decision.
@@ -529,19 +536,15 @@ export function startRuntimeServer({
529
536
  }
530
537
  return;
531
538
  }
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.
539
+ // A run/job status question must NEVER surface the raw system text in
540
+ // the thread: the runtime collects the facts and hands them to Donna,
541
+ // who synthesizes them in the session language. `readOnlyChat` so the
542
+ // turn is a conversation, not a control decision — and the facts are
543
+ // supplied here so the model cannot mistake the runtime run id for a
544
+ // production job id ("job not found").
536
545
  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;
546
+ input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
547
+ readOnlyChat = true;
545
548
  }
546
549
  if (context.running && !readOnlyChat) {
547
550
  // Agent-mode message while a run is active. Classify once: control
@@ -552,7 +555,13 @@ export function startRuntimeServer({
552
555
  llm: context?.session?.llm,
553
556
  session: context?.session,
554
557
  });
555
- if (classification.kind !== 'converse') {
558
+ if (classification.kind === 'observe') {
559
+ // An observation is system facts, not a control action: Donna gets
560
+ // them and answers in the session language, never a raw English
561
+ // line pushed into the thread.
562
+ input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
563
+ readOnlyChat = true;
564
+ } else if (classification.kind !== 'converse') {
556
565
  const result = await handleControlMessage(context, store, input, {
557
566
  intent: body.intent,
558
567
  startNextControlRequest,
@@ -561,8 +570,9 @@ export function startRuntimeServer({
561
570
  });
562
571
  sendJson(response, result.statusCode, result.body);
563
572
  return;
573
+ } else {
574
+ readOnlyChat = true;
564
575
  }
565
- readOnlyChat = true;
566
576
  }
567
577
  if (typeof turn !== 'function') {
568
578
  sendJson(response, 501, { error: 'Runtime interactive turns are unavailable.' });
@@ -580,7 +590,10 @@ export function startRuntimeServer({
580
590
  llm: context?.session?.llm,
581
591
  session: context?.session,
582
592
  });
583
- if (classification.kind !== 'converse') {
593
+ if (classification.kind === 'observe') {
594
+ input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
595
+ readOnlyChat = true;
596
+ } else if (classification.kind !== 'converse') {
584
597
  const result = await handleControlMessage(context, store, input, {
585
598
  intent: body.intent,
586
599
  startNextControlRequest,
@@ -596,7 +609,12 @@ export function startRuntimeServer({
596
609
  return result.body;
597
610
  }
598
611
  }
599
- return turn(context, { ...body, input, mode: readOnlyChat ? 'chat' : body.mode }, {
612
+ return turn(context, {
613
+ ...body,
614
+ input,
615
+ displayInput,
616
+ mode: readOnlyChat ? 'chat' : body.mode,
617
+ }, {
600
618
  signal: controller.signal,
601
619
  turnId,
602
620
  });
@@ -867,12 +885,24 @@ export function startRuntimeServer({
867
885
  if (context?.session) {
868
886
  context.session._runSkillWithinRun = async (skillName, args = {}, metadata = {}) => {
869
887
  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
- };
888
+ if (!skill) {
889
+ const available = listSkills(context.session).map((item) => item.name);
890
+ /*
891
+ A skill the model GUESSED is recoverable, not terminal: the observed
892
+ failure was `/diagnose` (leading slash copied from the catalogue) →
893
+ skill_not_found → the terminal path stripped the tools from the
894
+ synthesis turn → the model wrote `runtime__delegate{...}` as plain
895
+ text and the turn did nothing. An explicitly user-named missing skill
896
+ stays terminal: there is nothing to fall back to.
897
+ */
898
+ return {
899
+ ok: false,
900
+ terminal: metadata.selectionKind === 'explicit_name',
901
+ code: 'skill_not_found',
902
+ message: `No skill named "${skillName}". Pass the exact name without a leading slash (${available.join(', ') || 'none'}), or delegate the objective with runtime__delegate.`,
903
+ availableSkills: available,
904
+ };
905
+ }
876
906
  try {
877
907
  const idempotencyKey = metadata.idempotencyKey
878
908
  ? String(metadata.idempotencyKey)
@@ -1161,22 +1191,109 @@ export function runtimeState(context, store, { workspace = null, session = null
1161
1191
  // own history) so those replies surface. The log is a superset of the
1162
1192
  // canonical run conversation, so run rendering is unaffected.
1163
1193
  conversation: reduceAgentEvents(store.listEvents({ workspace })).conversation,
1164
- status: context?.running ? 'running' : state.status ?? 'idle',
1194
+ // `context.running` keeps the process alive while the scheduler waits for an
1195
+ // approval, but the run is then NOT running — the reducer already says
1196
+ // `pending_approval`. Prefer it over the blanket override.
1197
+ status: context?.running
1198
+ ? (state.status === 'pending_approval' ? 'pending_approval' : 'running')
1199
+ : state.status ?? 'idle',
1165
1200
  running: Boolean(context?.running),
1166
1201
  runId: context?.currentRunId ?? state.runId ?? null,
1167
1202
  workspace: context?.currentRunWorkspace ?? context?.workspace ?? state.workspace ?? workspace ?? null,
1168
1203
  };
1169
1204
  }
1170
1205
 
1206
+ // The live figures of the activity the run is on, in one line: percent, plan
1207
+ // step, build batch, instruction count and the stabilize counters. A status
1208
+ // that only named the current step could not tell 5% from 95%, nor what the
1209
+ // running batch had actually done.
1210
+ function describeActivityProgress(activity) {
1211
+ const progress = activity?.progress ?? {};
1212
+ const bits = [];
1213
+ if (Number.isFinite(Number(progress.percent))) bits.push(`${Number(progress.percent)}%`);
1214
+ if (progress.stepIndex != null && progress.stepTotal != null) bits.push(`step ${progress.stepIndex}/${progress.stepTotal}`);
1215
+ if (progress.batchIndex != null && progress.batchCount != null) bits.push(`batch ${Number(progress.batchIndex) + 1}/${progress.batchCount}`);
1216
+ if (progress.instructionCount != null) bits.push(`${progress.instructionCount} instruction${Number(progress.instructionCount) > 1 ? 's' : ''}`);
1217
+ const stabilize = [progress.stabilizeKept, progress.stabilizeMerged, progress.stabilizeInserted, progress.stabilizeRemoved];
1218
+ if (stabilize.some((value) => value != null)) {
1219
+ bits.push(`kept ${progress.stabilizeKept ?? 0}, merged ${progress.stabilizeMerged ?? 0}, inserted ${progress.stabilizeInserted ?? 0}, removed ${progress.stabilizeRemoved ?? 0}`);
1220
+ }
1221
+ const detail = progress.detail && !bits.includes(String(progress.detail)) ? String(progress.detail) : null;
1222
+ return { bits: bits.join(' · '), detail, label: activity?.label ?? null };
1223
+ }
1224
+
1225
+ // The facts a status answer is built from, rendered server-side ONCE. A status
1226
+ // question is answered by Donna, never by pushing this text into the thread:
1227
+ // the runtime supplies the figures, she phrases them in the session language.
1228
+ function runtimeStatusFacts(status) {
1229
+ const plan = Array.isArray(status.plan) ? status.plan : [];
1230
+ const approvals = Array.isArray(status.approvals) ? status.approvals : [];
1231
+ const queue = Array.isArray(status.controlQueue) ? status.controlQueue : [];
1232
+ const activities = Array.isArray(status.activities)
1233
+ ? status.activities
1234
+ : Object.values(status.activities ?? {});
1235
+ const lines = [
1236
+ `Runtime status: ${status.status ?? 'idle'}`,
1237
+ `Workspace: ${status.workspace ?? '-'}`,
1238
+ ];
1239
+ if (status.runId) lines.push(`Run id: ${status.runId}`);
1240
+ for (const activity of activities.filter((entry) => !entry?.terminal).slice(0, 8)) {
1241
+ const info = describeActivityProgress(activity);
1242
+ const detail = [info.bits, info.detail].filter(Boolean).join(' · ');
1243
+ lines.push(
1244
+ `Activity: ${info.label ?? activity.label ?? activity.id ?? '-'} — ${activity.status ?? '-'}${detail ? ` (${detail})` : ''}`,
1245
+ );
1246
+ }
1247
+ for (const [index, step] of plan.slice(0, 60).entries()) {
1248
+ lines.push(`Task ${step.step ?? index + 1}: ${step.status ?? 'pending'} - ${step.description ?? step.label ?? step.id ?? 'step'}`);
1249
+ }
1250
+ for (const approval of approvals.filter((entry) => entry.status === 'pending_approval')) {
1251
+ lines.push(`Pending approval: ${approval.reason ?? approval.taskId ?? approval.id ?? '-'}`);
1252
+ }
1253
+ for (const item of queue.filter((entry) => entry.status === 'queued')) {
1254
+ lines.push(`Queued: ${item.label ?? item.input ?? item.id ?? '-'}`);
1255
+ }
1256
+ return lines.join('\n');
1257
+ }
1258
+
1259
+ function runtimeStatusSynthesisPrompt(asked, status) {
1260
+ return [
1261
+ '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).',
1262
+ `User question: ${asked}`,
1263
+ '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.',
1264
+ '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.',
1265
+ '',
1266
+ 'Runtime facts:',
1267
+ runtimeStatusFacts(status),
1268
+ ].join('\n');
1269
+ }
1270
+
1171
1271
  function explainControlState(status) {
1172
1272
  const plan = Array.isArray(status.plan) ? status.plan : [];
1273
+ const activities = Array.isArray(status.activities)
1274
+ ? status.activities
1275
+ : Object.values(status.activities ?? {});
1276
+ // A run whose only outstanding work is a human decision is not "running":
1277
+ // `status.running` mirrors the process, which stays alive while the scheduler
1278
+ // waits. Mirrors the reducer's rule — pending_approval, or a pending approval
1279
+ // with no step actually executing.
1280
+ const approvals = Array.isArray(status.approvals) ? status.approvals : [];
1281
+ const pendingApproval = approvals.find((approval) => approval.status === 'pending_approval');
1282
+ const awaitingApproval = pendingApproval
1283
+ && (status.status === 'pending_approval' || !plan.some((step) => isActive(step?.status)));
1284
+ if (awaitingApproval) {
1285
+ return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
1286
+ }
1173
1287
  if (status.running) {
1174
1288
  const runningStep = plan.find((step) => step.status === 'running');
1289
+ const activity = activities.find((entry) => !entry?.terminal) ?? activities[0] ?? null;
1290
+ const info = activity ? describeActivityProgress(activity) : { bits: '', detail: null, label: null };
1291
+ const detailText = [info.bits, info.detail].filter(Boolean).join(' · ');
1292
+ const suffix = detailText ? ` (${detailText})` : '';
1175
1293
  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.';
1294
+ ? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.${suffix}`
1295
+ : `Runtime run is active. No current plan step is available yet.${suffix}`;
1178
1296
  }
1179
- const pendingApproval = status.approvals.find((approval) => approval.status === 'pending_approval');
1180
1297
  if (pendingApproval) {
1181
1298
  return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
1182
1299
  }
@@ -1187,6 +1304,15 @@ function explainControlState(status) {
1187
1304
  if (plan.some((step) => step.status === 'pending')) {
1188
1305
  return 'Runtime is idle with pending plan steps visible from the last run.';
1189
1306
  }
1307
+ // Idle at the end of a run: say what the last run did, not just "idle" — that
1308
+ // is the question the operator actually asks when the thread goes quiet.
1309
+ const failed = plan.filter((step) => isFailed(step.status) || isCancelled(step.status)).length;
1310
+ const done = plan.filter((step) => isSuccessful(step.status)).length;
1311
+ if (plan.length > 0) {
1312
+ return failed > 0
1313
+ ? `Runtime is idle. Last run: ${done}/${plan.length} task(s) succeeded, ${failed} failed or cancelled.`
1314
+ : `Runtime is idle. Last run: ${done}/${plan.length} task(s) succeeded.`;
1315
+ }
1190
1316
  return 'Runtime is idle.';
1191
1317
  }
1192
1318
 
@@ -1646,13 +1772,22 @@ function rejectPlanPatch(context, store, patchId, reason) {
1646
1772
  };
1647
1773
  }
1648
1774
 
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.
1775
+ // A question about the run/job currently executing. The free-text form is
1776
+ // deliberately narrow — a status word AND a run/job noun — so it never hijacks
1777
+ // an ordinary "explain how X works" question. Such a question must be answered
1778
+ // by the runtime itself: left to the model, a runtime runId was mistaken for a
1779
+ // production job id and reported as "not found", and a read-only chat turn had
1780
+ // no runtime status tool.
1781
+ //
1782
+ // The reserved built-in `/status` is ALWAYS a runtime status, in every surface:
1783
+ // `RESERVED_SLASH_COMMANDS` keeps the homonymous workspace skill out of
1784
+ // `matchSkillInvocation`, but without this branch `/turn` still handed the
1785
+ // literal command to the model, which ran the skill (English "status" output) or
1786
+ // an unrelated review instead of reporting anything. Serve types `/status` into
1787
+ // this endpoint; the ShellUI answers it locally.
1654
1788
  function asksForRunStatus(input) {
1655
- const text = String(input ?? '');
1789
+ const text = String(input ?? '').trim();
1790
+ if (/^\/status(?:\s|$)/i.test(text)) return true;
1656
1791
  const statusWord = /\b(status|statut|progression|progress|avancement|o[uù] en est|o[uù] en sont)\b/i;
1657
1792
  const runNoun = /\b(job|run|t[aâ]che|task|build|ingest|pipeline|export|polish|traitement)\b/i;
1658
1793
  return statusWord.test(text) && runNoun.test(text);
@@ -1665,7 +1800,7 @@ function asksForRunStatus(input) {
1665
1800
  // semantic judgement about the workspace's domain, so it is never a keyword
1666
1801
  // list here — it goes to the model, bounded, and falls back to the choice menu
1667
1802
  // (`ambiguous`) rather than guessing when no model is available.
1668
- async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
1803
+ export async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
1669
1804
  // Caller (the /control message route) already trims and rejects empty input.
1670
1805
  const lower = String(input ?? '').toLowerCase();
1671
1806
  const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
@@ -1699,7 +1834,10 @@ async function classifyControlMessage(input, status, { forcedIntent = null, llm
1699
1834
  // active, the only thing the runtime can act on is a status check: treating
1700
1835
  // the word as ordinary conversation made the read-only chat fallback lecture
1701
1836
  // the user about switching modes instead of answering.
1702
- if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\b/i.test(lower)) {
1837
+ // Anchored at BOTH ends: "oui" is a confirmation, "oui, ajoute une étape de
1838
+ // polish" is a plan change. Without the end anchor this branch shadowed
1839
+ // modify_run and enqueue_run for every message merely STARTING on a yes.
1840
+ if (status.running && /^\s*(oui|yes|yep|ok|okay|vas[- ]?y|d'accord|daccord|entendu)\s*[.!…]*\s*$/i.test(lower)) {
1703
1841
  return { kind: 'observe', confidence: 0.7, reason: 'confirmation_of_runtime_prompt' };
1704
1842
  }
1705
1843
  if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
@@ -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,9 @@ 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 turnDisplayInput = '';
1751
+ let turnMode = null;
1749
1752
  let handle;
1750
1753
  try {
1751
1754
  handle = await startRuntimeServer({
@@ -1753,24 +1756,95 @@ test('POST /turn answers a run status question from the runtime instead of the m
1753
1756
  store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1754
1757
  getContext: async () => context,
1755
1758
  run: async () => new Promise(() => {}),
1756
- turn: async () => { turns += 1; return { ok: true }; },
1759
+ turn: async (_context, options) => {
1760
+ turns += 1;
1761
+ turnInput = options.input;
1762
+ turnDisplayInput = options.displayInput;
1763
+ turnMode = options.mode;
1764
+ return { ok: true };
1765
+ },
1757
1766
  });
1758
1767
  } catch (err) {
1759
1768
  if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1760
1769
  throw err;
1761
1770
  }
1762
1771
  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.
1772
+ // System facts never reach the thread as raw text: the runtime supplies
1773
+ // them to Donna, who synthesizes the answer. The facts also prevent the
1774
+ // model mistaking the runtime runId for a production job id.
1765
1775
  const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1766
1776
  method: 'POST', headers: { 'content-type': 'application/json' },
1767
1777
  body: JSON.stringify({ input: 'donne le status du job en cours', mode: 'agent' }),
1768
1778
  });
1769
1779
  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);
1780
+ assert.equal(response.status, 202);
1781
+ assert.equal(body.kind, 'turn');
1782
+ // The turn is dispatched asynchronously after the 202.
1783
+ await new Promise((resolve) => setTimeout(resolve, 25));
1784
+ assert.equal(turns, 1);
1785
+ assert.equal(turnMode, 'chat');
1786
+ assert.match(turnInput, /Build TechSections/);
1787
+ assert.match(turnInput, /runtime run, not a production job/i);
1788
+ // …and the THREAD still shows what the reader typed. `executeInteractiveTurn`
1789
+ // persists `displayInput` as the user_message; feeding it the fact block
1790
+ // put a raw English dump in the reader's own bubble and replayed it as
1791
+ // history on every later turn.
1792
+ assert.equal(turnDisplayInput, 'donne le status du job en cours');
1793
+ } finally {
1794
+ context.currentAbortController?.abort();
1795
+ await handle.close();
1796
+ }
1797
+ });
1798
+
1799
+ test('a run blocked on approval is described as waiting, not as running', async (t) => {
1800
+ const session = { workspace: 'acme', controlQueue: [] };
1801
+ const context = { workspace: 'acme', session, running: true, currentAbortController: null };
1802
+ const status = {
1803
+ status: 'pending_approval',
1804
+ running: true,
1805
+ plan: [{ step: 1, description: 'Rebuild the concepts', status: 'pending_approval' }],
1806
+ queue: [],
1807
+ controlQueue: [],
1808
+ approvals: [{ id: 'a1', status: 'pending_approval', reason: 'a mutating task needs approval' }],
1809
+ conversation: [],
1810
+ };
1811
+ let turns = 0;
1812
+ let turnInput = '';
1813
+ let handle;
1814
+ try {
1815
+ handle = await startRuntimeServer({
1816
+ host: '127.0.0.1', port: 0,
1817
+ store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1818
+ getContext: async () => context,
1819
+ run: async () => new Promise(() => {}),
1820
+ turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
1821
+ });
1822
+ } catch (err) {
1823
+ if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1824
+ throw err;
1825
+ }
1826
+ try {
1827
+ // The controller (`explainControlState`) must not call a pending approval
1828
+ // "running": the scheduler keeps `context.running` true while it waits.
1829
+ const control = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
1830
+ method: 'POST', headers: { 'content-type': 'application/json' },
1831
+ body: JSON.stringify({ action: 'explain' }),
1832
+ });
1833
+ const controlBody = await control.json();
1834
+ assert.match(controlBody.explanation, /waiting for approval/i);
1835
+ assert.doesNotMatch(controlBody.explanation, /is active/i);
1836
+
1837
+ // And the turn hands the same facts to Donna rather than dumping them.
1838
+ const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1839
+ method: 'POST', headers: { 'content-type': 'application/json' },
1840
+ body: JSON.stringify({ input: 'donne le status du run en cours', mode: 'agent' }),
1841
+ });
1842
+ const body = await response.json();
1843
+ assert.equal(response.status, 202);
1844
+ assert.equal(body.kind, 'turn');
1845
+ await new Promise((resolve) => setTimeout(resolve, 25));
1846
+ assert.equal(turns, 1);
1847
+ assert.match(turnInput, /Pending approval: a mutating task needs approval/);
1774
1848
  } finally {
1775
1849
  context.currentAbortController?.abort();
1776
1850
  await handle.close();
@@ -1790,6 +1864,7 @@ test('POST /turn treats a bare confirmation during a run as a status check', asy
1790
1864
  conversation: [],
1791
1865
  };
1792
1866
  let turns = 0;
1867
+ let turnInput = '';
1793
1868
  let handle;
1794
1869
  try {
1795
1870
  handle = await startRuntimeServer({
@@ -1797,23 +1872,79 @@ test('POST /turn treats a bare confirmation during a run as a status check', asy
1797
1872
  store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1798
1873
  getContext: async () => context,
1799
1874
  run: async () => new Promise(() => {}),
1800
- turn: async () => { turns += 1; return { ok: true }; },
1875
+ turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
1801
1876
  });
1802
1877
  } catch (err) {
1803
1878
  if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1804
1879
  throw err;
1805
1880
  }
1806
1881
  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.
1882
+ // "oui" answers the launch acknowledgement. It is an observation, so it
1883
+ // reaches Donna with the runtime facts — not a deterministic English line
1884
+ // and not a read-only chat turn that lectures about switching modes.
1809
1885
  const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1810
1886
  method: 'POST', headers: { 'content-type': 'application/json' },
1811
1887
  body: JSON.stringify({ input: 'oui', mode: 'agent' }),
1812
1888
  });
1813
1889
  const body = await response.json();
1814
- assert.equal(response.status, 200);
1815
- assert.equal(body.kind, 'observe');
1816
- assert.equal(turns, 0);
1890
+ assert.equal(response.status, 202);
1891
+ assert.equal(body.kind, 'turn');
1892
+ await new Promise((resolve) => setTimeout(resolve, 25));
1893
+ assert.equal(turns, 1);
1894
+ assert.match(turnInput, /Runtime facts:/);
1895
+ assert.match(turnInput, /Rebuild the wiki/);
1896
+ } finally {
1897
+ context.currentAbortController?.abort();
1898
+ await handle.close();
1899
+ }
1900
+ });
1901
+
1902
+ test('POST /turn answers the reserved /status command itself, never the homonymous skill', async (t) => {
1903
+ // A workspace skill named `status` exists precisely to prove the built-in
1904
+ // wins: `/status` was handed to the model, which ran that skill (English
1905
+ // output) or an unrelated review instead of reporting anything. Serve types
1906
+ // `/status` into /turn; only `/skills run status` may reach the skill.
1907
+ const root = mkdtempSync(join(tmpdir(), 'runtime-status-builtin-'));
1908
+ mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
1909
+ writeFileSync(join(root, '.wiki', 'skills', 'status.md'), '---\nname: status\n---\nInspect services.');
1910
+ const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
1911
+ const context = { workspace: 'acme', session, running: false, currentAbortController: null };
1912
+ const status = {
1913
+ status: 'idle',
1914
+ running: false,
1915
+ plan: [{ step: 1, description: 'Rebuild the wiki', status: 'done' }],
1916
+ queue: [],
1917
+ controlQueue: [],
1918
+ approvals: [],
1919
+ conversation: [],
1920
+ };
1921
+ let turns = 0;
1922
+ let turnInput = '';
1923
+ let handle;
1924
+ try {
1925
+ handle = await startRuntimeServer({
1926
+ host: '127.0.0.1', port: 0,
1927
+ store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1928
+ getContext: async () => context,
1929
+ run: async () => new Promise(() => {}),
1930
+ turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
1931
+ });
1932
+ } catch (err) {
1933
+ if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1934
+ throw err;
1935
+ }
1936
+ try {
1937
+ const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1938
+ method: 'POST', headers: { 'content-type': 'application/json' },
1939
+ body: JSON.stringify({ input: '/status', mode: 'agent' }),
1940
+ });
1941
+ const body = await response.json();
1942
+ assert.equal(response.status, 202);
1943
+ assert.equal(body.kind, 'turn');
1944
+ await new Promise((resolve) => setTimeout(resolve, 25));
1945
+ assert.equal(turns, 1, 'the built-in status reaches Donna, never the homonymous skill');
1946
+ assert.match(turnInput, /Runtime facts:/);
1947
+ assert.doesNotMatch(turnInput, /Inspect services/);
1817
1948
  } finally {
1818
1949
  context.currentAbortController?.abort();
1819
1950
  await handle.close();