u-foo 1.7.5 → 1.8.0

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 (41) hide show
  1. package/README.md +9 -1
  2. package/README.zh-CN.md +9 -1
  3. package/bin/ufoo.js +4 -2
  4. package/package.json +1 -1
  5. package/src/agent/cliRunner.js +3 -2
  6. package/src/agent/ucodeBootstrap.js +5 -3
  7. package/src/agent/ufooAgent.js +184 -5
  8. package/src/assistant/constants.js +1 -1
  9. package/src/chat/commandExecutor.js +98 -3
  10. package/src/chat/commands.js +7 -0
  11. package/src/chat/completionController.js +40 -0
  12. package/src/chat/daemonMessageRouter.js +21 -1
  13. package/src/chat/dashboardKeyController.js +55 -3
  14. package/src/chat/dashboardView.js +31 -5
  15. package/src/chat/index.js +152 -36
  16. package/src/chat/inputListenerController.js +14 -0
  17. package/src/chat/inputSubmitHandler.js +9 -5
  18. package/src/chat/transientAgentState.js +64 -0
  19. package/src/cli/groupCoreCommands.js +21 -12
  20. package/src/cli.js +23 -1
  21. package/src/daemon/groupOrchestrator.js +581 -97
  22. package/src/daemon/index.js +418 -3
  23. package/src/daemon/ops.js +25 -7
  24. package/src/daemon/promptLoop.js +16 -0
  25. package/src/daemon/promptRequest.js +126 -2
  26. package/src/daemon/reporting.js +18 -0
  27. package/src/daemon/soloBootstrap.js +435 -0
  28. package/src/daemon/status.js +5 -1
  29. package/src/globalMode.js +33 -0
  30. package/src/group/bootstrap.js +157 -0
  31. package/src/group/promptProfiles.js +646 -0
  32. package/src/group/templateValidation.js +99 -0
  33. package/src/group/validateTemplate.js +36 -5
  34. package/src/init/index.js +13 -7
  35. package/src/report/store.js +6 -0
  36. package/src/shared/eventContract.js +1 -0
  37. package/templates/groups/{dev-basic.json → build-lane.json} +38 -34
  38. package/templates/groups/product-discovery.json +79 -0
  39. package/templates/groups/ui-polish.json +87 -0
  40. package/templates/groups/verify-ship.json +79 -0
  41. package/templates/groups/research-quick.json +0 -49
@@ -2,41 +2,29 @@
2
2
 
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
+ const EventBus = require("../bus");
6
+ const { prepareUcodeBootstrap } = require("../agent/ucodeBootstrap");
7
+ const { loadConfig } = require("../config");
8
+ const {
9
+ buildGroupPromptMetadata,
10
+ composeGroupBootstrapPrompt,
11
+ computeBootstrapFingerprint,
12
+ computeRosterVersion,
13
+ } = require("../group/bootstrap");
14
+ const { validateTemplateTarget: validateGroupTemplateTarget } = require("../group/templateValidation");
5
15
  const { getUfooPaths } = require("../ufoo/paths");
6
- const { resolveTemplateReference } = require("../group/templates");
7
- const { validateTemplate } = require("../group/validateTemplate");
8
16
 
9
17
  function asTrimmedString(value) {
10
18
  if (typeof value !== "string") return "";
11
19
  return value.trim();
12
20
  }
13
21
 
14
- const SAFE_GROUP_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
15
-
16
- function formatResolveErrors(errors = []) {
17
- if (!Array.isArray(errors) || errors.length === 0) return "";
18
- return errors
19
- .map((item) => `${item.filePath}: ${item.error}`)
20
- .join("; ");
22
+ function asStringArray(value) {
23
+ if (!Array.isArray(value)) return [];
24
+ return value.map((item) => asTrimmedString(item)).filter(Boolean);
21
25
  }
22
26
 
23
- function resolveTemplateTarget(projectRoot, target, options = {}) {
24
- const resolved = resolveTemplateReference(projectRoot, target, {
25
- allowPath: options.allowPath !== false,
26
- cwd: options.cwd || projectRoot,
27
- ...(options.templatesOptions || {}),
28
- });
29
-
30
- if (resolved.entry) {
31
- return { ok: true, resolved, error: "" };
32
- }
33
-
34
- const details = formatResolveErrors(resolved.errors || []);
35
- const error = details
36
- ? `failed to load template "${target}": ${details}`
37
- : `template not found: ${target}`;
38
- return { ok: false, resolved, error };
39
- }
27
+ const SAFE_GROUP_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
40
28
 
41
29
  function normalizeInstanceId(value = "") {
42
30
  const text = asTrimmedString(value);
@@ -58,16 +46,19 @@ function buildLaunchPlan(templateDoc = {}) {
58
46
  for (const agent of agents) {
59
47
  const nickname = asTrimmedString(agent && agent.nickname);
60
48
  if (!nickname) continue;
61
- const dependsOn = Array.isArray(agent.depends_on)
62
- ? agent.depends_on.map((item) => asTrimmedString(item)).filter(Boolean)
63
- : [];
49
+ const dependsOn = asStringArray(agent.depends_on);
64
50
  const startupOrder = Number.isInteger(agent.startup_order) ? agent.startup_order : 0;
65
51
  remaining.set(nickname, {
66
52
  id: asTrimmedString(agent.id),
67
53
  nickname,
54
+ requested_type: asTrimmedString(agent.type),
68
55
  type: asTrimmedString(agent.type),
56
+ role: asTrimmedString(agent.role),
57
+ prompt_profile: asTrimmedString(agent.prompt_profile),
69
58
  startup_order: startupOrder,
70
59
  depends_on: dependsOn,
60
+ accept_from: asStringArray(agent.accept_from),
61
+ report_to: asStringArray(agent.report_to),
71
62
  });
72
63
  }
73
64
 
@@ -113,6 +104,16 @@ function groupFilePath(projectRoot, groupId) {
113
104
  return path.join(ensureGroupsDir(projectRoot), `${normalized}.json`);
114
105
  }
115
106
 
107
+ function memberBootstrapFilePath(projectRoot, groupId, nickname) {
108
+ return path.join(
109
+ getUfooPaths(projectRoot).agentDir,
110
+ "ucode",
111
+ "groups",
112
+ groupId,
113
+ `${nickname}.bootstrap.md`
114
+ );
115
+ }
116
+
116
117
  function writeGroupState(projectRoot, runtime) {
117
118
  const filePath = groupFilePath(projectRoot, runtime.group_id);
118
119
  fs.writeFileSync(filePath, `${JSON.stringify(runtime, null, 2)}\n`, "utf8");
@@ -190,6 +191,10 @@ function nowIso() {
190
191
  return new Date().toISOString();
191
192
  }
192
193
 
194
+ function sleep(ms) {
195
+ return new Promise((resolve) => setTimeout(resolve, ms));
196
+ }
197
+
193
198
  function buildLaunchHostContext(params = {}) {
194
199
  const hostInjectSock = asTrimmedString(params.host_inject_sock || params.hostInjectSock);
195
200
  const hostDaemonSock = asTrimmedString(params.host_daemon_sock || params.hostDaemonSock);
@@ -208,11 +213,181 @@ function buildLaunchHostContext(params = {}) {
208
213
  return context;
209
214
  }
210
215
 
216
+ function buildRelationshipMaps(templateDoc = {}, plan = []) {
217
+ const upstream = new Map();
218
+ const downstream = new Map();
219
+
220
+ for (const item of plan) {
221
+ upstream.set(item.nickname, new Set([...item.depends_on, ...item.accept_from]));
222
+ downstream.set(item.nickname, new Set(item.report_to));
223
+ }
224
+
225
+ const edges = Array.isArray(templateDoc.edges) ? templateDoc.edges : [];
226
+ for (const edge of edges) {
227
+ const from = asTrimmedString(edge && edge.from);
228
+ const to = asTrimmedString(edge && edge.to);
229
+ if (!from || !to) continue;
230
+ if (!downstream.has(from)) downstream.set(from, new Set());
231
+ if (!upstream.has(to)) upstream.set(to, new Set());
232
+ downstream.get(from).add(to);
233
+ upstream.get(to).add(from);
234
+ }
235
+
236
+ const nicknameOrder = new Map(plan.map((item, index) => [item.nickname, index]));
237
+ const byNickname = new Map();
238
+ for (const item of plan) {
239
+ const orderedUpstream = Array.from(upstream.get(item.nickname) || [])
240
+ .filter((nickname) => nicknameOrder.has(nickname))
241
+ .sort((a, b) => nicknameOrder.get(a) - nicknameOrder.get(b));
242
+ const orderedDownstream = Array.from(downstream.get(item.nickname) || [])
243
+ .filter((nickname) => nicknameOrder.has(nickname))
244
+ .sort((a, b) => nicknameOrder.get(a) - nicknameOrder.get(b));
245
+ byNickname.set(item.nickname, {
246
+ upstream: orderedUpstream,
247
+ downstream: orderedDownstream,
248
+ });
249
+ }
250
+ return byNickname;
251
+ }
252
+
253
+ function pickRosterMembers(roster = [], nicknames = []) {
254
+ const wanted = new Set(Array.isArray(nicknames) ? nicknames : []);
255
+ return roster.filter((item) => wanted.has(item.nickname));
256
+ }
257
+
258
+ function resolveAutoAgentType(projectRoot, requestedType) {
259
+ const normalizedRequested = asTrimmedString(requestedType);
260
+ if (normalizedRequested && normalizedRequested !== "auto") return normalizedRequested;
261
+
262
+ const provider = asTrimmedString(loadConfig(projectRoot).agentProvider);
263
+ if (provider === "claude-cli") return "claude";
264
+ if (provider === "ucode") return "ucode";
265
+ return "codex";
266
+ }
267
+
268
+ function buildExecutionPlan({
269
+ projectRoot,
270
+ groupId,
271
+ templateEntry,
272
+ templateDoc,
273
+ plan,
274
+ promptRegistry,
275
+ promptProfiles,
276
+ }) {
277
+ const profileByNickname = new Map();
278
+ for (const item of promptProfiles || []) {
279
+ if (item && item.nickname) profileByNickname.set(item.nickname, item);
280
+ }
281
+
282
+ const registryById = promptRegistry && promptRegistry.byId ? promptRegistry.byId : new Map();
283
+ const relationships = buildRelationshipMaps(templateDoc, plan);
284
+
285
+ const roster = plan.map((item) => {
286
+ const resolvedType = resolveAutoAgentType(projectRoot, item.requested_type || item.type);
287
+ const profile = profileByNickname.get(item.nickname) || null;
288
+ return {
289
+ nickname: item.nickname,
290
+ requested_type: item.requested_type || item.type,
291
+ type: resolvedType,
292
+ role: item.role,
293
+ prompt_profile: item.prompt_profile,
294
+ resolved_profile: profile ? profile.resolved_profile : "",
295
+ display_name: profile ? profile.display_name : "",
296
+ short_name: profile ? profile.short_name : "",
297
+ profile_source: profile ? profile.profile_source : "",
298
+ deprecated: profile ? profile.deprecated === true : false,
299
+ depends_on: item.depends_on.slice(),
300
+ accept_from: item.accept_from.slice(),
301
+ report_to: item.report_to.slice(),
302
+ };
303
+ });
304
+
305
+ const rosterVersion = computeRosterVersion(roster);
306
+ const templateInfo = templateEntry && templateEntry.data && templateEntry.data.template
307
+ ? templateEntry.data.template
308
+ : {};
309
+
310
+ const executionPlan = plan.map((item) => {
311
+ const resolvedType = resolveAutoAgentType(projectRoot, item.requested_type || item.type);
312
+ const profile = profileByNickname.get(item.nickname) || null;
313
+ const resolvedProfile = profile ? registryById.get(profile.resolved_profile) || null : null;
314
+ const relation = relationships.get(item.nickname) || { upstream: [], downstream: [] };
315
+ const upstream = pickRosterMembers(roster, relation.upstream);
316
+ const downstream = pickRosterMembers(roster, relation.downstream);
317
+ const metadata = buildGroupPromptMetadata({
318
+ groupId,
319
+ templateAlias: templateEntry.alias || asTrimmedString(templateInfo.alias),
320
+ templateName: asTrimmedString(templateInfo.name),
321
+ rosterVersion,
322
+ member: {
323
+ nickname: item.nickname,
324
+ role: item.role,
325
+ prompt_profile: item.prompt_profile,
326
+ resolved_profile: profile ? profile.resolved_profile : "",
327
+ depends_on: item.depends_on,
328
+ accept_from: item.accept_from,
329
+ report_to: item.report_to,
330
+ },
331
+ groupMembers: roster,
332
+ upstream,
333
+ downstream,
334
+ });
335
+ const bootstrapRequired = Boolean(resolvedProfile && resolvedProfile.prompt);
336
+ const bootstrapStrategy = !bootstrapRequired
337
+ ? "none"
338
+ : (resolvedType === "ucode" ? "ucode-bootstrap-file" : "post-launch-inject");
339
+ const bootstrapPrompt = bootstrapRequired
340
+ ? composeGroupBootstrapPrompt({
341
+ profilePrompt: resolvedProfile.prompt,
342
+ metadata,
343
+ })
344
+ : "";
345
+ const bootstrapFingerprint = bootstrapRequired
346
+ ? computeBootstrapFingerprint({
347
+ groupId,
348
+ nickname: item.nickname,
349
+ resolvedProfile: profile ? profile.resolved_profile : "",
350
+ rosterVersion,
351
+ promptText: bootstrapPrompt,
352
+ metadata,
353
+ })
354
+ : "";
355
+
356
+ return {
357
+ ...item,
358
+ requested_type: item.requested_type || item.type,
359
+ type: resolvedType,
360
+ resolved_profile: profile ? profile.resolved_profile : "",
361
+ display_name: profile ? profile.display_name : "",
362
+ short_name: profile ? profile.short_name : "",
363
+ profile_source: profile ? profile.profile_source : "",
364
+ deprecated: profile ? profile.deprecated === true : false,
365
+ bootstrap_required: bootstrapRequired,
366
+ bootstrap_strategy: bootstrapStrategy,
367
+ bootstrap_metadata: metadata,
368
+ bootstrap_prompt: bootstrapPrompt,
369
+ bootstrap_fingerprint: bootstrapFingerprint,
370
+ bootstrap_file: bootstrapStrategy === "ucode-bootstrap-file"
371
+ ? memberBootstrapFilePath(projectRoot, groupId, item.nickname)
372
+ : "",
373
+ upstream: relation.upstream.slice(),
374
+ downstream: relation.downstream.slice(),
375
+ };
376
+ });
377
+
378
+ return {
379
+ roster,
380
+ rosterVersion,
381
+ executionPlan,
382
+ };
383
+ }
384
+
211
385
  function buildDefaultRuntime({
212
386
  groupId,
213
387
  instance,
214
388
  templateEntry,
215
389
  plan,
390
+ rosterVersion,
216
391
  }) {
217
392
  const templateInfo = templateEntry && templateEntry.data && templateEntry.data.template
218
393
  ? templateEntry.data.template
@@ -228,6 +403,7 @@ function buildDefaultRuntime({
228
403
  template_version: Number.isInteger(templateEntry.schemaVersion) ? templateEntry.schemaVersion : null,
229
404
  template_source: templateEntry.source || "",
230
405
  template_file: templateEntry.filePath || "",
406
+ roster_version: rosterVersion || "",
231
407
  created_at: createdAt,
232
408
  started_at: createdAt,
233
409
  updated_at: createdAt,
@@ -236,9 +412,29 @@ function buildDefaultRuntime({
236
412
  index: idx,
237
413
  template_agent_id: item.id || "",
238
414
  nickname: item.nickname,
415
+ requested_type: item.requested_type || item.type,
239
416
  type: item.type,
417
+ role: item.role || "",
418
+ prompt_profile: item.prompt_profile || "",
419
+ resolved_profile: item.resolved_profile || "",
420
+ display_name: item.display_name || "",
421
+ short_name: item.short_name || "",
422
+ profile_source: item.profile_source || "",
423
+ profile_deprecated: item.deprecated === true,
240
424
  startup_order: item.startup_order,
241
425
  depends_on: item.depends_on.slice(),
426
+ accept_from: item.accept_from.slice(),
427
+ report_to: item.report_to.slice(),
428
+ upstream: item.upstream.slice(),
429
+ downstream: item.downstream.slice(),
430
+ bootstrap_required: item.bootstrap_required === true,
431
+ bootstrap_strategy: item.bootstrap_strategy || "none",
432
+ bootstrap_status: item.bootstrap_required ? "pending" : "skipped",
433
+ bootstrap_attempted_at: "",
434
+ bootstrap_error: "",
435
+ bootstrapped_subscriber_id: "",
436
+ bootstrap_fingerprint: item.bootstrap_fingerprint || "",
437
+ bootstrap_file: item.bootstrap_file || "",
242
438
  status: "pending",
243
439
  managed: true,
244
440
  subscriber_id: "",
@@ -260,12 +456,158 @@ function pickLaunchSubscriber(projectRoot, launchResult, nickname) {
260
456
  return resolveActiveSubscriberByNickname(projectRoot, nickname);
261
457
  }
262
458
 
459
+ function findAppliedBootstrapRecord(projectRoot, subscriberId, fingerprint, currentGroupId = "") {
460
+ const targetSubscriber = asTrimmedString(subscriberId);
461
+ const targetFingerprint = asTrimmedString(fingerprint);
462
+ if (!targetSubscriber || !targetFingerprint) return null;
463
+
464
+ const groups = listGroupStates(projectRoot);
465
+ for (const runtime of groups) {
466
+ if (!runtime || runtime.group_id === currentGroupId) continue;
467
+ const members = Array.isArray(runtime.members) ? runtime.members : [];
468
+ for (const member of members) {
469
+ if (!member) continue;
470
+ if (asTrimmedString(member.bootstrapped_subscriber_id) !== targetSubscriber) continue;
471
+ if (asTrimmedString(member.bootstrap_fingerprint) !== targetFingerprint) continue;
472
+ if (asTrimmedString(member.bootstrap_status) !== "applied") continue;
473
+ return {
474
+ group_id: asTrimmedString(runtime.group_id),
475
+ nickname: asTrimmedString(member.nickname),
476
+ bootstrap_attempted_at: asTrimmedString(member.bootstrap_attempted_at),
477
+ bootstrapped_subscriber_id: targetSubscriber,
478
+ bootstrap_fingerprint: targetFingerprint,
479
+ };
480
+ }
481
+ }
482
+ return null;
483
+ }
484
+
485
+ function canReuseBootstrappedMember(member, item, subscriberId) {
486
+ return Boolean(
487
+ member
488
+ && member.bootstrap_status === "applied"
489
+ && asTrimmedString(member.bootstrapped_subscriber_id) === asTrimmedString(subscriberId)
490
+ && asTrimmedString(member.bootstrap_fingerprint) === asTrimmedString(item.bootstrap_fingerprint)
491
+ );
492
+ }
493
+
494
+ function readAgentMeta(projectRoot, subscriberId) {
495
+ const targetSubscriber = asTrimmedString(subscriberId);
496
+ if (!targetSubscriber) return null;
497
+ const agents = readBusAgents(projectRoot);
498
+ const meta = agents[targetSubscriber];
499
+ return meta && typeof meta === "object" ? meta : null;
500
+ }
501
+
502
+ function parseTimestampMs(value) {
503
+ const ms = Date.parse(String(value || ""));
504
+ return Number.isFinite(ms) ? ms : Number.NaN;
505
+ }
506
+
507
+ function isBootstrapReadyState(activityState = "") {
508
+ const normalized = asTrimmedString(activityState).toLowerCase();
509
+ return normalized === "ready"
510
+ || normalized === "idle"
511
+ || normalized === "waiting_input"
512
+ || normalized === "blocked";
513
+ }
514
+
515
+ async function waitForBootstrapReady(
516
+ projectRoot,
517
+ subscriberId,
518
+ {
519
+ timeoutMs = 15000,
520
+ retryDelayMs = 250,
521
+ protectionMs = 3000,
522
+ workingGraceMs = 10000,
523
+ } = {}
524
+ ) {
525
+ const targetSubscriber = asTrimmedString(subscriberId);
526
+ if (!targetSubscriber) {
527
+ return { ok: false, error: "missing subscriber_id for bootstrap readiness" };
528
+ }
529
+
530
+ const deadline = Date.now() + timeoutMs;
531
+ const startedAt = Date.now();
532
+ let lastState = "";
533
+ let lastStatus = "";
534
+ while (Date.now() < deadline) {
535
+ const meta = readAgentMeta(projectRoot, targetSubscriber);
536
+ const status = asTrimmedString(meta && meta.status).toLowerCase();
537
+ const activityState = asTrimmedString(meta && meta.activity_state);
538
+ lastStatus = status || lastStatus;
539
+ lastState = activityState || lastState;
540
+
541
+ if (!meta && lastStatus) {
542
+ return { ok: false, error: "agent disappeared before bootstrap" };
543
+ }
544
+ if (status && status !== "active") {
545
+ return { ok: false, error: `agent became ${status} before bootstrap` };
546
+ }
547
+ if (isBootstrapReadyState(activityState)) {
548
+ return { ok: true, activity_state: activityState.toLowerCase() };
549
+ }
550
+
551
+ const elapsedMs = Date.now() - startedAt;
552
+ const normalizedState = activityState.toLowerCase();
553
+ if (normalizedState === "working" && elapsedMs >= protectionMs) {
554
+ const activitySinceMs = parseTimestampMs(meta && meta.activity_since);
555
+ const workingMs = Number.isFinite(activitySinceMs)
556
+ ? Math.max(0, Date.now() - activitySinceMs)
557
+ : elapsedMs;
558
+ if (workingMs >= workingGraceMs) {
559
+ return {
560
+ ok: true,
561
+ activity_state: "working",
562
+ degraded: true,
563
+ };
564
+ }
565
+ }
566
+ // eslint-disable-next-line no-await-in-loop
567
+ await sleep(retryDelayMs);
568
+ }
569
+
570
+ return {
571
+ ok: false,
572
+ error: lastState
573
+ ? `agent not ready for bootstrap (last activity_state=${lastState})`
574
+ : "agent not ready for bootstrap",
575
+ };
576
+ }
577
+
578
+ async function injectBootstrapPrompt(bus, subscriberId, promptText, timeoutMs = 15000, retryDelayMs = 250) {
579
+ const deadline = Date.now() + timeoutMs;
580
+ let lastError = null;
581
+ while (Date.now() < deadline) {
582
+ try {
583
+ // eslint-disable-next-line no-await-in-loop
584
+ await bus.inject(subscriberId, promptText);
585
+ return { ok: true };
586
+ } catch (err) {
587
+ lastError = err;
588
+ // eslint-disable-next-line no-await-in-loop
589
+ await sleep(retryDelayMs);
590
+ }
591
+ }
592
+ return {
593
+ ok: false,
594
+ error: lastError && lastError.message
595
+ ? lastError.message
596
+ : `bootstrap inject failed for ${subscriberId}`,
597
+ };
598
+ }
599
+
263
600
  function createGroupOrchestrator(options = {}) {
264
601
  const {
265
602
  projectRoot,
266
603
  handleOps,
267
604
  processManager = null,
268
605
  templatesOptions = {},
606
+ promptProfilesOptions = {},
607
+ bootstrapTimeoutMs = 15000,
608
+ bootstrapRetryDelayMs = 250,
609
+ bootstrapProtectionMs = 3000,
610
+ bootstrapWorkingGraceMs = 10000,
269
611
  } = options;
270
612
 
271
613
  if (!projectRoot) {
@@ -290,36 +632,54 @@ function createGroupOrchestrator(options = {}) {
290
632
  }
291
633
 
292
634
  function validateTemplateTarget(target, resolveOptions = {}) {
293
- const rawTarget = asTrimmedString(target);
294
- if (!rawTarget) {
295
- return { ok: false, error: "template target is required", errors: [], entry: null };
296
- }
297
- const resolvedState = resolveTemplateTarget(projectRoot, rawTarget, {
635
+ return validateGroupTemplateTarget(projectRoot, target, {
298
636
  templatesOptions,
637
+ promptProfilesOptions,
299
638
  allowPath: resolveOptions.allowPath !== false,
300
639
  });
301
- if (!resolvedState.ok) {
302
- return {
303
- ok: false,
304
- error: resolvedState.error,
305
- errors: resolvedState.resolved.errors || [],
306
- entry: null,
307
- };
308
- }
309
- const result = validateTemplate(resolvedState.resolved.entry.data);
310
- if (!result.ok) {
311
- return {
312
- ok: false,
313
- error: "template validation failed",
314
- errors: result.errors,
315
- entry: resolvedState.resolved.entry,
316
- };
640
+ }
641
+
642
+ async function rollbackMembers(runtime, rollbackTargets = []) {
643
+ for (let j = rollbackTargets.length - 1; j >= 0; j -= 1) {
644
+ const target = rollbackTargets[j];
645
+ // eslint-disable-next-line no-await-in-loop
646
+ const closeResults = await handleOps(projectRoot, [{ action: "close", agent_id: target.target }], processManager);
647
+ const closeResult = Array.isArray(closeResults)
648
+ ? closeResults.find((entry) => entry && entry.action === "close")
649
+ : null;
650
+ const targetMember = runtime.members[target.memberIndex];
651
+ if (closeResult && closeResult.ok !== false) {
652
+ targetMember.status = "rolled_back";
653
+ targetMember.stop = { ok: true, reason: "rollback", at: nowIso() };
654
+ } else {
655
+ targetMember.status = "rollback_failed";
656
+ targetMember.stop = {
657
+ ok: false,
658
+ reason: "rollback",
659
+ at: nowIso(),
660
+ error: closeResult?.error || "close failed",
661
+ };
662
+ runtime.errors.push({
663
+ stage: "rollback",
664
+ nickname: targetMember.nickname,
665
+ error: closeResult?.error || "close failed",
666
+ });
667
+ }
317
668
  }
669
+ }
670
+
671
+ async function failGroupLaunch(runtime, rollbackTargets, stage, nickname, error) {
672
+ runtime.status = "failed";
673
+ runtime.updated_at = nowIso();
674
+ runtime.errors.push({ stage, nickname, error });
675
+ await rollbackMembers(runtime, rollbackTargets);
676
+ writeGroupState(projectRoot, runtime);
318
677
  return {
319
- ok: true,
320
- error: "",
321
- errors: [],
322
- entry: resolvedState.resolved.entry,
678
+ ok: false,
679
+ status: runtime.status,
680
+ group_id: runtime.group_id,
681
+ error,
682
+ group: runtime,
323
683
  };
324
684
  }
325
685
 
@@ -362,6 +722,16 @@ function createGroupOrchestrator(options = {}) {
362
722
  };
363
723
  }
364
724
 
725
+ const compiled = buildExecutionPlan({
726
+ projectRoot,
727
+ groupId,
728
+ templateEntry: validated.entry,
729
+ templateDoc: validated.entry.data,
730
+ plan,
731
+ promptRegistry: validated.promptRegistry,
732
+ promptProfiles: validated.promptProfiles,
733
+ });
734
+
365
735
  if (dryRun) {
366
736
  return {
367
737
  ok: true,
@@ -369,11 +739,27 @@ function createGroupOrchestrator(options = {}) {
369
739
  status: "dry_run",
370
740
  group_id: groupId,
371
741
  template_alias: validated.entry.alias,
372
- members: plan.map((item) => ({
742
+ roster_version: compiled.rosterVersion,
743
+ members: compiled.executionPlan.map((item) => ({
373
744
  nickname: item.nickname,
374
745
  type: item.type,
746
+ role: item.role,
375
747
  startup_order: item.startup_order,
376
748
  depends_on: item.depends_on.slice(),
749
+ accept_from: item.accept_from.slice(),
750
+ report_to: item.report_to.slice(),
751
+ prompt_profile: item.prompt_profile,
752
+ resolved_profile: item.resolved_profile,
753
+ display_name: item.display_name,
754
+ short_name: item.short_name,
755
+ profile_source: item.profile_source,
756
+ deprecated: item.deprecated,
757
+ bootstrap_required: item.bootstrap_required,
758
+ bootstrap_strategy: item.bootstrap_strategy,
759
+ bootstrap_fingerprint: item.bootstrap_fingerprint,
760
+ upstream: item.upstream.slice(),
761
+ downstream: item.downstream.slice(),
762
+ group_members: compiled.roster,
377
763
  })),
378
764
  };
379
765
  }
@@ -382,15 +768,43 @@ function createGroupOrchestrator(options = {}) {
382
768
  groupId,
383
769
  instance,
384
770
  templateEntry: validated.entry,
385
- plan,
771
+ plan: compiled.executionPlan,
772
+ rosterVersion: compiled.rosterVersion,
386
773
  });
387
774
  writeGroupState(projectRoot, runtime);
388
775
 
389
776
  const rollbackTargets = [];
777
+ const eventBus = new EventBus(projectRoot);
390
778
 
391
- for (let i = 0; i < plan.length; i += 1) {
392
- const item = plan[i];
779
+ for (let i = 0; i < compiled.executionPlan.length; i += 1) {
780
+ const item = compiled.executionPlan[i];
393
781
  const member = runtime.members[i];
782
+ const extraEnv = {};
783
+
784
+ if (item.bootstrap_strategy === "ucode-bootstrap-file") {
785
+ member.bootstrap_attempted_at = nowIso();
786
+ member.bootstrap_error = "";
787
+ try {
788
+ prepareUcodeBootstrap({
789
+ projectRoot,
790
+ targetFile: item.bootstrap_file,
791
+ promptText: item.bootstrap_prompt,
792
+ });
793
+ extraEnv.UFOO_UCODE_BOOTSTRAP_FILE = item.bootstrap_file;
794
+ } catch (err) {
795
+ member.status = "failed";
796
+ member.bootstrap_status = "failed";
797
+ member.bootstrap_error = err && err.message ? err.message : "failed to prepare ucode bootstrap";
798
+ return failGroupLaunch(
799
+ runtime,
800
+ rollbackTargets,
801
+ "bootstrap",
802
+ item.nickname,
803
+ member.bootstrap_error
804
+ );
805
+ }
806
+ }
807
+
394
808
  const op = {
395
809
  action: "launch",
396
810
  agent: item.type,
@@ -398,6 +812,9 @@ function createGroupOrchestrator(options = {}) {
398
812
  nickname: item.nickname,
399
813
  ...launchHostContext,
400
814
  };
815
+ if (Object.keys(extraEnv).length > 0) {
816
+ op.extra_env = extraEnv;
817
+ }
401
818
 
402
819
  // eslint-disable-next-line no-await-in-loop
403
820
  const opsResults = await handleOps(projectRoot, [op], processManager);
@@ -408,46 +825,15 @@ function createGroupOrchestrator(options = {}) {
408
825
  if (!launchResult || launchResult.ok === false) {
409
826
  member.status = "failed";
410
827
  member.launch = launchResult || {};
411
- runtime.status = "failed";
412
- runtime.updated_at = nowIso();
413
- runtime.errors.push({
414
- stage: "launch",
415
- nickname: item.nickname,
416
- error: launchResult && launchResult.error
828
+ return failGroupLaunch(
829
+ runtime,
830
+ rollbackTargets,
831
+ "launch",
832
+ item.nickname,
833
+ launchResult && launchResult.error
417
834
  ? launchResult.error
418
- : `launch failed for ${item.nickname}`,
419
- });
420
-
421
- for (let j = rollbackTargets.length - 1; j >= 0; j -= 1) {
422
- const target = rollbackTargets[j];
423
- // eslint-disable-next-line no-await-in-loop
424
- const closeResults = await handleOps(projectRoot, [{ action: "close", agent_id: target.target }], processManager);
425
- const closeResult = Array.isArray(closeResults)
426
- ? closeResults.find((entry) => entry && entry.action === "close")
427
- : null;
428
- const targetMember = runtime.members[target.memberIndex];
429
- if (closeResult && closeResult.ok !== false) {
430
- targetMember.status = "rolled_back";
431
- targetMember.stop = { ok: true, reason: "rollback", at: nowIso() };
432
- } else {
433
- targetMember.status = "rollback_failed";
434
- targetMember.stop = { ok: false, reason: "rollback", at: nowIso(), error: closeResult?.error || "close failed" };
435
- runtime.errors.push({
436
- stage: "rollback",
437
- nickname: targetMember.nickname,
438
- error: closeResult?.error || "close failed",
439
- });
440
- }
441
- }
442
-
443
- writeGroupState(projectRoot, runtime);
444
- return {
445
- ok: false,
446
- status: runtime.status,
447
- group_id: runtime.group_id,
448
- error: runtime.errors[runtime.errors.length - 1]?.error || "group launch failed",
449
- group: runtime,
450
- };
835
+ : `launch failed for ${item.nickname}`
836
+ );
451
837
  }
452
838
 
453
839
  const reused = Boolean(launchResult.skipped);
@@ -457,6 +843,7 @@ function createGroupOrchestrator(options = {}) {
457
843
  member.subscriber_id = subscriberId || "";
458
844
  member.launched_at = nowIso();
459
845
  member.launch = launchResult;
846
+ member.bootstrap_error = "";
460
847
  runtime.updated_at = nowIso();
461
848
 
462
849
  if (!reused) {
@@ -464,8 +851,105 @@ function createGroupOrchestrator(options = {}) {
464
851
  memberIndex: i,
465
852
  target: subscriberId || item.nickname,
466
853
  });
854
+ } else if (!canReuseBootstrappedMember(member, item, subscriberId)) {
855
+ const priorBootstrap = findAppliedBootstrapRecord(
856
+ projectRoot,
857
+ subscriberId,
858
+ item.bootstrap_fingerprint,
859
+ runtime.group_id
860
+ );
861
+ if (priorBootstrap) {
862
+ member.bootstrap_status = "applied";
863
+ member.bootstrap_attempted_at = priorBootstrap.bootstrap_attempted_at || nowIso();
864
+ member.bootstrapped_subscriber_id = priorBootstrap.bootstrapped_subscriber_id;
865
+ member.bootstrap_fingerprint = priorBootstrap.bootstrap_fingerprint;
866
+ member.bootstrap_error = "";
867
+ } else if (item.bootstrap_required && item.bootstrap_strategy === "post-launch-inject" && subscriberId) {
868
+ member.bootstrap_status = "pending";
869
+ } else {
870
+ member.status = "failed";
871
+ member.bootstrap_status = "failed";
872
+ member.bootstrap_error = `unsafe reused member "${item.nickname}": missing matching bootstrap fingerprint`;
873
+ writeGroupState(projectRoot, runtime);
874
+ return failGroupLaunch(
875
+ runtime,
876
+ rollbackTargets,
877
+ "bootstrap",
878
+ item.nickname,
879
+ member.bootstrap_error
880
+ );
881
+ }
882
+ }
883
+
884
+ if (!item.bootstrap_required) {
885
+ member.bootstrap_status = "skipped";
886
+ writeGroupState(projectRoot, runtime);
887
+ continue;
888
+ }
889
+
890
+ if (
891
+ member.bootstrap_status === "applied"
892
+ && asTrimmedString(member.bootstrapped_subscriber_id) === asTrimmedString(subscriberId)
893
+ && asTrimmedString(member.bootstrap_fingerprint) === asTrimmedString(item.bootstrap_fingerprint)
894
+ ) {
895
+ writeGroupState(projectRoot, runtime);
896
+ continue;
897
+ }
898
+
899
+ member.bootstrap_attempted_at = member.bootstrap_attempted_at || nowIso();
900
+ if (item.bootstrap_strategy === "post-launch-inject") {
901
+ // Wait for the agent wrapper/startup sequence to settle before injecting
902
+ // the group bootstrap prompt, otherwise the default startup command flow
903
+ // can be interrupted mid-boot.
904
+ // eslint-disable-next-line no-await-in-loop
905
+ const ready = await waitForBootstrapReady(
906
+ projectRoot,
907
+ subscriberId,
908
+ {
909
+ timeoutMs: bootstrapTimeoutMs,
910
+ retryDelayMs: bootstrapRetryDelayMs,
911
+ protectionMs: bootstrapProtectionMs,
912
+ workingGraceMs: bootstrapWorkingGraceMs,
913
+ }
914
+ );
915
+ if (!ready.ok) {
916
+ member.status = "failed";
917
+ member.bootstrap_status = "failed";
918
+ member.bootstrap_error = ready.error || `bootstrap readiness failed for ${item.nickname}`;
919
+ return failGroupLaunch(
920
+ runtime,
921
+ rollbackTargets,
922
+ "bootstrap",
923
+ item.nickname,
924
+ member.bootstrap_error
925
+ );
926
+ }
927
+ // eslint-disable-next-line no-await-in-loop
928
+ const injected = await injectBootstrapPrompt(
929
+ eventBus,
930
+ subscriberId,
931
+ item.bootstrap_prompt,
932
+ bootstrapTimeoutMs,
933
+ bootstrapRetryDelayMs
934
+ );
935
+ if (!injected.ok) {
936
+ member.status = "failed";
937
+ member.bootstrap_status = "failed";
938
+ member.bootstrap_error = injected.error || `bootstrap inject failed for ${item.nickname}`;
939
+ return failGroupLaunch(
940
+ runtime,
941
+ rollbackTargets,
942
+ "bootstrap",
943
+ item.nickname,
944
+ member.bootstrap_error
945
+ );
946
+ }
467
947
  }
468
948
 
949
+ member.bootstrap_status = "applied";
950
+ member.bootstrapped_subscriber_id = subscriberId || "";
951
+ member.bootstrap_fingerprint = item.bootstrap_fingerprint || "";
952
+ member.bootstrap_error = "";
469
953
  writeGroupState(projectRoot, runtime);
470
954
  }
471
955