@worca/app 1.4.0 → 1.5.0-rc.2

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 (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -242,6 +242,16 @@ async function serverIsUp() {
242
242
  * `worca … --at/--every/--cron`: write the ticket or the schedule. Returns
243
243
  * { ticket, schedule, spec } — the caller decides whether to --wait.
244
244
  */
245
+ // Schedule attribution (identity.mjs): the CLI is the machine itself, so it records 'local'.
246
+ const CLI_ACTOR = 'local';
247
+ /** A stored actor worth naming: a person, never 'local' or empty. */
248
+ const person = (v) => (typeof v === 'string' && v && v !== 'local' ? v : null);
249
+ /** The list's "by" column: only for rows a person created or changed. */
250
+ function byCol(x) {
251
+ const who = person(x.updatedBy) || person(x.createdBy);
252
+ return who ? ` · by ${who}` : '';
253
+ }
254
+
245
255
  export async function createFromFlags(flags, { projectDir, extras, promptText, spec, out, c }) {
246
256
  const title = fallbackTitle(flags, promptText);
247
257
  let ticket, schedule = null;
@@ -250,7 +260,7 @@ export async function createFromFlags(flags, { projectDir, extras, promptText, s
250
260
  const request = await requestFromFlags(flags, { projectDir, extras, promptText, stageId: id });
251
261
  ({ schedule, ticket } = createSchedule({
252
262
  id, title, projectDir, request, rule: spec.rule, overlap: spec.overlap, maxFailures: spec.maxFailures,
253
- ifMissed: spec.ifMissed, graceMin: spec.graceMin,
263
+ ifMissed: spec.ifMissed, graceMin: spec.graceMin, createdBy: CLI_ACTOR,
254
264
  }));
255
265
  out(`${c('green', 'Scheduled')} ${c('bold', schedule.id)} — ${describeRule(schedule.rule)} (${spec.tz})`);
256
266
  if (ticket) out(` Next run: ${formatInstant(Date.parse(ticket.runAt), spec.tz, { withYear: true })} (in ${formatCountdown(Date.parse(ticket.runAt) - Date.now())})`);
@@ -258,11 +268,11 @@ export async function createFromFlags(flags, { projectDir, extras, promptText, s
258
268
  const id = randomUUID();
259
269
  const request = await requestFromFlags(flags, { projectDir, extras, promptText, stageId: id });
260
270
  if (spec.after) {
261
- ticket = createTicket({ id, title, projectDir, request, after: { kind: spec.after.kind, id: spec.after.id }, afterPolicy: spec.afterPolicy, sourceFromPrevious: spec.sourceFromPrevious, ifMissed: spec.ifMissed, graceMin: spec.graceMin });
271
+ ticket = createTicket({ id, title, projectDir, request, after: { kind: spec.after.kind, id: spec.after.id }, afterPolicy: spec.afterPolicy, sourceFromPrevious: spec.sourceFromPrevious, ifMissed: spec.ifMissed, graceMin: spec.graceMin, createdBy: CLI_ACTOR });
262
272
  out(`${c('green', 'Scheduled')} ${c('bold', ticket.id.slice(0, 8))} after ‘${spec.after.title || spec.after.id.slice(0, 8)}’ (${spec.after.status})`);
263
273
  } else {
264
274
  ticket = createTicket({
265
- id, title, projectDir, runAtMs: spec.runAtMs, request, ifMissed: spec.ifMissed, graceMin: spec.graceMin,
275
+ id, title, projectDir, runAtMs: spec.runAtMs, request, ifMissed: spec.ifMissed, graceMin: spec.graceMin, createdBy: CLI_ACTOR,
266
276
  ...(flags.wait ? { ownerPid: process.pid, ownerHost: hostname() } : {}),
267
277
  });
268
278
  out(`${c('green', 'Scheduled')} ${c('bold', ticket.id.slice(0, 8))} for ${formatInstant(spec.runAtMs, spec.tz, { withYear: true })} (in ${formatCountdown(spec.runAtMs - Date.now())})`);
@@ -381,7 +391,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
381
391
  out(c('bold', 'Repeating'));
382
392
  for (const s of series) {
383
393
  const state = s.status === 'active' ? `next ${when(s.nextRunAt)}` : s.status === 'paused' ? c('yellow', s.pauseReason === 'failure_streak' ? `paused after ${s.failureStreak} failures` : 'paused') : 'ended';
384
- out(` ${s.id} ${s.sentence} · ${state} · ${where(s)} · ${s.title || ''}`);
394
+ out(` ${s.id} ${s.sentence} · ${state} · ${where(s)} · ${s.title || ''}${byCol(s)}`);
385
395
  }
386
396
  }
387
397
  if (tickets.length) {
@@ -389,7 +399,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
389
399
  for (const t of tickets) {
390
400
  const st = t.after && t.status === 'scheduled' ? 'waiting' : t.status === 'scheduled' ? `in ${formatCountdown(Date.parse(t.runAt) - Date.now()) || 'a moment'}` : t.status === 'missed' ? c('yellow', 'missed') : t.status;
391
401
  const held = t.ownerPid != null && t.status === 'scheduled' ? ' · held by a waiting terminal' : '';
392
- out(` ${t.id.slice(0, 8)} ${t.after ? `after ${afterLabel(t, { withStatus: false })}` : when(t.runAt)} · ${st} · ${where(t)} · ${t.title || ''}${held}`);
402
+ out(` ${t.id.slice(0, 8)} ${t.after ? `after ${afterLabel(t, { withStatus: false })}` : when(t.runAt)} · ${st} · ${where(t)} · ${t.title || ''}${byCol(t)}${held}`);
393
403
  }
394
404
  }
395
405
  const unread = unreadCount('schedule');
@@ -421,6 +431,8 @@ export async function cmdSchedule(argv, { out, c, fail }) {
421
431
  out(c('bold', item.title || label));
422
432
  out(` id ${item.id}`);
423
433
  out(` target ${where(item)}`);
434
+ if (person(item.createdBy)) out(` created by ${person(item.createdBy)}`);
435
+ if (person(item.updatedBy) && item.updatedBy !== item.createdBy) out(` changed by ${person(item.updatedBy)}`);
424
436
  if (kind === 'recurring') {
425
437
  out(` repeats ${item.sentence} (${item.tz})`);
426
438
  out(` status ${item.status}${item.pauseReason ? ` (${item.pauseReason.replace(/_/g, ' ')})` : ''}`);
@@ -446,7 +458,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
446
458
  const p = predecessorState(item.after, { policy: item.after.policy });
447
459
  if (!p.pipelineId || !previousBranchesOf(p.pipelineId)) fail(`Start ‘${p.title || 'the run before it'}’ first, or change its source branch`);
448
460
  }
449
- const t = kind === 'recurring' ? runScheduleNow(item.id) : requestRunNow(item.id);
461
+ const t = kind === 'recurring' ? runScheduleNow(item.id, { by: CLI_ACTOR }) : requestRunNow(item.id, { by: CLI_ACTOR });
450
462
  if (!t) fail(`${label} is ${item.status} and cannot be started`);
451
463
  out(`Asked ${c('bold', label)} to start now.`);
452
464
  if (!(await serverIsUp()) && t.ownerPid == null) out(c('yellow', 'Note: no Worca server is up — it starts as soon as `worca ui` runs.'));
@@ -455,7 +467,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
455
467
  if (verb === 'cancel') {
456
468
  if (kind === 'recurring') { deleteSchedule(item.id); out(`Deleted the repeating schedule ${c('bold', label)}.`); return 0; }
457
469
  if (item.scheduleId) fail(`${label} is the next run of ${item.scheduleId} — use: worca schedule skip ${item.scheduleId}`);
458
- if (!cancelTicket(item.id)) fail(`${label} is ${item.status} and can no longer be canceled`);
470
+ if (!cancelTicket(item.id, { by: CLI_ACTOR })) fail(`${label} is ${item.status} and can no longer be canceled`);
459
471
  out(`Canceled ${c('bold', label)}.`);
460
472
  return 0;
461
473
  }
@@ -474,7 +486,7 @@ export async function cmdSchedule(argv, { out, c, fail }) {
474
486
  const policy = rest.includes('--after-any') ? 'any' : (item.after ? item.after.policy : 'done');
475
487
  const r = resolveAfterRef({ kind: ref.kind, id: ref.id }, { projectDir: item.projectDir, workspaceId: item.workspaceId, policy, selfId: item.id });
476
488
  if (!r.ok) fail(r.error);
477
- if (!updateTicket(item.id, { after: { kind: r.after.kind, id: r.after.id }, afterPolicy: policy, ...(rest.includes('--source-from-previous') ? { sourceFromPrevious: true } : {}) })) fail(`${label} is ${item.status} and can no longer be moved`);
489
+ if (!updateTicket(item.id, { after: { kind: r.after.kind, id: r.after.id }, afterPolicy: policy, ...(rest.includes('--source-from-previous') ? { sourceFromPrevious: true } : {}) }, { by: CLI_ACTOR })) fail(`${label} is ${item.status} and can no longer be moved`);
478
490
  out(`Moved ${c('bold', label)} after ‘${r.after.title || r.after.id.slice(0, 8)}’.`);
479
491
  return 0;
480
492
  }
@@ -482,12 +494,13 @@ export async function cmdSchedule(argv, { out, c, fail }) {
482
494
  const at = parseAt(value, { nowMs: Date.now(), tz });
483
495
  if (!at.ok) fail(at.error);
484
496
  if (at.ms <= Date.now()) fail('--at: that time is in the past');
485
- if (!updateTicket(item.id, { runAtMs: at.ms })) fail(`${label} is ${item.status} and can no longer be moved`);
497
+ if (!updateTicket(item.id, { runAtMs: at.ms }, { by: CLI_ACTOR })) fail(`${label} is ${item.status} and can no longer be moved`);
486
498
  out(`Moved ${c('bold', label)} to ${formatInstant(at.ms, tz, { withYear: true })} (in ${formatCountdown(at.ms - Date.now())}).`);
487
499
  return 0;
488
500
  }
489
501
  if (kind !== 'recurring') fail(`${verb} applies to a repeating schedule; ${label} runs once`);
490
- const s = verb === 'skip' ? skipNext(item.id) : verb === 'pause' ? pauseSchedule(item.id) : resumeSchedule(item.id);
502
+ const opt = { by: CLI_ACTOR };
503
+ const s = verb === 'skip' ? skipNext(item.id, opt) : verb === 'pause' ? pauseSchedule(item.id, opt) : resumeSchedule(item.id, opt);
491
504
  if (!s) fail(`${label} is ${item.status}`);
492
505
  out(verb === 'skip' ? `Skipped. Next run: ${when(s.nextRunAt)}.` : verb === 'pause' ? `Paused ${c('bold', label)}.` : `Resumed ${c('bold', label)}. Next run: ${when(s.nextRunAt)}.`);
493
506
  return 0;
@@ -39,6 +39,7 @@ import { pauseExitCode, describePauseReason, promptOptions, REASON } from '../co
39
39
  import { effectiveDebugSpawn } from '../core/settings.mjs';
40
40
  import { SCHEDULE_VALUE_FLAGS, wantsSchedule, readScheduleFlags, createFromFlags, waitAndRun, cmdSchedule } from './schedule.mjs';
41
41
  import { cmdModels } from './models.mjs';
42
+ import { cmdContainer } from './container.mjs';
42
43
  import {
43
44
  DEFAULT_UI_HOST, DEFAULT_UI_PORT, probeUi, stopUi, readUiInstance, uiUrl, waitForUiState,
44
45
  } from '../core/ui-instance.mjs';
@@ -251,6 +252,8 @@ Usage:
251
252
 
252
253
  Subcommands:
253
254
  add [name] [--path <dir>] Register a project. Defaults: name = basename(path), path = cwd.
255
+ add --clone <https-url> [--branch <b>] [--name <folder>]
256
+ Clone a repository into the projects folder and register it.
254
257
  list List registered projects (tab-separated; missing dirs are flagged).
255
258
  remove <name> Remove a registered project by name (case-insensitive).
256
259
  resume <pipelineId> Continue a paused pipeline (re-attaches Claude sessions).
@@ -266,11 +269,14 @@ Subcommands:
266
269
  Run the web UI (default http://localhost:4317). See: worca ui help
267
270
  workflow <cmd> [...] Export a workflow (Claude Code skill, JSON, or plugin) / import JSON: list|export|import. See: worca workflow help
268
271
  metrics push [--project <path>] Push pending team-metrics run records (headless flush)
272
+ metrics pr-workflow [--project <path>] Add the GitHub Action that records PR merges for the Timeline
269
273
  policy <cmd> [...] Team policy from the worca-policy branch: show|pull|init|setup. See: worca policy help
270
274
  schedule <cmd> [...] Manage scheduled runs: list|show|run-now|move|cancel|skip|pause|resume|log.
271
275
  See: worca schedule help
272
276
  models <cmd> [...] Model catalog + providers: list|providers|login|logout|import|test|set.
273
277
  See: worca models help
278
+ container <cmd> [...] Run Worca in a container: init|up|down|status|logs|pull|login|shell|run|where.
279
+ See: worca container help (docs/docker.md)
274
280
  help Print this help (same as --help).
275
281
  version Print the version (same as --version).
276
282
 
@@ -1049,10 +1055,12 @@ function runInstall(targetDir, passthrough) {
1049
1055
 
1050
1056
  // ── project registry subcommands ──────────────────────────────────────────────
1051
1057
 
1052
- /** Parse a tiny argv slice for the `add` subcommand. Supports --path/--path=<dir>. */
1058
+ /** Parse a tiny argv slice for the `add` subcommand. Supports --path/--path=<dir> and
1059
+ * --clone <url> [--branch <b>] [--name <folder>]. */
1053
1060
  function parseAddArgs(argv) {
1054
1061
  const positionals = [];
1055
1062
  let pathArg = null;
1063
+ const clone = { url: null, branch: null, name: null };
1056
1064
  for (let i = 0; i < argv.length; i++) {
1057
1065
  let a = argv[i];
1058
1066
  let inline;
@@ -1065,17 +1073,44 @@ function parseAddArgs(argv) {
1065
1073
  const v = inline !== undefined ? inline : argv[++i];
1066
1074
  if (v === undefined) fail('Flag --path requires a value.');
1067
1075
  pathArg = v;
1076
+ } else if (a === '--clone' || a === '--branch' || a === '--name') {
1077
+ const v = inline !== undefined ? inline : argv[++i];
1078
+ if (v === undefined) fail(`Flag ${a} requires a value.`);
1079
+ clone[a === '--clone' ? 'url' : a.slice(2)] = v;
1068
1080
  } else if (a.startsWith('-')) {
1069
1081
  fail(`Unknown flag: ${a}`);
1070
1082
  } else {
1071
1083
  positionals.push(a);
1072
1084
  }
1073
1085
  }
1074
- return { name: positionals[0], path: pathArg };
1086
+ if ((clone.branch || clone.name) && !clone.url) fail('--branch and --name go with --clone.');
1087
+ if (clone.url && pathArg) fail('--clone and --path cannot be combined: the clone goes into the projects folder.');
1088
+ return { name: positionals[0], path: pathArg, clone: clone.url ? clone : null };
1089
+ }
1090
+
1091
+ /** `worca add --clone`: the same path as the UI's Clone from URL (src/core/clone-project.mjs). */
1092
+ async function cmdAddClone(clone, positionalName) {
1093
+ const { cloneProject } = await import('../core/clone-project.mjs');
1094
+ const { getProjectsRoot } = await import('../core/settings.mjs');
1095
+ try {
1096
+ out(`Cloning ${clone.url} …`);
1097
+ const { project } = await cloneProject({ url: clone.url, branch: clone.branch, name: clone.name || positionalName || null },
1098
+ { projectsRoot: getProjectsRoot(), listProjects, addProject });
1099
+ out(`Added project "${project.name}" -> ${project.path}`);
1100
+ try {
1101
+ const m = await import('../core/metrics/sync.mjs');
1102
+ await m.discoverProject(project.path, { force: true });
1103
+ } catch { /* metrics never block `worca add` */ }
1104
+ return 0;
1105
+ } catch (err) {
1106
+ process.stderr.write(`worca: ${err?.message || err}${err?.code ? ` (${err.code})` : ''}\n`);
1107
+ return 1;
1108
+ }
1075
1109
  }
1076
1110
 
1077
1111
  async function cmdAdd(argv) {
1078
- const { name: rawName, path: rawPath } = parseAddArgs(argv);
1112
+ const { name: rawName, path: rawPath, clone } = parseAddArgs(argv);
1113
+ if (clone) return cmdAddClone(clone, rawName);
1079
1114
  // Always route through normalizeProjectPath so display, storage, and
1080
1115
  // basename() all see exactly the same string addProject will persist.
1081
1116
  const target = normalizeProjectPath(rawPath) || resolve(process.cwd());
@@ -2879,6 +2914,12 @@ const METRICS_HELP = `worca metrics — team metrics (git-backed, team-wide run
2879
2914
  Usage:
2880
2915
  worca metrics push [--project <path>] Flush pending run records to their worca-metrics branch.
2881
2916
  Without --project, every outbox on this machine is flushed.
2917
+ worca metrics pr-workflow [--project <path>] [--force] [--print]
2918
+ Add the GitHub Action that records pull-request events
2919
+ (opened, merged, closed) on worca-metrics, so the Team
2920
+ metrics Timeline knows when work shipped without gh.
2921
+ Commit and push the file afterwards. --print writes it
2922
+ to stdout instead.
2882
2923
  worca metrics help
2883
2924
 
2884
2925
  Exit codes: 0 all pushed (or nothing pending) · 1 at least one outbox could not be pushed.
@@ -2912,6 +2953,22 @@ async function cmdMetrics(argv) {
2912
2953
  }
2913
2954
  return failed ? 1 : 0;
2914
2955
  }
2956
+ case 'pr-workflow': {
2957
+ const a = pluginArgs(rest, ['--project'], ['--force', '--print']);
2958
+ if (a._.length) fail(`unexpected argument "${a._[0]}" — see: worca metrics help`);
2959
+ const prs = await import('../core/metrics/prs.mjs');
2960
+ if (a.print) { process.stdout.write(await prs.prWorkflowText()); return 0; }
2961
+ const dir = resolve(a.project || process.cwd());
2962
+ const r = await prs.installPrWorkflow(dir, { force: !!a.force });
2963
+ const rel = prs.PR_WORKFLOW_PATH;
2964
+ if (r.status === 'differs') {
2965
+ process.stderr.write(`worca metrics pr-workflow: ${rel} already exists and differs — re-run with --force to replace it\n`);
2966
+ return 1;
2967
+ }
2968
+ out(r.status === 'unchanged' ? `${c('green', '✓')} ${rel} is up to date` : `${c('green', '✓')} ${r.status} ${rel}`);
2969
+ if (r.status !== 'unchanged') out(` Commit and push it to the default branch. Run it once from the Actions tab ("Run workflow") to backfill recent pull requests.`);
2970
+ return 0;
2971
+ }
2915
2972
  default:
2916
2973
  fail(`unknown metrics verb "${verb}" — see: worca metrics help`);
2917
2974
  }
@@ -3047,7 +3104,7 @@ async function drainMetricsFlushes() {
3047
3104
 
3048
3105
  // ── main ──────────────────────────────────────────────────────────────────────────
3049
3106
 
3050
- const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow', 'metrics', 'script', 'policy', 'schedule', 'models']);
3107
+ const SUBCOMMANDS = new Set(['add', 'list', 'remove', 'resume', 'doctor', 'plugin', 'marketplace', 'config', 'ui', 'workflow', 'metrics', 'script', 'policy', 'schedule', 'models', 'container']);
3051
3108
 
3052
3109
  /** Levenshtein distance, two-row. Only ever called on short argv tokens. */
3053
3110
  function editDistance(a, b) {
@@ -3112,6 +3169,7 @@ async function main() {
3112
3169
  if (sub === 'policy') return cmdPolicy(rest);
3113
3170
  if (sub === 'schedule') return cmdSchedule(rest, { out, c, fail });
3114
3171
  if (sub === 'models') return cmdModels(rest, { out, c, fail });
3172
+ if (sub === 'container') return cmdContainer(rest, { out, c, fail });
3115
3173
  }
3116
3174
  // `worca --ui [...]` is the historical spelling of `worca ui start [...]`; hand the
3117
3175
  // remaining tokens to the ui parser so --port/--open/--mock work with either.
@@ -0,0 +1,92 @@
1
+ // Agents under their own uid (docker/entrypoint.sh, single-volume hosts).
2
+ //
3
+ // Worca never hands an agent a credential (github-credentials.mjs), but an agent
4
+ // running as the server's own user could still read the server's environment
5
+ // through /proc/<pid>/environ, or worca's settings and database. So on a hosted
6
+ // container the entrypoint creates the boundary and tells the server about it:
7
+ //
8
+ // WORCA_AGENT_USER the user agents run as (worca-agent)
9
+ // WORCA_AGENT_HOME that user's HOME (its own ~/.claude, npm and pip caches)
10
+ // WORCA_AGENT_GID the group server and agent share (projects, runs, store)
11
+ //
12
+ // Each agent spawn becomes `sudo -n -E -u <user> -- <bin> …` (a sudoers rule
13
+ // lets the server switch to that one user and nothing else). Unset = agents run
14
+ // as the server, as on every local install.
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import { chmodSync, chownSync, readdirSync, statSync } from 'node:fs';
17
+ import { delimiter, isAbsolute, join } from 'node:path';
18
+
19
+ const USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
20
+
21
+ /** { user, home, gid } when agent isolation is on, else null. Pure: reads only `env`. */
22
+ export function agentIdentity(env = process.env) {
23
+ const user = String(env.WORCA_AGENT_USER || '').trim();
24
+ const home = String(env.WORCA_AGENT_HOME || '').trim();
25
+ if (!USER_RE.test(user) || !isAbsolute(home)) return null;
26
+ const gid = Number.parseInt(env.WORCA_AGENT_GID, 10);
27
+ return { user, home, gid: Number.isInteger(gid) && gid > 0 ? gid : null };
28
+ }
29
+
30
+ /** An absolute path for `bin` from PATH (sudo's secure_path must not change which binary runs). */
31
+ export function resolveOnPath(bin, pathVar = process.env.PATH || '', isFile = defaultIsFile) {
32
+ if (isAbsolute(bin)) return bin;
33
+ if (bin.includes('/')) return null;
34
+ for (const dir of String(pathVar).split(delimiter)) {
35
+ if (dir && isAbsolute(dir) && isFile(join(dir, bin))) return join(dir, bin);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function defaultIsFile(p) {
41
+ try { return statSync(p).isFile(); } catch { return false; }
42
+ }
43
+
44
+ /**
45
+ * The spawn for one agent command under `id`: `sudo -n -E -u <user> -- <abs bin> …args`,
46
+ * with the agent's HOME, USER and LOGNAME in its env. Throws when `bin` cannot be resolved,
47
+ * rather than running something else.
48
+ */
49
+ export function agentSpawn(bin, args, env, id, { isFile = defaultIsFile } = {}) {
50
+ const abs = resolveOnPath(bin, env.PATH || process.env.PATH || '', isFile);
51
+ if (!abs) throw new Error(`cannot run ${bin} as ${id.user}: not found on PATH`);
52
+ return {
53
+ file: 'sudo',
54
+ args: ['-n', '-E', '-u', id.user, '--', abs, ...args],
55
+ env: { ...env, HOME: id.home, USER: id.user, LOGNAME: id.user },
56
+ };
57
+ }
58
+
59
+ /** SIGKILL every process of the agent's group `pgid` (the server cannot signal another uid;
60
+ * sudo relays SIGTERM, but a SIGKILLed sudo would orphan its command). Best effort. */
61
+ export function killAgentGroup(pgid, id, spawnImpl = spawn) {
62
+ if (!id || !Number.isInteger(pgid) || pgid <= 1) return;
63
+ try {
64
+ const c = spawnImpl('sudo', ['-n', '-u', id.user, '--', 'kill', '-KILL', '--', `-${pgid}`], { stdio: 'ignore' });
65
+ c.on?.('error', () => {});
66
+ c.unref?.();
67
+ } catch { /* nothing left to kill */ }
68
+ }
69
+
70
+ /** killAgentGroup, synchronously: for a process 'exit' listener, which cannot wait. */
71
+ export function killAgentGroupSync(pgid, id, spawnSyncImpl = spawnSync) {
72
+ if (!id || !Number.isInteger(pgid) || pgid <= 1) return;
73
+ try {
74
+ spawnSyncImpl('sudo', ['-n', '-u', id.user, '--', 'kill', '-KILL', '--', `-${pgid}`], { stdio: 'ignore', timeout: 5000 });
75
+ } catch { /* nothing left to kill */ }
76
+ }
77
+
78
+ /** Let the agent read a directory the server made for one spawn (staged prompt files):
79
+ * the shared group, group read/traverse. Best effort; a failure surfaces as the agent's
80
+ * own read error. */
81
+ export function shareWithAgent(dir, id) {
82
+ if (!id || id.gid == null) return;
83
+ try {
84
+ chownSync(dir, -1, id.gid);
85
+ chmodSync(dir, 0o750);
86
+ for (const name of readdirSync(dir)) {
87
+ const p = join(dir, name);
88
+ chownSync(p, -1, id.gid);
89
+ chmodSync(p, 0o640);
90
+ }
91
+ } catch { /* the agent reports what it cannot read */ }
92
+ }
@@ -21,6 +21,7 @@ import { RUN_LOG_FILE } from './run-log.mjs';
21
21
  import { readRunLedger } from './metrics/ledger.mjs';
22
22
  import { readPolicyState } from './policy/state.mjs';
23
23
  import { memoryTotals } from './memory-sync.mjs';
24
+ import { actorLabel } from './identity.mjs';
24
25
 
25
26
  // ── DB row <-> state object mapping (Phase 3) ──────────────────────────────────
26
27
  // JSON columns are TEXT; (de)serialize at THIS boundary only. Reads are fail-safe:
@@ -269,6 +270,12 @@ function formAskOf(qWrap, aWrap) {
269
270
 
270
271
  /** The two form fields a reader row carries — `{}` for a legacy row, so no legacy
271
272
  * payload gains a key (test/step-questions-db.test.mjs pins the exact row shape). */
273
+ /** `{answeredBy}` when the answer row names who answered (identity.mjs actor), else {} —
274
+ * additive, so a row answered before attribution keeps its exact wire shape. */
275
+ function answeredByOf(aWrap) {
276
+ return aWrap && typeof aWrap.answeredBy === 'string' && aWrap.answeredBy ? { answeredBy: aWrap.answeredBy } : {};
277
+ }
278
+
272
279
  function formFieldsOf(qWrap, aWrap) {
273
280
  const ask = formAskOf(qWrap, aWrap);
274
281
  return ask ? { ask, formAnswer: formAnswerOf(aWrap) } : {};
@@ -305,6 +312,7 @@ export function readStepQuestions(pipelineId) {
305
312
  // gains no key at all, so its wire shape (History, get_run_progress) stays
306
313
  // byte-identical; consumers test `row.ask`, never `'ask' in row`.
307
314
  ...formFieldsOf(qWrap, aWrap),
315
+ ...answeredByOf(aWrap),
308
316
  };
309
317
  });
310
318
  }
@@ -384,6 +392,7 @@ export function readPipelineExtras(pipelineId) {
384
392
  questions: Array.isArray(qWrap?.questions) ? qWrap.questions : [],
385
393
  answers: Array.isArray(aWrap?.answers) ? aWrap.answers : [],
386
394
  ...formFieldsOf(qWrap, aWrap), // spec §9: `ask` + `formAnswer` on a form row only
395
+ ...answeredByOf(aWrap),
387
396
  };
388
397
  const reviews = getDb().prepare(
389
398
  'SELECT kind, cycle, verdict FROM reviews WHERE pipeline_id = ? ORDER BY kind, cycle'
@@ -933,7 +942,7 @@ export async function createPipeline(projectDir, opts = {}) {
933
942
  const {
934
943
  prompt, promptFile, extras = [], title,
935
944
  promptText: precomputedPromptText = null, sourceType = null, sourceMeta = null,
936
- guardrailsId = null,
945
+ guardrailsId = null, startedBy = null,
937
946
  workspaceKey = null, workspaceId = null, workspaceName = null,
938
947
  workspaceDescription = '', projects = null,
939
948
  } = opts;
@@ -1030,6 +1039,8 @@ export async function createPipeline(projectDir, opts = {}) {
1030
1039
  // unguarded runs). Creation-immutable, like sourceType: written on INSERT,
1031
1040
  // never touched by updates. NULL = legacy/pre-entity or non-orchestrator row.
1032
1041
  guardrailsId: guardrailsId || null,
1042
+ // Who started the run (identity.mjs): creation-immutable like guardrailsId. NULL = unknown.
1043
+ startedBy: startedBy || null,
1033
1044
  };
1034
1045
 
1035
1046
  // Workspace runs carry the §5.2 superset, discriminated by target:'workspace'.
@@ -1095,15 +1106,30 @@ function firstMeaningfulLine(text) {
1095
1106
  * @param {string} markdownLine
1096
1107
  * @returns {Promise<void>}
1097
1108
  */
1098
- export async function appendAudit(pipelineDir, markdownLine) {
1099
- const id = resolvePipelineId(pipelineDir);
1109
+ export async function appendAudit(pipelineDir, markdownLine, { actor = null } = {}) {
1110
+ appendAuditById(resolvePipelineId(pipelineDir), markdownLine, { actor });
1111
+ }
1112
+
1113
+ /**
1114
+ * appendAudit by pipeline id (the server's human actions: PRs, archive, cap overrides).
1115
+ * `actor` = who did it (identity.mjs; 'local' allowed), stored in its own column always;
1116
+ * the line's text names the person only when they are not 'local' (byActor), so the
1117
+ * History view and the markdown export read "Paused by ada@example.com." Best-effort.
1118
+ */
1119
+ export function appendAuditById(id, markdownLine, { actor = null } = {}) {
1100
1120
  if (!id) return;
1101
1121
  const ts = new Date().toISOString();
1102
1122
  const text = String(markdownLine ?? '').trim();
1123
+ const who = typeof actor === 'string' && actor ? actor.slice(0, 200) : null;
1103
1124
  try {
1104
1125
  tx(() => {
1105
- getDb().prepare('INSERT INTO pipeline_events (pipeline_id, ts, text) VALUES (?, ?, ?)')
1106
- .run(id, ts, text);
1126
+ if (who) {
1127
+ getDb().prepare('INSERT INTO pipeline_events (pipeline_id, ts, text, actor) VALUES (?, ?, ?, ?)')
1128
+ .run(id, ts, text, who);
1129
+ } else {
1130
+ getDb().prepare('INSERT INTO pipeline_events (pipeline_id, ts, text) VALUES (?, ?, ?)')
1131
+ .run(id, ts, text);
1132
+ }
1107
1133
  });
1108
1134
  } catch { /* audit is best-effort; never break a run on a logging failure */ }
1109
1135
  }
@@ -1164,11 +1190,11 @@ export async function writeState(pipelineDir, stateObj) {
1164
1190
  INSERT INTO pipelines (id, project_key, workspace_key, target, title, base_name,
1165
1191
  date_prefix, status, phase, cycle, started_at, updated_at, total_cost_usd,
1166
1192
  total_active_ms, prompt, branch, workspace_meta, stepper, tools, resume_point,
1167
- source_type, source_ref, guardrails_id, outcome, human_hours)
1193
+ source_type, source_ref, guardrails_id, outcome, human_hours, started_by)
1168
1194
  VALUES (@id,@project_key,@workspace_key,@target,@title,@base_name,@date_prefix,
1169
1195
  @status,@phase,@cycle,@started_at,@updated_at,@total_cost_usd,@total_active_ms,
1170
1196
  @prompt,@branch,@workspace_meta,@stepper,@tools,@resume_point,
1171
- @source_type,@source_ref,@guardrails_id,@outcome,@human_hours)
1197
+ @source_type,@source_ref,@guardrails_id,@outcome,@human_hours,@started_by)
1172
1198
  ON CONFLICT(id) DO UPDATE SET
1173
1199
  status=excluded.status, phase=excluded.phase, cycle=excluded.cycle,
1174
1200
  updated_at=excluded.updated_at, total_cost_usd=excluded.total_cost_usd,
@@ -1552,6 +1578,7 @@ function toPipelineRow(o) {
1552
1578
  source_type: o.sourceType ?? 'prompt',
1553
1579
  source_ref: s(o.sourceMeta),
1554
1580
  guardrails_id: o.guardrailsId ?? null,
1581
+ started_by: o.startedBy ?? null,
1555
1582
  // §5.9 outcome: the derived run-level v2 facts, so a rehydrated state matches
1556
1583
  // a live one. NULL for a v1 run (nothing to say), so v1 rows are unchanged.
1557
1584
  outcome: (o.engine === 2 || o.endReached !== undefined)
@@ -1668,6 +1695,7 @@ async function rowToHistoryEntry(row, repoDir = null, opts = {}) {
1668
1695
  branch: feature,
1669
1696
  sourceBranch: source,
1670
1697
  guardrailsId: row.guardrails_id ?? null,
1698
+ startedBy: row.started_by ?? null,
1671
1699
  pauseReason: row.pause_reason ?? null,
1672
1700
  pauseDetail: row.pause_detail ?? null,
1673
1701
  retainedWork: retainedWorkFor(row),
@@ -1727,7 +1755,7 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
1727
1755
  const dirById = await runDirIndex(pipelinesDir);
1728
1756
  const rows = getDb().prepare(`
1729
1757
  SELECT id, project_key, target, title, status, started_at, updated_at, total_cost_usd, total_active_ms,
1730
- branch, workspace_meta, guardrails_id, pr_url,
1758
+ branch, workspace_meta, guardrails_id, started_by, pr_url,
1731
1759
  json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
1732
1760
  json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
1733
1761
  FROM pipelines
@@ -1756,7 +1784,7 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
1756
1784
  export async function listAllPipelines(opts = {}, { batchSize = 16 } = {}) {
1757
1785
  const rows = getDb().prepare(`
1758
1786
  SELECT id, project_key, workspace_key, target, title, status, started_at, updated_at,
1759
- total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id, pr_url,
1787
+ total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id, started_by, pr_url,
1760
1788
  json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
1761
1789
  json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
1762
1790
  FROM pipelines
@@ -1938,6 +1966,7 @@ function rowToState(row) {
1938
1966
  stepper: j(row.stepper, null),
1939
1967
  tools: j(row.tools, null),
1940
1968
  guardrailsId: row.guardrails_id ?? null,
1969
+ startedBy: row.started_by ?? null,
1941
1970
  // v31 provenance: set when a schedule started this run (NULL = started by hand).
1942
1971
  scheduledFor: row.scheduled_for ?? null,
1943
1972
  scheduleId: row.schedule_id ?? null,
@@ -1957,6 +1986,8 @@ function rowToState(row) {
1957
1986
  // too, so a deep-linked History detail no longer waits for the LIST row.
1958
1987
  const rp = j(row.resume_point, null);
1959
1988
  state.pauseReason = typeof rp?.pauseReason === 'string' ? rp.pauseReason : null;
1989
+ // Who paused it (run-harness _recordAction): { kind, by, at } or null.
1990
+ state.lastAction = rp && rp.lastAction && typeof rp.lastAction.by === 'string' ? { ...rp.lastAction } : null;
1960
1991
  state.pauseDetail = typeof rp?.pauseDetail === 'string' ? rp.pauseDetail : null;
1961
1992
  const outcome = j(row.outcome, null);
1962
1993
  if (outcome) {
@@ -2008,6 +2039,7 @@ function buildAuditMarkdown(row) {
2008
2039
  `- **id**: ${row.id}\n` +
2009
2040
  `- **project**: ${(readStoreMeta(row.project_key)?.path) ?? ''}\n` +
2010
2041
  `- **started**: ${row.started_at ?? ''}\n` +
2042
+ (actorLabel(row.started_by) ? `- **started by**: ${actorLabel(row.started_by)}\n` : '') +
2011
2043
  `- **prompt file**: prompt.md\n\n` +
2012
2044
  `## Prompt\n\n` +
2013
2045
  (row.prompt && row.prompt.trim() ? row.prompt.trim() + '\n' : '_(empty prompt)_\n') +
@@ -0,0 +1,25 @@
1
+ // src/core/ask/clone-deps.mjs
2
+ // Binds clone-proposal.mjs to the real projects folder and registry. The MCP child gets
3
+ // `clones.validateChange` (validate only, for the model's self-correction; it never sees a
4
+ // GitHub credential, so its card has no GitHub line); the parent turn re-validates with
5
+ // `validateCloneProposal`, whose card names how the clone authenticates.
6
+ import { getProjectsRoot } from '../settings.mjs';
7
+ import { listProjects } from '../projects.mjs';
8
+ import { githubMode } from '../deployment.mjs';
9
+ import { createCloneValidator, githubLabel } from './clone-proposal.mjs';
10
+
11
+ /** The parent's validator: the card carries the GitHub line (deployment.mjs, never a secret). */
12
+ export const validateCloneProposal = createCloneValidator({
13
+ projectsRoot: () => getProjectsRoot(),
14
+ listProjects: () => listProjects(),
15
+ github: (host) => githubLabel(githubMode(process.env), { host, appId: (process.env.WORCA_GH_APP_ID || '').trim() || null }),
16
+ });
17
+
18
+ /** The MCP child's bundle. */
19
+ export function defaultCloneDeps() {
20
+ return {
21
+ clones: {
22
+ validateChange: createCloneValidator({ projectsRoot: () => getProjectsRoot(), listProjects: () => listProjects() }),
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,79 @@
1
+ // src/core/ask/clone-proposal.mjs
2
+ // The ONE validator behind mcp__worca__propose_clone_project, and the event/notice text of the
3
+ // clone card (docs/deploy-railway.md "First project"). Pure: the projects root, the registered
4
+ // projects and the GitHub description are injected (clone-deps.mjs binds the real ones), so the
5
+ // MCP child validates for the model's self-correction and the parent turn re-validates
6
+ // authoritatively and mints the card — the model-proposal.mjs split. Nothing here clones:
7
+ // applying the card starts ui/server.mjs's clone job, behind the user's click.
8
+ import { planClone, CloneError } from '../clone-project.mjs';
9
+
10
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
11
+ // eslint-disable-next-line no-control-regex
12
+ const BREAKS_RE = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/g;
13
+ const clip = (v, n) => String(v ?? '').replace(BREAKS_RE, ' ').slice(0, n);
14
+
15
+ /** How the clone authenticates, in words for the card. `mode` = githubMode() (deployment.mjs). */
16
+ export function githubLabel(mode, { host = 'github.com', appId = null } = {}) {
17
+ if (host !== 'github.com') return `none (${host} is not GitHub): public repositories only`;
18
+ if (mode === 'app') return `GitHub App${appId ? ` ${appId}` : ''} (a read-only token for this clone)`;
19
+ if (mode === 'split') return 'the read token (WORCA_GH_READ_TOKEN)';
20
+ if (mode === 'single') return 'the deployment token (GH_TOKEN)';
21
+ return 'none: public repositories only';
22
+ }
23
+
24
+ /**
25
+ * @param {object} r
26
+ * @param {() => string} r.projectsRoot the effective projects folder
27
+ * @param {() => Promise<Array>} r.listProjects registered projects ({name, path})
28
+ * @param {object} [r.env] for WORCA_CLONE_ALLOW
29
+ * @param {(host:string) => string|null} [r.github] the card's GitHub line (null in the MCP child)
30
+ * @param {(p:string) => boolean} [r.exists]
31
+ */
32
+ export function createCloneValidator(r) {
33
+ /** @returns {Promise<{ok:true, card:object}|{ok:false, errors:string[]}>} */
34
+ return async function validateCloneProposal(input) {
35
+ const inp = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
36
+ let plan;
37
+ try {
38
+ plan = planClone({ url: inp.url, branch: str(inp.branch) || null, name: str(inp.name) || null },
39
+ { projectsRoot: r.projectsRoot(), env: r.env || process.env, ...(r.exists ? { exists: r.exists } : {}) });
40
+ } catch (err) {
41
+ return { ok: false, errors: [err instanceof CloneError ? err.message : String(err && err.message ? err.message : err)] };
42
+ }
43
+ const projects = await r.listProjects();
44
+ if ((projects || []).some((p) => p && typeof p.name === 'string' && p.name.toLowerCase() === plan.name.toLowerCase())) {
45
+ return { ok: false, errors: [`a project named "${plan.name}" already exists — pass another name`] };
46
+ }
47
+ const note = clip(str(inp.note), 200);
48
+ const github = typeof r.github === 'function' ? r.github(plan.host) : null;
49
+ return { ok: true, card: {
50
+ type: 'clone', kind: 'clone',
51
+ summary: `Clone ${plan.owner}/${plan.repo} as project ${plan.name}`,
52
+ url: plan.url, branch: plan.branch, name: plan.name, dir: plan.dir,
53
+ ...(github ? { github } : {}),
54
+ ...(note ? { note } : {}),
55
+ change: { url: plan.url, branch: plan.branch, name: plan.name },
56
+ } };
57
+ };
58
+ }
59
+
60
+ const eventText = (s, n) => clip(s, n).replace(/"/g, "'").replace(/\[(\/?)worca context\]/gi, '($1worca context)');
61
+
62
+ /** `[worca event] …` — the synthetic turn's prompt after the user acted on a clone card, or its clone ended. */
63
+ export function cloneEventPrompt({ cardId, state, card = {}, result = null }) {
64
+ const summary = eventText(card.summary, 200);
65
+ if (state === 'declined') return `[worca event] clone card ${cardId} declined; "${summary}"`;
66
+ if (state === 'failed') {
67
+ return `[worca event] clone card ${cardId} failed${result?.code ? ` (${eventText(result.code, 40)})` : ''}: ${eventText(result?.error || 'unknown error', 300)}; "${summary}"`;
68
+ }
69
+ const where = result?.project ? `; project ${eventText(result.project.name, 100)} at ${eventText(result.project.path, 300)}` : '';
70
+ return `[worca event] clone card ${cardId} applied: cloned and registered${where}; "${summary}"`;
71
+ }
72
+
73
+ /** The user-row notice above the event turn. */
74
+ export function cloneNoticeText({ state, card = {}, result = null }) {
75
+ const s = clip(card.summary, 160);
76
+ if (state === 'declined') return `Declined — ${s}`;
77
+ if (state === 'failed') return `Could not clone — ${s}: ${clip(result?.error || 'unknown error', 200)}`;
78
+ return `Cloned — ${s}${result?.project?.path ? ` · ${clip(result.project.path, 200)}` : ''}`;
79
+ }
@@ -126,6 +126,7 @@ export function labelForTool(name, input = {}, attachmentNames = {}) {
126
126
  const id = typeof input?.id === 'string' ? input.id : '';
127
127
  switch (n) {
128
128
  case 'list_runs': return 'Finding runs';
129
+ case 'list_people': return 'Looking up who ran what';
129
130
  case 'get_run':
130
131
  case 'get_run_diff': return id ? `Reading run ${id.slice(0, 12)}` : 'Reading run';
131
132
  case 'list_workflows': return 'Looking at workflows';
@@ -170,6 +171,7 @@ export function labelForTool(name, input = {}, attachmentNames = {}) {
170
171
  case 'test_provider': return input?.provider ? `Testing ${String(input.provider).slice(0, 20)}` : 'Testing a provider';
171
172
  case 'list_copilot_models': return 'Listing Copilot models';
172
173
  case 'propose_model_change': return 'Proposing a model change';
174
+ case 'propose_clone_project': return 'Proposing a project clone';
173
175
  default: return `Using ${n}`;
174
176
  }
175
177
  }
@@ -246,6 +248,7 @@ export function createTurnReducer({
246
248
  onPolicyProposal = null, // propose_policy_change RESULT (team policy card; same split)
247
249
  onScheduleProposal = null, // propose_schedule_change RESULT (schedule card; the parent re-validates the input)
248
250
  onModelProposal = null, // propose_model_change RESULT (model card; same split)
251
+ onCloneProposal = null, // propose_clone_project RESULT (clone card; same split)
249
252
  onScheduleMutation = null, // a direct schedule write succeeded in the MCP child
250
253
  onTrackRun = null,
251
254
  onCommentMutation = null,
@@ -606,6 +609,13 @@ export function createTurnReducer({
606
609
  if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
607
610
  } catch { reducerErrors += 1; }
608
611
  }
612
+ if (b.name === 'mcp__worca__propose_clone_project' && typeof onCloneProposal === 'function') {
613
+ // Same split as the model card: the parent re-validates the INPUT and adds how GitHub is reached (clone-proposal.mjs).
614
+ try {
615
+ const ret = onCloneProposal({ toolUseId: b.id, input: fullInputs.get(b.id) ?? {}, text, isError: !!c.is_error });
616
+ if (ret && typeof ret.then === 'function') pendingHooks.push(ret.then(() => {}, () => { reducerErrors += 1; }));
617
+ } catch { reducerErrors += 1; }
618
+ }
609
619
  if (b.name === 'mcp__worca__track_run' && typeof onTrackRun === 'function') {
610
620
  // The parent owns the runs Map, the link rows and the followers: it re-resolves the id itself (D4).
611
621
  try {