@dotdrelle/wiki-manager 0.14.13 → 0.14.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +29 -4
  2. package/README.md +19 -0
  3. package/docker-compose.yml +6 -1
  4. package/package.json +1 -1
  5. package/src/activity/activityAggregator.js +50 -16
  6. package/src/activity/activityAggregator.test.js +67 -4
  7. package/src/agent/graph.js +79 -11
  8. package/src/agent/graph.test.js +43 -3
  9. package/src/cli/wiki-manager.js +74 -11
  10. package/src/cli/wiki-manager.test.js +40 -1
  11. package/src/commands/slash.js +10 -3
  12. package/src/commands/slash.test.js +24 -0
  13. package/src/core/buildInfo.json +2 -2
  14. package/src/core/dockerCompose.test.js +4 -0
  15. package/src/core/env.test.js +3 -0
  16. package/src/core/mcp.js +1 -1
  17. package/src/core/wikiSetup.js +35 -0
  18. package/src/core/wikiWorkspace.test.js +20 -0
  19. package/src/core/workflow.js +72 -0
  20. package/src/core/workflow.test.js +57 -0
  21. package/src/core/workspaces.js +10 -2
  22. package/src/orchestrator/dependencyResolver.js +19 -1
  23. package/src/orchestrator/objectiveResolver.js +24 -0
  24. package/src/orchestrator/objectiveResolver.test.js +23 -1
  25. package/src/orchestrator/scheduler.js +27 -7
  26. package/src/orchestrator/scheduler.test.js +45 -1
  27. package/src/runtime/auth.test.js +65 -1
  28. package/src/runtime/client.js +4 -0
  29. package/src/runtime/donna-contract.test.js +2 -0
  30. package/src/runtime/lifecycle.js +21 -12
  31. package/src/runtime/runner.js +141 -18
  32. package/src/runtime/runner.test.js +30 -0
  33. package/src/runtime/server.js +13 -2
  34. package/src/runtime/server.test.js +30 -1
  35. package/src/runtime/store.js +54 -0
  36. package/src/runtime/store.test.js +42 -0
  37. package/src/shell/FileEditorDialog.tsx +2 -2
  38. package/src/shell/LeftPane.tsx +60 -15
  39. package/src/shell/RightPane.tsx +168 -56
  40. package/src/shell/StartupScreen.tsx +3 -7
  41. package/src/shell/renderer.ts +1 -0
  42. package/src/shell/repl.js +7 -81
  43. package/src/shell/repl.test.js +141 -38
  44. package/src/shell/tui.tsx +65 -65
  45. package/src/shell/useSession.ts +92 -6
  46. package/wiki-workspace +28 -0
@@ -8,6 +8,7 @@ import { createAgentGraph } from '../agent/graph.js';
8
8
  import { handleSlashCommand, printHelp, printVersion, refreshMcpRuntimeStatus } from '../commands/slash.js';
9
9
  import { runShell, runHeadlessChatTurn } from '../shell/repl.js';
10
10
  import { runPreflightChecks, withRuntimePreflight } from '../core/startupCheck.js';
11
+ import { refreshRunningContainers } from '../core/wikiSetup.js';
11
12
  import { applySessionWikircProfile } from '../core/sessionConfig.js';
12
13
  import { listWikircProfiles } from '../core/wikirc.js';
13
14
  import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
@@ -17,6 +18,7 @@ import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core
17
18
  import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
18
19
  import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
19
20
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
21
+ import { listWorkspaces } from '../core/workspaces.js';
20
22
  // Runtime modules use node:sqlite (Node.js built-in unavailable in Bun).
21
23
  // They are imported dynamically so the shell / TUI path never loads them.
22
24
 
@@ -112,6 +114,18 @@ export async function forwardRuntimeApproval(getWorkspaceContext, request = {})
112
114
  return context.approvalManager?.approve(request) ?? { approved: false };
113
115
  }
114
116
 
117
+ export function resolvePreparedDelegationApproval({
118
+ autoApprove = false,
119
+ approvalManager = null,
120
+ runId,
121
+ } = {}) {
122
+ if (autoApprove !== true || typeof approvalManager?.approve !== 'function') {
123
+ return { approved: false, awaitingApproval: true };
124
+ }
125
+ const result = approvalManager.approve({ scope: 'run', runId });
126
+ return { approved: true, awaitingApproval: false, result };
127
+ }
128
+
115
129
  function timestampForFile() {
116
130
  return new Date().toISOString().replace(/[:.]/g, '-');
117
131
  }
@@ -422,7 +436,7 @@ async function runHeadless(argv, agent) {
422
436
  let input = prompt;
423
437
  if (skillName) {
424
438
  const skillResult = await handleSlashCommand(`/skills run ${skillName}`, { packageJson, session, onStep: step });
425
- if (skillResult.output) log.push(skillResult.output);
439
+ if (skillResult.output && !skillResult.rawOutput) log.push(skillResult.output);
426
440
  if (String(skillResult.output ?? '').startsWith('Skill not found')) throw new Error(`Skill not found: ${skillName}`);
427
441
  input = skillResult.agentTrigger
428
442
  ? [
@@ -501,7 +515,7 @@ async function runRuntime(argv, agent) {
501
515
  const { defaultRuntimeStateDir, openRuntimeStore, RECOVERABLE_QUEUE_STATUSES } = await import('../runtime/store.js');
502
516
  const { startRuntimeServer } = await import('../runtime/server.js');
503
517
  const { recoverActiveRuns } = await import('../runtime/recoveryManager.js');
504
- const { emitRuntimeLog, startActivitySupervisor, cancelActiveActivityJobs } = await import('../runtime/supervisor.js');
518
+ const { emitRuntimeLog, startActivitySupervisor, cancelActiveActivityJobs, discoverAgentsOnce } = await import('../runtime/supervisor.js');
505
519
  const { resolveRuntimeAuthToken } = await import('../runtime/auth.js');
506
520
  const { createSqliteQueueStore } = await import('../runtime/queueStore.js');
507
521
  const { createApprovalManager } = await import('../runtime/approvals.js');
@@ -803,6 +817,12 @@ async function runRuntime(argv, agent) {
803
817
  const { resolveObjective } = await import('../orchestrator/objectiveResolver.js');
804
818
  const { validateFragment } = await import('../orchestrator/planValidator.js');
805
819
  const session = context.session;
820
+ // The supervisor starts discovery asynchronously. A delegation submitted
821
+ // immediately after opening ShellUI must not observe the transient empty
822
+ // registry and fail while the provider is already healthy. Refresh the
823
+ // live endpoints and await one discovery pass before resolving.
824
+ await refreshMcpRuntimeStatus(session);
825
+ await discoverAgentsOnce(session, { registry: session.agentRegistry });
806
826
  let selection;
807
827
  try {
808
828
  selection = await resolveObjective(objective, session);
@@ -915,14 +935,24 @@ async function runRuntime(argv, agent) {
915
935
  throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
916
936
  }
917
937
  emitRuntimeLog(session, `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider.serverName}.agent_plan (${prepared.capability}/${prepared.operation})`);
918
- // Demandé = consenti: a directly-delegated run carries the user's
919
- // explicit consent, so auto-approve its initial plan. Persisting a
920
- // run-scope grant (via the approval manager) makes the scheduler's
921
- // readyTasks approval check pass, so the tasks run without re-prompting.
922
- // Replanned tasks are integrated later without a fresh grant.
923
- if (context.approvalManager?.approve) {
924
- context.approvalManager.approve({ scope: 'run', runId });
925
- emitRuntimeLog(session, `approval: run ${runId} auto-approved (user-requested action)`);
938
+ // Real approval gate (opt-out): a directly-delegated run only skips the
939
+ // human approval step when the caller explicitly opts in via
940
+ // `autoApprove` (e.g. headless/CI, or a future "trust this run" toggle).
941
+ // By default the run WAITS: integrate() above created the per-task
942
+ // approval requests, and the scheduler's approvalCovered() filter blocks
943
+ // the mutating tasks until a run-scope grant arrives (/approve or
944
+ // "valide tout"). This keeps a visible pending_approval window instead of
945
+ // resolving it programmatically ~30ms after launch, which no polled UI
946
+ // could ever render.
947
+ const approval = resolvePreparedDelegationApproval({
948
+ autoApprove: body.autoApprove,
949
+ approvalManager: context.approvalManager,
950
+ runId,
951
+ });
952
+ if (approval.approved) {
953
+ emitRuntimeLog(session, `approval: run ${runId} auto-approved (autoApprove opt-in)`);
954
+ } else {
955
+ emitRuntimeLog(session, `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`);
926
956
  }
927
957
  body._planReady?.resolve?.({ runId, planRevision: session.agentProjection?.planRevision ?? 0 });
928
958
  }
@@ -1162,10 +1192,22 @@ async function runRuntime(argv, agent) {
1162
1192
  await new Promise(() => {});
1163
1193
  }
1164
1194
 
1195
+ // One place for the skipped-image-update warnings so the runtime and TUI
1196
+ // startup paths report refresh failures identically.
1197
+ function logImageRefreshErrors(imageRefresh) {
1198
+ for (const error of imageRefresh?.errors ?? []) {
1199
+ console.warn(`[wiki-manager] image update skipped: ${error}`);
1200
+ }
1201
+ }
1202
+
1165
1203
  export async function runCli(argv) {
1166
1204
  if (argv[0] === 'runtime') {
1167
1205
  const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
1168
1206
  if (scaffolded.length > 0) loadManagerEnv();
1207
+ const imageRefresh = await refreshRunningContainers({
1208
+ onStep: (message) => console.log(`[wiki-manager] ${message}`),
1209
+ });
1210
+ logImageRefreshErrors(imageRefresh);
1169
1211
  const agent = createAgentGraph();
1170
1212
  await runRuntime(argv.slice(1), agent);
1171
1213
  return;
@@ -1218,6 +1260,10 @@ export async function runCli(argv) {
1218
1260
  if (!process.versions.bun) {
1219
1261
  throw new Error('Interactive TUI requires Bun. Run: bun ./bin/wiki-manager.js');
1220
1262
  }
1263
+ const initialWorkspaceName = valueAfter(argv, '--workspace');
1264
+ if (initialWorkspaceName && !listWorkspaces().some((workspace) => workspace.name === initialWorkspaceName)) {
1265
+ throw new Error(`Workspace not found: ${initialWorkspaceName}`);
1266
+ }
1221
1267
  const { runOpenTuiShell, runStartupWizard } = await import('../shell/tui.tsx');
1222
1268
  // Fresh directory → copy mcp.endpoints.json/.env from the packaged
1223
1269
  // examples so external agents (cme, mailer, documents) connect out of
@@ -1242,6 +1288,17 @@ export async function runCli(argv) {
1242
1288
  // configuration. Re-read everything before drawing the home screen.
1243
1289
  preflight = await runPreflightChecks();
1244
1290
  }
1291
+ const dockerReady = preflight.checks.some((check) => check.kind === 'docker' && check.ok);
1292
+ const internetReady = preflight.checks.some((check) => check.kind === 'internet' && check.ok);
1293
+ if (dockerReady && internetReady) {
1294
+ void refreshRunningContainers({
1295
+ onStep: (message) => console.log(`[wiki-manager] ${message}`),
1296
+ }).then((imageRefresh) => {
1297
+ logImageRefreshErrors(imageRefresh);
1298
+ }).catch((error) => {
1299
+ console.warn(`[wiki-manager] image update skipped: ${error instanceof Error ? error.message : String(error)}`);
1300
+ });
1301
+ }
1245
1302
  let runtime = null;
1246
1303
  try {
1247
1304
  const { ensureRuntime } = await import('../runtime/lifecycle.js');
@@ -1257,7 +1314,13 @@ export async function runCli(argv) {
1257
1314
  // (see tui.tsx onShellExit): render() resolves at MOUNT, so anything
1258
1315
  // after this await would run while the shell is still on screen —
1259
1316
  // 0.12.9 shipped exactly that bug and killed the runtime under the user.
1260
- await runOpenTuiShell({ agent, packageJson, runtime, preflight });
1317
+ await runOpenTuiShell({
1318
+ agent,
1319
+ packageJson,
1320
+ runtime,
1321
+ preflight,
1322
+ initialWorkspaceName,
1323
+ });
1261
1324
  return;
1262
1325
  }
1263
1326
 
@@ -1,6 +1,9 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { forwardRuntimeApproval } from './wiki-manager.js';
3
+ import {
4
+ forwardRuntimeApproval,
5
+ resolvePreparedDelegationApproval,
6
+ } from './wiki-manager.js';
4
7
 
5
8
  test('runtime approval bridge preserves the complete run-scoped grant', async () => {
6
9
  let forwarded = null;
@@ -26,3 +29,39 @@ test('runtime approval bridge preserves the complete run-scoped grant', async ()
26
29
  assert.deepEqual(forwarded, request);
27
30
  assert.deepEqual(result, { approved: true });
28
31
  });
32
+
33
+ test('prepared delegation waits for explicit approval by default', () => {
34
+ let calls = 0;
35
+ const result = resolvePreparedDelegationApproval({
36
+ runId: 'run-gated',
37
+ approvalManager: {
38
+ approve() {
39
+ calls += 1;
40
+ },
41
+ },
42
+ });
43
+
44
+ assert.equal(calls, 0);
45
+ assert.deepEqual(result, { approved: false, awaitingApproval: true });
46
+ });
47
+
48
+ test('prepared delegation only approves when autoApprove is explicitly true', () => {
49
+ let forwarded = null;
50
+ const result = resolvePreparedDelegationApproval({
51
+ autoApprove: true,
52
+ runId: 'run-headless',
53
+ approvalManager: {
54
+ approve(request) {
55
+ forwarded = request;
56
+ return { approved: true };
57
+ },
58
+ },
59
+ });
60
+
61
+ assert.deepEqual(forwarded, { scope: 'run', runId: 'run-headless' });
62
+ assert.deepEqual(result, {
63
+ approved: true,
64
+ awaitingApproval: false,
65
+ result: { approved: true },
66
+ });
67
+ });
@@ -391,7 +391,10 @@ function skillDetailText(skill) {
391
391
 
392
392
  function buildSkillRunPrompt(skill) {
393
393
  return [
394
- `Execute the "${skill.name}" skill for the current workspace.`,
394
+ `The user asked to run the "${skill.name}" skill for the current workspace.`,
395
+ 'First explain concisely, in the user language, what will be launched and its intended outcome.',
396
+ 'Do not quote, reproduce, or display the raw skill content.',
397
+ 'Then execute the workflow, using the available tools when required.',
395
398
  'Follow the workflow steps below. Call MCP tools and shell commands as needed for each step.',
396
399
  'Report progress as you go. Ask for confirmation before irreversible or costly actions not already defined in the skill.',
397
400
  '',
@@ -413,7 +416,11 @@ function skillActionCommand(session, action, name) {
413
416
  return { output: `Skill not found: ${name}.${hint}` };
414
417
  }
415
418
  if (action === 'run') {
416
- return { output: `Skill: ${skill.name} — launching…`, agentTrigger: buildSkillRunPrompt(skill) };
419
+ return {
420
+ output: JSON.stringify({ operation: 'run-skill', skill: skill.name }),
421
+ rawOutput: true,
422
+ agentTrigger: buildSkillRunPrompt(skill),
423
+ };
417
424
  }
418
425
  return { output: skillDetailText(skill) };
419
426
  }
@@ -603,7 +610,7 @@ Options:
603
610
  --cacert <path> Trust a local CA; Docker must be able to read this host path
604
611
  --once <prompt> Run one agent turn and exit
605
612
  --headless Run a workspace task non-interactively
606
- --workspace <name> Workspace for --headless
613
+ --workspace <name> Initial workspace (interactive or --headless)
607
614
  --skill <name> Skill to run in --headless (implies --wait)
608
615
  --prompt <text> Task or extra instruction for --headless
609
616
  --log-file <path> Optional headless log path
@@ -93,6 +93,30 @@ test('/new without a name shows usage', async () => {
93
93
  assert.match(result.output ?? '', /Usage/i);
94
94
  });
95
95
 
96
+ test('/skills run sends the private skill body to Donna without rendering it as command output', async () => {
97
+ const root = await mkdtemp(join(tmpdir(), 'wiki-manager-skill-run-'));
98
+ const skillDir = join(root, '.wiki', 'skills');
99
+ mkdirSync(skillDir, { recursive: true });
100
+ writeFileSync(join(skillDir, 'pipeline.md'), [
101
+ '---',
102
+ 'name: pipeline',
103
+ 'description: Build the deliverables',
104
+ '---',
105
+ 'SECRET WORKFLOW BODY',
106
+ '',
107
+ ].join('\n'), 'utf8');
108
+
109
+ const result = await handleSlashCommand('/skills run pipeline', {
110
+ packageJson: { version: 'test' },
111
+ session: { workspacePath: root },
112
+ });
113
+
114
+ assert.equal(result.rawOutput, true);
115
+ assert.doesNotMatch(result.output, /SECRET WORKFLOW BODY/);
116
+ assert.match(result.agentTrigger, /SECRET WORKFLOW BODY/);
117
+ assert.match(result.agentTrigger, /Do not quote, reproduce, or display the raw skill content/);
118
+ });
119
+
96
120
  test('/use loads only workspaces and /config use switches wikirc profiles', async () => {
97
121
  const root = await mkdtemp(join(tmpdir(), 'wiki-manager-use-profile-'));
98
122
  const registryRoot = join(root, 'registry');
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.13",
3
- "commit": "f0faf8b"
2
+ "version": "0.14.20",
3
+ "commit": "8833a7e"
4
4
  }
@@ -11,6 +11,10 @@ test('workspace compose does not start a per-workspace agent runtime', async ()
11
11
  assert.equal(compose.services['agent-runtime'], undefined);
12
12
  assert.deepEqual(aliases.all.targets, ['serve', 'mcp-http', 'production-mcp']);
13
13
  assert.equal(aliases.runtime, undefined);
14
+ assert.equal(
15
+ compose.services.serve.environment.includes('WIKI_MANAGER_RUNTIME_URL=http://host.docker.internal:${WIKI_MANAGER_RUNTIME_PORT:-7788}'),
16
+ true,
17
+ );
14
18
  });
15
19
 
16
20
  test('agent compose services run as the host uid and gid', async () => {
@@ -29,6 +29,9 @@ test('scaffold copies the packaged examples into a fresh directory', () => {
29
29
  const endpoints = JSON.parse(readFileSync(join(dir, 'mcp.endpoints.json'), 'utf8'));
30
30
  assert.ok(endpoints.mcpServers);
31
31
  assert.ok(endpoints.chatAccess);
32
+ const env = readFileSync(join(dir, '.env'), 'utf8');
33
+ assert.match(env, /^# WIKI_MANAGER_RUNTIME_HOST=0\.0\.0\.0$/m);
34
+ assert.match(env, /^# WIKI_MANAGER_RUNTIME_PORT=7788$/m);
32
35
  });
33
36
  });
34
37
 
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.14.13';
4
+ const WIKI_MANAGER_VERSION = '0.14.20';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -105,6 +105,41 @@ export async function stopAgents(options = {}) {
105
105
  }
106
106
  }
107
107
 
108
+ export async function refreshRunningContainers(options = {}) {
109
+ if (process.env.WIKI_MANAGER_AUTO_UPDATE === '0') {
110
+ return { skipped: true, refreshed: [] };
111
+ }
112
+ const script = join(managerRoot(), 'wiki-workspace');
113
+ const common = {
114
+ cwd: dirname(managerEnvFile()),
115
+ env: {
116
+ ...process.env,
117
+ WIKI_WORKSPACES_DIR: workspacesDir(),
118
+ WIKI_MANAGER_ENV_FILE: managerEnvFile(),
119
+ WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
120
+ AGENTS_DATA_DIR: resolveAgentsDataDir(),
121
+ },
122
+ timeout: options.timeout ?? 600_000,
123
+ maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
124
+ };
125
+ const targets = [['agents', 'refresh'], ...listWorkspaces().map((workspace) => ['wiki', workspace.name, 'refresh'])];
126
+ // Each target is an independent Compose project (its own agents/workspace
127
+ // stack) — refresh them concurrently instead of summing every target's
128
+ // pull/restart time into one sequential wait.
129
+ const results = await Promise.all(targets.map(async (args) => {
130
+ options.onStep?.(`Images: checking ${args[0] === 'agents' ? 'running agents' : `workspace ${args[1]}`}…`);
131
+ try {
132
+ const { stdout, stderr } = await execFileAsync(script, args, common);
133
+ return { ok: true, output: [stdout, stderr].filter(Boolean).join('\n').trim() };
134
+ } catch (err) {
135
+ return { ok: false, error: wrapDockerError(err).message };
136
+ }
137
+ }));
138
+ const refreshed = results.filter((result) => result.ok).map((result) => result.output).filter(Boolean);
139
+ const errors = results.filter((result) => !result.ok).map((result) => result.error);
140
+ return { skipped: false, refreshed, errors };
141
+ }
142
+
108
143
  export async function createNewWorkspace(name, targetPath) {
109
144
  try {
110
145
  const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
@@ -31,3 +31,23 @@ test('wiki-workspace regenerates CA compose overrides instead of retaining remov
31
31
  assert.match(script, /mv "\$tmp_override" "\$override_path"/);
32
32
  assert.match(script, /Changes are overwritten on the next compose command/);
33
33
  });
34
+
35
+ test('workspace creation keeps mutable manager files outside the installed package', async () => {
36
+ const source = await readFile(new URL('./workspaces.js', import.meta.url), 'utf8');
37
+
38
+ assert.match(source, /const stateDir = dirname\(managerEnvFile\(\)\)/);
39
+ assert.match(source, /cwd: stateDir/);
40
+ assert.match(source, /WIKI_MANAGER_ENV_FILE: managerEnvFile\(\)/);
41
+ assert.match(source, /WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile\(\)/);
42
+ });
43
+
44
+ test('container refresh pulls and renews only services that are already running', async () => {
45
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
46
+
47
+ assert.match(script, /refresh_running_services\(\) \{/);
48
+ assert.match(script, /\[\[ -n "\$\{line\/\/\[\[:space:\]\]\/\}" \]\] \|\| continue/);
49
+ assert.match(script, /ps --status running --services/);
50
+ assert.match(script, /"\$@" pull "\$\{running_services\[@\]\}"/);
51
+ assert.match(script, /refresh_running_services 'No running external agent containers to refresh' 'Refreshed running agents: %s' _agents_dc/);
52
+ assert.match(script, /refresh_running_services "No running workspace containers to refresh: \$workspace" "Refreshed running workspace containers: \$workspace \(%s\)" compose_for_workspace "\$workspace"/);
53
+ });
@@ -107,11 +107,83 @@ export function projectWorkflow(state = {}, events = []) {
107
107
  activity,
108
108
  waitingReasons,
109
109
  warnings,
110
+ usage: summarizeTokenUsage(events),
111
+ timingByTask: summarizeTaskTiming(events),
110
112
  };
111
113
  projected.graph = aggregateGraph(projected, events);
112
114
  return projected;
113
115
  }
114
116
 
117
+ // Per-task wall-clock timing, derived from the lifecycle events. Start = the
118
+ // first assigned/started event, finish = the last terminal event. Display-only
119
+ // (the inspector shows duration + orders tasks into a temporal flow); never used
120
+ // for scheduling.
121
+ function summarizeTaskTiming(events = []) {
122
+ const byTask = {};
123
+ const START_EVENTS = new Set(['task.assigned', 'task.started']);
124
+ const END_EVENTS = new Set(['task.completed', 'task.failed', 'task.result_returned']);
125
+ for (const event of events) {
126
+ if (!START_EVENTS.has(event.type) && !END_EVENTS.has(event.type)) continue;
127
+ const taskId = String(event.taskId ?? event.payload?.taskId ?? '').replace(/^task:/, '');
128
+ if (!taskId) continue;
129
+ const ts = Number(new Date(event.ts).getTime());
130
+ if (!Number.isFinite(ts)) continue;
131
+ const entry = byTask[taskId] ?? (byTask[taskId] = { startedAt: null, finishedAt: null, durationMs: null });
132
+ if (START_EVENTS.has(event.type)) {
133
+ entry.startedAt = entry.startedAt == null ? ts : Math.min(entry.startedAt, ts);
134
+ } else {
135
+ entry.finishedAt = entry.finishedAt == null ? ts : Math.max(entry.finishedAt, ts);
136
+ }
137
+ }
138
+ for (const entry of Object.values(byTask)) {
139
+ if (entry.startedAt != null && entry.finishedAt != null && entry.finishedAt >= entry.startedAt) {
140
+ entry.durationMs = entry.finishedAt - entry.startedAt;
141
+ }
142
+ }
143
+ return byTask;
144
+ }
145
+
146
+ function summarizeTokenUsage(events = []) {
147
+ let inputTokens = 0;
148
+ let outputTokens = 0;
149
+ let totalTokens = 0;
150
+ let inputKnown = false;
151
+ let outputKnown = false;
152
+ let totalKnown = false;
153
+ const byTask = {};
154
+ const seen = new Set();
155
+ for (const event of events) {
156
+ if (!['task.result_returned', 'task.completed', 'task.failed'].includes(event.type)) continue;
157
+ const result = event.payload?.result ?? {};
158
+ const metrics = result.metrics ?? event.payload?.metrics ?? {};
159
+ const attemptId = result.attemptId ?? event.payload?.attemptId ?? event.id;
160
+ if (attemptId && seen.has(attemptId)) continue;
161
+ if (attemptId) seen.add(attemptId);
162
+ const input = metricNumber(metrics.inputTokens ?? metrics.promptTokens ?? metrics.prompt_tokens);
163
+ const output = metricNumber(metrics.outputTokens ?? metrics.completionTokens ?? metrics.completion_tokens);
164
+ const total = metricNumber(metrics.totalTokens ?? metrics.total_tokens ?? metrics.tokens);
165
+ if (input != null) { inputTokens += input; inputKnown = true; }
166
+ if (output != null) { outputTokens += output; outputKnown = true; }
167
+ if (total != null) { totalTokens += total; totalKnown = true; }
168
+ else if (input != null || output != null) { totalTokens += (input ?? 0) + (output ?? 0); totalKnown = true; }
169
+ const taskId = String(event.taskId ?? event.payload?.taskId ?? '');
170
+ if (taskId && (input != null || output != null || total != null)) {
171
+ const current = byTask[taskId] ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0, inputKnown: false, outputKnown: false, totalKnown: false };
172
+ if (input != null) { current.inputTokens += input; current.inputKnown = true; }
173
+ if (output != null) { current.outputTokens += output; current.outputKnown = true; }
174
+ current.totalTokens += total ?? ((input ?? 0) + (output ?? 0));
175
+ current.totalKnown = true;
176
+ byTask[taskId] = current;
177
+ }
178
+ }
179
+ return { inputTokens, outputTokens, totalTokens, inputKnown, outputKnown, totalKnown, byTask };
180
+ }
181
+
182
+ function metricNumber(value) {
183
+ const number = Number(value);
184
+ return Number.isFinite(number) && number >= 0 ? number : null;
185
+ }
186
+
115
187
  function currentRun(state, events) {
116
188
  const runId = state.runId ?? state.runs?.find((run) => isActiveStatus(run.status))?.id ?? events.findLast?.((event) => event.runId)?.runId ?? null;
117
189
  if (!runId && !state.status) return null;
@@ -64,3 +64,60 @@ test('projectWorkflow reports approval and queue waiting reasons', () => {
64
64
  assert.ok(workflow.waitingReasons.includes('approval:approval-1'));
65
65
  assert.ok(workflow.waitingReasons.includes('queue:queued-1'));
66
66
  });
67
+
68
+ test('projectWorkflow aggregates input and output tokens once per attempt', () => {
69
+ const state = {
70
+ status: 'done',
71
+ runId: 'run-usage',
72
+ plan: [{ id: 'build', description: 'Build', status: 'done' }],
73
+ activities: [],
74
+ queue: [],
75
+ approvals: [],
76
+ };
77
+ const result = {
78
+ attemptId: 'attempt-1',
79
+ metrics: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 },
80
+ };
81
+ const workflow = projectWorkflow(state, [
82
+ { id: 'event-1', type: 'task.result_returned', runId: 'run-usage', taskId: 'build', payload: { result } },
83
+ { id: 'event-2', type: 'task.completed', runId: 'run-usage', taskId: 'build', payload: { result } },
84
+ ]);
85
+
86
+ assert.deepEqual(workflow.usage, {
87
+ inputTokens: 1200,
88
+ outputTokens: 300,
89
+ totalTokens: 1500,
90
+ inputKnown: true,
91
+ outputKnown: true,
92
+ totalKnown: true,
93
+ byTask: {
94
+ build: {
95
+ inputTokens: 1200,
96
+ outputTokens: 300,
97
+ totalTokens: 1500,
98
+ inputKnown: true,
99
+ outputKnown: true,
100
+ totalKnown: true,
101
+ },
102
+ },
103
+ });
104
+ });
105
+
106
+ test('projectWorkflow derives per-task timing (start, finish, duration) from lifecycle events', () => {
107
+ const state = {
108
+ status: 'done',
109
+ runId: 'run-timing',
110
+ plan: [{ id: 'ingest', description: 'Ingest', status: 'done' }],
111
+ activities: [],
112
+ queue: [],
113
+ approvals: [],
114
+ };
115
+ const workflow = projectWorkflow(state, [
116
+ { id: 'e1', type: 'task.started', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:00.000Z', payload: {} },
117
+ { id: 'e2', type: 'task.completed', runId: 'run-timing', taskId: 'ingest', ts: '2026-07-23T10:00:12.500Z', payload: {} },
118
+ ]);
119
+
120
+ assert.equal(workflow.timingByTask.ingest.startedAt, Date.parse('2026-07-23T10:00:00.000Z'));
121
+ assert.equal(workflow.timingByTask.ingest.finishedAt, Date.parse('2026-07-23T10:00:12.500Z'));
122
+ assert.equal(workflow.timingByTask.ingest.durationMs, 12500);
123
+ });
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { promisify } from 'node:util';
6
- import { managerEnvFile, readEnvFile, userManagerDir } from './env.js';
6
+ import { managerEnvFile, managerMcpEndpointsFile, readEnvFile, userManagerDir } from './env.js';
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const packageRoot = resolve(__dirname, '../..');
@@ -70,15 +70,23 @@ export async function createWorkspace(name, targetPath = null, options = {}) {
70
70
  }
71
71
  const args = ['config', name];
72
72
  if (targetPath) args.push(targetPath);
73
+ const stateDir = dirname(managerEnvFile());
73
74
  const { stdout, stderr } = await execFileAsync(
74
75
  join(managerRoot(), 'wiki-workspace'),
75
76
  args,
76
77
  {
77
- cwd: managerRoot(),
78
+ // Relative Compose mounts and default scaffold paths must resolve from
79
+ // the user's manager state, never from the globally installed package.
80
+ cwd: stateDir,
78
81
  env: {
79
82
  ...process.env,
80
83
  WIKI_WORKSPACES_DIR: workspacesDir(),
81
84
  WIKI_MANAGER_ENV_FILE: managerEnvFile(),
85
+ // The script runs from the installed package directory so it can find
86
+ // its Compose templates. Pin mutable manager state to the user's
87
+ // launch directory; a global npm package is commonly owned by root
88
+ // and must never become the destination for this scaffold.
89
+ WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile(),
82
90
  },
83
91
  maxBuffer: options.maxBuffer ?? 1024 * 1024 * 8,
84
92
  timeout: options.timeout ?? 600_000,
@@ -3,6 +3,9 @@ import { approvalCovered } from './approvalPolicy.js';
3
3
 
4
4
  const DONE_STATUSES = new Set(['done', 'completed', 'complete', 'success', 'succeeded']);
5
5
  const TERMINAL_STATUSES = new Set([...DONE_STATUSES, 'failed', 'cancelled', 'canceled', 'skipped']);
6
+ // A task in one of these statuses hasn't run yet but could become ready —
7
+ // shared with runner.js's scheduler-stall check so the two can't drift apart.
8
+ export const PENDING_STATUSES = new Set(['pending', 'pending_approval', 'waiting_approval']);
6
9
 
7
10
  export function readyTasks(dag, {
8
11
  registry = null,
@@ -18,7 +21,7 @@ export function readyTasks(dag, {
18
21
  .filter((task) => {
19
22
  const status = statusOf(task);
20
23
  return status === 'pending'
21
- || ((status === 'waiting_approval' || status === 'pending_approval')
24
+ || (PENDING_STATUSES.has(status)
22
25
  && approvalCovered(task, approvals, {
23
26
  runId: task?.runId ?? dag?.runId ?? null,
24
27
  workspaceId: dag?.workspace ?? null,
@@ -35,6 +38,21 @@ export function readyTasks(dag, {
35
38
  .sort(compareTaskPriority);
36
39
  }
37
40
 
41
+ export function tasksAwaitingApproval(dag, { approvals = [] } = {}) {
42
+ const tasks = normalizeTasks(dag);
43
+ const done = new Set(tasks.filter((task) => DONE_STATUSES.has(statusOf(task))).map(taskId));
44
+ return tasks
45
+ .filter((task) => PENDING_STATUSES.has(statusOf(task)))
46
+ .filter((task) => task?.requiresApproval === true)
47
+ .filter((task) => !approvalCovered(task, approvals, {
48
+ runId: task?.runId ?? dag?.runId ?? null,
49
+ workspaceId: dag?.workspace ?? null,
50
+ planRevision: dag?.planRevision ?? null,
51
+ }))
52
+ .filter((task) => dependenciesDone(task, done))
53
+ .filter((task) => groupBarrierSatisfied(task, tasks));
54
+ }
55
+
38
56
  function normalizeTasks(dag) {
39
57
  if (Array.isArray(dag)) return dag;
40
58
  if (Array.isArray(dag?.tasks)) return dag.tasks;
@@ -1,6 +1,8 @@
1
1
  export async function resolveObjective(objective, session) {
2
2
  const candidates = capabilityCandidates(session);
3
3
  if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
4
+ const deterministic = resolveMentionedRegistryOperation(objective, candidates);
5
+ if (deterministic) return selectionWithProvider(session, deterministic, candidates);
4
6
  const llm = session?.llm;
5
7
  if (!llm?.completeWithTools) throw new Error('Objective resolution requires the configured workspace LLM.');
6
8
 
@@ -25,6 +27,28 @@ export async function resolveObjective(objective, session) {
25
27
  if (!candidate.operations.includes(operation)) {
26
28
  throw new Error(`Objective resolver selected unsupported operation "${operation}" for ${capability}.`);
27
29
  }
30
+ return selectionWithProvider(session, { capability, operation }, candidates);
31
+ }
32
+
33
+ // Prefer an operation explicitly named by the user when that name resolves to
34
+ // exactly one entry in the live registry. This is deliberately generic: the
35
+ // resolver knows neither capability ids nor business verbs. Prefix matching
36
+ // covers natural inflections such as an operation name followed by a suffix.
37
+ function resolveMentionedRegistryOperation(objective, candidates) {
38
+ const words = String(objective ?? '')
39
+ .normalize('NFKD')
40
+ .replace(/\p{Diacritic}/gu, '')
41
+ .toLowerCase()
42
+ .match(/[a-z0-9]+/g) ?? [];
43
+ const matches = candidates.flatMap((candidate) => candidate.operations
44
+ .filter((operation) => String(operation).split(/[._-]+/).some((token) =>
45
+ token.length >= 4 && words.some((word) => word.startsWith(token))))
46
+ .map((operation) => ({ capability: candidate.id, operation })));
47
+ return matches.length === 1 ? matches[0] : null;
48
+ }
49
+
50
+ function selectionWithProvider(session, selection, candidates) {
51
+ const { capability, operation } = selection;
28
52
  const providers = providersFor(session, capability)
29
53
  .filter((provider) => !operation || (provider.capability?.supportedOperations ?? []).includes(operation))
30
54
  .sort((a, b) => String(a.agentInstanceId).localeCompare(String(b.agentInstanceId)));