@songsid/agend 2.1.4-beta.7 → 2.1.4-beta.9

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 (54) hide show
  1. package/dist/channel/adapters/discord.js +7 -0
  2. package/dist/channel/adapters/discord.js.map +1 -1
  3. package/dist/channel/mcp-server.js +6 -20
  4. package/dist/channel/mcp-server.js.map +1 -1
  5. package/dist/channel/mcp-tools.js +1 -1
  6. package/dist/channel/mcp-tools.js.map +1 -1
  7. package/dist/config-validator.js +3 -0
  8. package/dist/config-validator.js.map +1 -1
  9. package/dist/daemon.d.ts +50 -0
  10. package/dist/daemon.js +173 -34
  11. package/dist/daemon.js.map +1 -1
  12. package/dist/fleet-manager.d.ts +60 -1
  13. package/dist/fleet-manager.js +416 -195
  14. package/dist/fleet-manager.js.map +1 -1
  15. package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
  16. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
  17. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
  18. package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
  19. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
  20. package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
  21. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
  22. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
  23. package/dist/general-knowledge/skills/model-discovery/SKILL.md +1 -0
  24. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
  25. package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
  26. package/dist/general-knowledge/skills/session-management/SKILL.md +1 -0
  27. package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
  28. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
  29. package/dist/instance-lifecycle.d.ts +2 -0
  30. package/dist/instance-lifecycle.js +1 -0
  31. package/dist/instance-lifecycle.js.map +1 -1
  32. package/dist/instructions.d.ts +12 -0
  33. package/dist/instructions.js +33 -0
  34. package/dist/instructions.js.map +1 -1
  35. package/dist/locale.js +10 -0
  36. package/dist/locale.js.map +1 -1
  37. package/dist/outbound-schemas.d.ts +1 -0
  38. package/dist/outbound-schemas.js +1 -0
  39. package/dist/outbound-schemas.js.map +1 -1
  40. package/dist/tool-progress.d.ts +40 -0
  41. package/dist/tool-progress.js +289 -0
  42. package/dist/tool-progress.js.map +1 -0
  43. package/dist/topic-commands.d.ts +15 -0
  44. package/dist/topic-commands.js +56 -0
  45. package/dist/topic-commands.js.map +1 -1
  46. package/dist/transcript-monitor.d.ts +15 -2
  47. package/dist/transcript-monitor.js +63 -17
  48. package/dist/transcript-monitor.js.map +1 -1
  49. package/dist/transcript-sources.d.ts +106 -0
  50. package/dist/transcript-sources.js +428 -0
  51. package/dist/transcript-sources.js.map +1 -0
  52. package/dist/types.d.ts +6 -0
  53. package/dist/workflow-templates/default.md +2 -1
  54. package/package.json +1 -1
@@ -135,6 +135,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
135
135
  * exactly one progress message per turn, and it is the cancel button itself.
136
136
  */
137
137
  const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
138
+ /** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
139
+ const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
138
140
  /** Elapsed time is only shown once work has clearly outlasted a quick answer. */
139
141
  /**
140
142
  * Default delay before the button starts showing elapsed time. Configurable via
@@ -253,6 +255,8 @@ export class FleetManager {
253
255
  replyDeduper = new ReplyDeduper();
254
256
  /** instanceName → what it is doing right now, when the backend can tell us. */
255
257
  instanceActivity = new Map();
258
+ /** instanceName → this turn's tool list (multi-line), for the bubble. */
259
+ instanceProgress = new Map();
256
260
  /** instanceName → tail of deliveries waiting for its IPC to come back. */
257
261
  ipcWaitTails = new Map();
258
262
  /** instanceName → restart currently executing; concurrent callers join it. */
@@ -1074,19 +1078,34 @@ export class FleetManager {
1074
1078
  this.logger.info({ name }, "Instance already running, skipping");
1075
1079
  return;
1076
1080
  }
1081
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1077
1082
  if (config.general_topic) {
1078
1083
  // antigravity (agy) does not read MCP instructions — fleet context and
1079
1084
  // routing instructions are not injected, so it cannot act as a dispatcher.
1080
- const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
1081
1085
  if (backend === "antigravity") {
1082
1086
  this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
1083
1087
  this.notifyInstanceTopic(name, "⚠️ antigravity backend is not supported for General instances (no MCP instructions injection). Switch to claude-code or kiro-cli.");
1084
1088
  }
1085
- this.ensureGeneralInstructions(config.working_directory, config.backend);
1089
+ this.ensureGeneralInstructions(config.working_directory, backend, name);
1090
+ }
1091
+ else if (kind === "fleet-topic") {
1092
+ // Workers get only role-eligible on-demand skills. Classic instances are
1093
+ // deliberately excluded: their workspace and conversation lifecycle are
1094
+ // managed independently from fleet-topic workers.
1095
+ try {
1096
+ const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
1097
+ this.syncRoleSkills(skillsWorkDir, backend, "worker");
1098
+ }
1099
+ catch (err) {
1100
+ // Skill publishing is additive. A read-only or temporarily unavailable
1101
+ // workspace must not turn an otherwise valid worker startup into a
1102
+ // fleet outage.
1103
+ this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
1104
+ }
1086
1105
  }
1087
1106
  await this.lifecycle.start(name, config, topicMode, {
1088
1107
  kind,
1089
- backend: config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
1108
+ backend,
1090
1109
  model: this.resolveInstanceModel(name).display,
1091
1110
  });
1092
1111
  // Only clear a stale process status after a real start succeeded. Clearing
@@ -1661,7 +1680,7 @@ export class FleetManager {
1661
1680
  const generalDir = join(getAgendHome(), name);
1662
1681
  mkdirSync(generalDir, { recursive: true });
1663
1682
  const backendName = fleet.defaults.backend ?? "claude-code";
1664
- this.ensureGeneralInstructions(generalDir, backendName);
1683
+ this.ensureGeneralInstructions(generalDir, backendName, name);
1665
1684
  fleet.instances[name] = {
1666
1685
  ...DEFAULT_INSTANCE_CONFIG,
1667
1686
  working_directory: generalDir,
@@ -2220,6 +2239,26 @@ export class FleetManager {
2220
2239
  const result = await this.topicCommands.sendCompact(name);
2221
2240
  await data.respond(result);
2222
2241
  }
2242
+ else if (data.command === "steer") {
2243
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2244
+ if (!name) {
2245
+ await data.respond(t("classic.no_agent"));
2246
+ return;
2247
+ }
2248
+ const steerText = String(data.options?.message ?? "").trim();
2249
+ if (!steerText) {
2250
+ await data.respond(t("steer.usage"));
2251
+ return;
2252
+ }
2253
+ // chat_id/message_id stay empty: a slash interaction has no channel
2254
+ // message to react to, and an empty chat_id keeps updateLastChat from
2255
+ // rerouting the instance's replies to the slash context.
2256
+ const result = this.topicCommands.sendSteer(name, steerText, {
2257
+ chatId: "", messageId: "", username: data.username ?? "user",
2258
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2259
+ });
2260
+ await data.respond(result);
2261
+ }
2223
2262
  else if (data.command === "clear") {
2224
2263
  await this.handleClearSlash(data, adapterId);
2225
2264
  }
@@ -2344,6 +2383,26 @@ export class FleetManager {
2344
2383
  const result = await this.topicCommands.sendCompact(name);
2345
2384
  await data.respond(result);
2346
2385
  }
2386
+ else if (data.command === "steer") {
2387
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2388
+ if (!name) {
2389
+ await data.respond(t("classic.no_agent"));
2390
+ return;
2391
+ }
2392
+ const steerText = String(data.options?.message ?? "").trim();
2393
+ if (!steerText) {
2394
+ await data.respond(t("steer.usage"));
2395
+ return;
2396
+ }
2397
+ // chat_id/message_id stay empty: a slash interaction has no channel
2398
+ // message to react to, and an empty chat_id keeps updateLastChat from
2399
+ // rerouting the instance's replies to the slash context.
2400
+ const result = this.topicCommands.sendSteer(name, steerText, {
2401
+ chatId: "", messageId: "", username: data.username ?? "user",
2402
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2403
+ });
2404
+ await data.respond(result);
2405
+ }
2347
2406
  }, this.logger, "adapter.slash_command"));
2348
2407
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
2349
2408
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -2626,6 +2685,26 @@ export class FleetManager {
2626
2685
  const result = await this.topicCommands.sendCompact(name);
2627
2686
  await data.respond(result);
2628
2687
  }
2688
+ else if (data.command === "steer") {
2689
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2690
+ if (!name) {
2691
+ await data.respond(t("classic.no_agent"));
2692
+ return;
2693
+ }
2694
+ const steerText = String(data.options?.message ?? "").trim();
2695
+ if (!steerText) {
2696
+ await data.respond(t("steer.usage"));
2697
+ return;
2698
+ }
2699
+ // chat_id/message_id stay empty: a slash interaction has no channel
2700
+ // message to react to, and an empty chat_id keeps updateLastChat from
2701
+ // rerouting the instance's replies to the slash context.
2702
+ const result = this.topicCommands.sendSteer(name, steerText, {
2703
+ chatId: "", messageId: "", username: data.username ?? "user",
2704
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2705
+ });
2706
+ await data.respond(result);
2707
+ }
2629
2708
  }, this.logger, `adapter[${adapterId}].slash_command`));
2630
2709
  await adapter.start();
2631
2710
  if (channelConfig.group_id) {
@@ -2743,6 +2822,9 @@ export class FleetManager {
2743
2822
  else if (msg.type === "instance_activity") {
2744
2823
  this.cacheInstanceActivity(name, msg.activity);
2745
2824
  }
2825
+ else if (msg.type === "instance_progress") {
2826
+ this.cacheInstanceProgress(name, msg.progress || null);
2827
+ }
2746
2828
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
2747
2829
  this.cacheInstanceExecutionState(name, msg);
2748
2830
  if (msg.type === "instance_state_response") {
@@ -3190,6 +3272,24 @@ export class FleetManager {
3190
3272
  await msgAdapter?.sendText(chatId, result);
3191
3273
  return;
3192
3274
  }
3275
+ // /steer — interject into the running turn. Not admin-gated: anyone who
3276
+ // can talk to this agent can send it a message; steer only changes when
3277
+ // it lands, and it keeps the full [user:] formatting (unlike /raw).
3278
+ if (text === "/steer" || text.startsWith("/steer ") || text.startsWith("/steer@")) {
3279
+ const steerName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3280
+ if (!steerName) {
3281
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3282
+ return;
3283
+ }
3284
+ const steerContent = text.replace(/^\/steer(@\S+)?/, "").trim();
3285
+ if (!steerContent) {
3286
+ await msgAdapter?.sendText(chatId, t("steer.usage"));
3287
+ return;
3288
+ }
3289
+ const result = this.topicCommands.sendSteer(steerName, steerContent, msg);
3290
+ await msgAdapter?.sendText(chatId, result);
3291
+ return;
3292
+ }
3193
3293
  // Handle /clear command (admin only) — unlike /compact this starts a
3194
3294
  // fresh conversation and intentionally discards the current history.
3195
3295
  if (text === "/clear" || text.startsWith("/clear@")) {
@@ -5115,6 +5215,80 @@ export class FleetManager {
5115
5215
  else
5116
5216
  this.instanceActivity.delete(name);
5117
5217
  }
5218
+ /**
5219
+ * Cache the turn's tool-progress list and push it into the instance's live
5220
+ * bubble, coalesced so a burst of tool events cannot flood the Bot API
5221
+ * (Telegram's flood limit is per-chat across ALL forum topics, so edits are
5222
+ * rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
5223
+ */
5224
+ cacheInstanceProgress(name, progress) {
5225
+ if (progress)
5226
+ this.instanceProgress.set(name, progress);
5227
+ else
5228
+ this.instanceProgress.delete(name);
5229
+ for (const entry of this.cancelButtons.values()) {
5230
+ if (entry.instanceName === name)
5231
+ this.scheduleProgressEdit(entry);
5232
+ }
5233
+ }
5234
+ /** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
5235
+ scheduleProgressEdit(entry) {
5236
+ const since = Date.now() - (entry.lastProgressEditAt ?? 0);
5237
+ if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
5238
+ this.refreshBubble(entry);
5239
+ return;
5240
+ }
5241
+ if (entry.progressEditTimer)
5242
+ return; // trailing edit already scheduled
5243
+ entry.progressEditTimer = setTimeout(() => {
5244
+ entry.progressEditTimer = undefined;
5245
+ this.refreshBubble(entry);
5246
+ }, TOOL_PROGRESS_EDIT_MIN_MS - since);
5247
+ entry.progressEditTimer.unref?.();
5248
+ }
5249
+ /**
5250
+ * The ONE composer for the bubble text. Both writers — the elapsed-time
5251
+ * ticker and the tool-progress push — go through here; two independent
5252
+ * renderers editing the same message is how the progress list used to get
5253
+ * wiped by the next elapsed tick (#528 trap 2).
5254
+ */
5255
+ composeBubbleText(entry) {
5256
+ return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), this.instanceProgress.get(entry.instanceName));
5257
+ }
5258
+ /** Pure composition of header + tool list, exposed for tests. */
5259
+ static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
5260
+ const header = FleetManager.progressText(elapsedMs,
5261
+ // The single-line activity detail is redundant once a tool list exists.
5262
+ progress ? undefined : activity, minElapsedMs);
5263
+ return progress ? `${header}\n${progress}` : header;
5264
+ }
5265
+ /** Recompose and edit the bubble in place; skips when nothing changed. */
5266
+ refreshBubble(entry) {
5267
+ if (!this.cancelButtons.has(entry.messageId)) {
5268
+ clearInterval(entry.progressTimer);
5269
+ return;
5270
+ }
5271
+ const text = this.composeBubbleText(entry);
5272
+ if (text === entry.lastProgressText)
5273
+ return; // nothing changed — skip the API call
5274
+ const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
5275
+ if (!adapter?.editAlert)
5276
+ return;
5277
+ entry.lastProgressText = text;
5278
+ entry.lastProgressEditAt = Date.now();
5279
+ adapter.editAlert(entry.chatId, entry.messageId, {
5280
+ type: "cancel",
5281
+ instanceName: entry.instanceName,
5282
+ message: text,
5283
+ choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
5284
+ }, entry.threadId ? { threadId: entry.threadId } : undefined)
5285
+ .catch(err => {
5286
+ // A failed progress edit must never escalate: the button still works and
5287
+ // the next tick retries. Common causes are a deleted message or a
5288
+ // rate limit.
5289
+ this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
5290
+ });
5291
+ }
5118
5292
  /**
5119
5293
  * Refresh the button's text in place while the instance keeps working.
5120
5294
  *
@@ -5132,31 +5306,7 @@ export class FleetManager {
5132
5306
  return PROGRESS_MIN_ELAPSED_MS;
5133
5307
  }
5134
5308
  startProgressTicker(entry) {
5135
- const tick = () => {
5136
- if (!this.cancelButtons.has(entry.messageId)) {
5137
- clearInterval(entry.progressTimer);
5138
- return;
5139
- }
5140
- const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs());
5141
- if (text === entry.lastProgressText)
5142
- return; // nothing changed — skip the API call
5143
- const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
5144
- if (!adapter?.editAlert)
5145
- return;
5146
- entry.lastProgressText = text;
5147
- adapter.editAlert(entry.chatId, entry.messageId, {
5148
- type: "cancel",
5149
- instanceName: entry.instanceName,
5150
- message: text,
5151
- choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
5152
- }, entry.threadId ? { threadId: entry.threadId } : undefined)
5153
- .catch(err => {
5154
- // A failed progress edit must never escalate: the button still works and
5155
- // the next tick retries. Common causes are a deleted message or a
5156
- // rate limit.
5157
- this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
5158
- });
5159
- };
5309
+ const tick = () => this.refreshBubble(entry);
5160
5310
  entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
5161
5311
  entry.progressTimer.unref?.();
5162
5312
  // One extra tick right when the threshold passes, so a 30s threshold shows
@@ -5224,6 +5374,8 @@ export class FleetManager {
5224
5374
  clearInterval(entry.idleCheckTimer);
5225
5375
  if (entry.progressTimer)
5226
5376
  clearInterval(entry.progressTimer);
5377
+ if (entry.progressEditTimer)
5378
+ clearTimeout(entry.progressEditTimer);
5227
5379
  if (entry.replyGraceTimer)
5228
5380
  clearTimeout(entry.replyGraceTimer);
5229
5381
  this.cancelButtons.delete(entry.messageId);
@@ -5482,6 +5634,10 @@ export class FleetManager {
5482
5634
  "gemini-cli": "GEMINI.md",
5483
5635
  "opencode": "AGENTS.md",
5484
5636
  "kiro-cli": ".kiro/steering/project.md",
5637
+ // Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
5638
+ // same files their writeConfig() appends fleet instructions to.
5639
+ "grok": "AGENTS.md",
5640
+ "antigravity": ".agents/agents.md",
5485
5641
  "mock": "CLAUDE.md",
5486
5642
  };
5487
5643
  static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
@@ -5491,146 +5647,33 @@ You route tasks, manage instances, enforce policies, and synthesize results.
5491
5647
  Do NOT modify project files directly — delegate file changes to the project's instance.
5492
5648
  You CAN write code snippets, explain code, and answer technical questions directly.
5493
5649
 
5494
- -----
5495
-
5496
- ## Task Classification
5497
-
5498
- Classify every incoming request before acting.
5499
-
5500
- ### Handle Directly (ALL conditions must be true)
5501
-
5502
- - No file system access needed
5503
- - No external execution needed
5504
- - Answerable from static knowledge
5505
- - ≤ 2 reasoning steps
5506
-
5507
- Examples: Q&A, translation, fleet status queries, explaining a concept, writing code snippets.
5508
-
5509
- ### Delegate to 1 Instance
5510
-
5511
- - Task scoped to a single project or repo
5512
- - Requires file access, code changes, or execution
5513
-
5514
- ### Coordinate Multiple Instances
5515
-
5516
- - Task spans multiple repos or domains
5517
- - Requires outputs from one instance to feed into another
5518
- - Benefits from parallel execution (max 3 instances per task)
5519
-
5520
- -----
5521
-
5522
- ## Instance Discovery (in this order)
5523
- 1. list_teams() → reuse existing teams first
5524
- 2. list_instances() → find by working_directory, description, or tags
5525
- 3. describe_instance() → confirm capabilities before delegating
5526
- 4. create_instance() → only if no suitable instance exists
5527
-
5528
- Rules: prefer reuse over creation. Do NOT create duplicates of running instances.
5529
-
5530
- -----
5531
-
5532
- ## Delegation Protocol
5650
+ ## Task Routing
5533
5651
 
5534
- Every delegation via send_to_instance() MUST include:
5652
+ - **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
5653
+ - **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
5654
+ - **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
5535
5655
 
5536
- 1. Task scope what exactly to do, bounded clearly
5537
- 2. Expected output — what to return and in what form
5538
- 3. Policy reminder — "Follow Development Workflow policy" (for code tasks)
5656
+ Instance discovery order: list_teams() list_instances() describe_instance() create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
5539
5657
 
5540
- ### Loop Prevention
5658
+ ## Reply Contract
5541
5659
 
5542
- - Never re-delegate a task back to the instance that sent it to you
5543
- - If a task has bounced 3 times, stop and solve locally or reduce scope
5544
-
5545
- ### Execution Strategy
5546
-
5547
- Parallel — use only when tasks are independent with no shared state
5548
- Sequential — use when one task's output feeds into the next
5549
-
5550
- -----
5551
-
5552
- ## Result Handling
5553
-
5554
- When an instance reports back, classify the outcome:
5555
-
5556
- - Success → Summarize key results for user. Omit internal coordination noise.
5557
- - Partial → State what succeeded, what remains, proposed next steps.
5558
- - Failure → Retry up to 2 times. If still failing: try alternative instance, reduce scope, or return partial result clearly marked.
5559
- - No response → Ping again after reasonable wait. If still silent: report to user with options.
5560
-
5561
- ### Output to User
5562
-
5563
- Every final response to the user should contain:
5564
-
5565
- - Result — the actual answer or deliverable
5566
- - Gaps — anything incomplete or unresolved (omit if none)
5567
-
5568
- -----
5569
-
5570
- ## Shared Decisions
5571
-
5572
- Use post_decision() / list_decisions() for any choice that affects more than 1 instance, changes an API contract, introduces a new dependency, or alters deployment process.
5573
-
5574
- When instances disagree, collect both viewpoints, make a decision, and record it via post_decision.
5575
-
5576
- -----
5660
+ Every final response to the user contains: the result (the actual answer or deliverable) and gaps (anything incomplete — omit if none). Summarize instance reports; omit internal coordination noise.
5577
5661
 
5578
5662
  ## After Restart
5579
5663
 
5580
- After a restart, run this sequence BEFORE processing any new messages:
5581
- 1. list_instances() → rebuild fleet awareness
5582
- 2. list_teams() → restore team structure
5583
- 3. list_decisions() → reload policies and conventions
5584
-
5585
- Only then handle incoming requests.
5586
-
5587
- -----
5588
-
5589
- ## Development Workflow Policy
5590
-
5591
- All code changes across the fleet should follow this workflow.
5592
- The coordinator enforces compliance but does not perform these steps directly.
5593
- Remind instances of this policy when delegating code tasks.
5594
-
5595
- ### Workflow Stages
5596
- Design Proposed → Design Approved → Implementation → Submit for Review → Under Review → Approved → Merge
5597
-
5598
- ### Policy Rules
5599
-
5600
- 1. Design before code — developer sends design proposal to reviewer before implementation. Consensus required before proceeding.
5601
- 2. Challenger pairing — every code task should have a developer + reviewer. Reviewer actively questions decisions and finds risks.
5602
- 3. Verify by execution — backend/CLI changes must be tested by running them. Do not trust documentation alone.
5603
- 4. Independent review — every merge requires code review from someone other than the author.
5604
- 5. Root cause first — bug fixes require confirmed root cause before proposing a fix.
5605
- 6. Merge conditions: tests pass, reviewer approved, branch and worktree cleaned up.
5664
+ BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
5606
5665
 
5607
- ### Specialist Instance Rules
5666
+ ## Playbooks (on-demand skills)
5608
5667
 
5609
- - Execute within defined scope only
5610
- - Return structured output: result, assumptions, uncertainties, verification status
5611
- - Do NOT create new instances without coordinator approval
5612
-
5613
- -----
5614
-
5615
- ## Team Management
5616
-
5617
- - Always check existing teams before creating new ones
5618
- - Default to ephemeral teams (created for a specific task, dissolved after completion)
5619
- - Clean up ephemeral teams and instances after task completion
5620
-
5621
- -----
5622
-
5623
- ## Instance Configuration Tips
5624
-
5625
- When users create specialized instances, suggest these configurations:
5626
-
5627
- - **Reviewer instances**: Add \`pre_task_command: "/chat load reviewer-base"\` to reset context before each review, preventing influence from previous conversations.
5628
- - **Collab mode**: For multi-bot channels, use \`/collab\` to enable @mention-based triggering.
5629
- - **Cost control**: Set per-instance \`cost_guard\` for expensive backends.
5668
+ Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
5669
+ - **delegation-playbook** delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
5670
+ - **development-workflow** the fleet-wide code-change policy you enforce when delegating code tasks.
5671
+ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
5630
5672
  `;
5631
5673
  /** Ensure the general instance has its project instructions file + knowledge */
5632
- ensureGeneralInstructions(workDir, backendName) {
5674
+ ensureGeneralInstructions(workDir, backendName, instanceName) {
5633
5675
  const backend = backendName ?? "claude-code";
5676
+ workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
5634
5677
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
5635
5678
  const filePath = join(workDir, filename);
5636
5679
  mkdirSync(dirname(filePath), { recursive: true });
@@ -5638,60 +5681,238 @@ When users create specialized instances, suggest these configurations:
5638
5681
  writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
5639
5682
  this.logger.info({ filePath }, "Created general instance instructions file");
5640
5683
  }
5641
- // Sync bundled knowledge files to general's steering directory
5684
+ // Sync bundled knowledge files to general's steering and skills directories.
5642
5685
  this.syncGeneralKnowledge(workDir, backend);
5643
5686
  }
5644
- /** Copy general-knowledge steering + skills to the general instance's workspace */
5687
+ /** Resolve the workspace path a backend actually uses before publishing knowledge. */
5688
+ resolveKnowledgeWorkDir(workDir, backend, instanceName) {
5689
+ // Some backends relocate their real cwd (agy moves hidden paths to
5690
+ // ~/agend-workspaces/<name>). Instructions and skills must land where the
5691
+ // CLI actually runs, not where fleet.yaml points.
5692
+ try {
5693
+ const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
5694
+ .resolveWorkingDirectory?.(workDir, instanceName);
5695
+ if (resolved)
5696
+ workDir = resolved;
5697
+ }
5698
+ catch { /* unknown backend name — keep the raw path */ }
5699
+ return workDir;
5700
+ }
5701
+ /**
5702
+ * Where each backend natively loads on-demand skills from, relative to the
5703
+ * workspace. Backends without a native skill mechanism (opencode, grok,
5704
+ * antigravity, gemini-cli) are deliberately absent: dropping files a CLI
5705
+ * never reads is clutter, not capability. Unknown directories are ignored
5706
+ * by older CLI versions, so publishing is fail-open across upgrades.
5707
+ */
5708
+ static SKILLS_DIR_SEGMENTS = {
5709
+ "kiro-cli": [".kiro", "skills"],
5710
+ "claude-code": [".claude", "skills"],
5711
+ "codex": [".agents", "skills"],
5712
+ // Live-verified: OpenCode and Antigravity scan .agents/skills; Grok's
5713
+ // vendor-canonical location is .grok/skills.
5714
+ "opencode": [".agents", "skills"],
5715
+ "grok": [".grok", "skills"],
5716
+ "antigravity": [".agents", "skills"],
5717
+ };
5718
+ /** Copy general-knowledge steering + all role-eligible skills to General. */
5645
5719
  syncGeneralKnowledge(workDir, backend) {
5646
5720
  const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
5647
5721
  if (!existsSync(knowledgeDir))
5648
5722
  return;
5649
- // Sync steering files → .kiro/steering/ (or workDir root for non-kiro)
5650
- const steeringDir = backend === "kiro-cli"
5651
- ? join(workDir, ".kiro", "steering")
5652
- : workDir;
5653
- mkdirSync(steeringDir, { recursive: true });
5654
- const srcSteering = join(knowledgeDir, "steering");
5655
- if (existsSync(srcSteering)) {
5656
- for (const file of readdirSync(srcSteering)) {
5657
- if (!file.endsWith(".md"))
5658
- continue;
5659
- const src = join(srcSteering, file);
5723
+ this.syncGeneralSteering(workDir, backend, join(knowledgeDir, "steering"));
5724
+ this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
5725
+ this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
5726
+ }
5727
+ /** Publish only the bundled skills eligible for an instance role. */
5728
+ syncRoleSkills(workDir, backend, role, knowledgeDir) {
5729
+ const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
5730
+ if (!skillSegments)
5731
+ return;
5732
+ const root = knowledgeDir ?? join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
5733
+ if (!existsSync(root))
5734
+ return;
5735
+ // Before managed-skill manifests existed, only Kiro General received
5736
+ // bundled skills. Allow that one legacy layout to be adopted so upgrades
5737
+ // can keep those copies current; other backends never had unmanaged
5738
+ // AgEnD-published skills and must retain the normal user-ownership guard.
5739
+ const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
5740
+ this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
5741
+ this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
5742
+ }
5743
+ /**
5744
+ * Steering (always-on rules like core-rules.md). Kiro loads a native
5745
+ * steering directory; every other backend gets the content embedded into
5746
+ * its instructions file (CLAUDE.md / AGENTS.md / …) inside a managed marker
5747
+ * block — the previous behavior dropped bare .md files in the workspace
5748
+ * root, which no CLI ever read. The block is replaced in place on every
5749
+ * sync, so rule updates reach EXISTING workspaces; everything the user
5750
+ * wrote outside the markers is preserved byte-for-byte.
5751
+ */
5752
+ syncGeneralSteering(workDir, backend, srcSteering) {
5753
+ if (!existsSync(srcSteering))
5754
+ return;
5755
+ const files = readdirSync(srcSteering).filter(f => f.endsWith(".md")).sort();
5756
+ if (files.length === 0)
5757
+ return;
5758
+ if (backend === "kiro-cli") {
5759
+ const steeringDir = join(workDir, ".kiro", "steering");
5760
+ mkdirSync(steeringDir, { recursive: true });
5761
+ for (const file of files) {
5660
5762
  const dest = join(steeringDir, file);
5661
- const newContent = readFileSync(src, "utf-8");
5763
+ const newContent = readFileSync(join(srcSteering, file), "utf-8");
5662
5764
  try {
5663
5765
  if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
5664
5766
  continue;
5665
5767
  }
5666
- catch { }
5768
+ catch { /* rewrite */ }
5667
5769
  writeFileSync(dest, newContent);
5668
5770
  }
5771
+ return;
5669
5772
  }
5670
- // Sync skills .kiro/skills/ (kiro-cli only)
5671
- if (backend === "kiro-cli") {
5672
- const srcSkills = join(knowledgeDir, "skills");
5673
- if (existsSync(srcSkills)) {
5674
- const destSkills = join(workDir, ".kiro", "skills");
5675
- mkdirSync(destSkills, { recursive: true });
5676
- for (const skillDir of readdirSync(srcSkills)) {
5677
- const skillSrc = join(srcSkills, skillDir);
5678
- if (!existsSync(join(skillSrc, "SKILL.md")))
5679
- continue;
5680
- const skillDest = join(destSkills, skillDir);
5681
- mkdirSync(skillDest, { recursive: true });
5682
- const src = join(skillSrc, "SKILL.md");
5683
- const dest = join(skillDest, "SKILL.md");
5684
- const newContent = readFileSync(src, "utf-8");
5685
- try {
5686
- if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
5687
- continue;
5688
- }
5689
- catch { }
5690
- writeFileSync(dest, newContent);
5691
- }
5773
+ const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
5774
+ const instructionsPath = join(workDir, filename);
5775
+ const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
5776
+ const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
5777
+ let existing = "";
5778
+ try {
5779
+ existing = existsSync(instructionsPath) ? readFileSync(instructionsPath, "utf-8") : "";
5780
+ }
5781
+ catch { /* treat as empty */ }
5782
+ const beginAt = existing.indexOf(FleetManager.STEERING_BLOCK_BEGIN);
5783
+ const endAt = existing.indexOf(FleetManager.STEERING_BLOCK_END);
5784
+ let next;
5785
+ if (beginAt !== -1 && endAt !== -1 && endAt > beginAt) {
5786
+ next = existing.slice(0, beginAt) + block + existing.slice(endAt + FleetManager.STEERING_BLOCK_END.length);
5787
+ }
5788
+ else {
5789
+ next = existing.trimEnd() + (existing.trim() ? "\n\n" : "") + block + "\n";
5790
+ }
5791
+ if (next !== existing) {
5792
+ mkdirSync(dirname(instructionsPath), { recursive: true });
5793
+ writeFileSync(instructionsPath, next);
5794
+ }
5795
+ }
5796
+ static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
5797
+ static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
5798
+ /**
5799
+ * Publish AgEnD's bundled skills into a CLI's native skills directory,
5800
+ * owning ONLY what we published. A manifest records which skill names AgEnD
5801
+ * wrote; a bundled rename/removal deletes the stale managed copy, while a
5802
+ * skill the user created by hand is never listed and therefore never
5803
+ * touched — even if a future bundle happens to reuse its name (the sync
5804
+ * then skips it and logs, rather than overwrite the user's work). The sole
5805
+ * migration exception is the pre-manifest Kiro General layout explicitly
5806
+ * selected by adoptLegacyUnmanaged.
5807
+ */
5808
+ syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
5809
+ if (!existsSync(srcSkills))
5810
+ return;
5811
+ mkdirSync(destSkills, { recursive: true });
5812
+ const manifestPath = join(destSkills, ".agend-managed-skills.json");
5813
+ const hadManifest = existsSync(manifestPath);
5814
+ let previouslyManaged = [];
5815
+ try {
5816
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
5817
+ if (Array.isArray(parsed))
5818
+ previouslyManaged = parsed.filter(n => typeof n === "string");
5819
+ }
5820
+ catch { /* first sync, or corrupt manifest — treat as owning nothing */ }
5821
+ const bundled = readdirSync(srcSkills)
5822
+ .filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
5823
+ .sort();
5824
+ // Pre-manifest Kiro General copied bundled skills directly into
5825
+ // .kiro/skills. If every existing skill is still a bundled name, this is
5826
+ // the unambiguous legacy layout: adopt it once, update it below, and write
5827
+ // the ownership manifest. Any extra skill name keeps the directory fully
5828
+ // on the user-owned/collision path.
5829
+ if (!hadManifest && adoptLegacyUnmanaged) {
5830
+ const existing = readdirSync(destSkills, { withFileTypes: true })
5831
+ .filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
5832
+ .map(entry => entry.name)
5833
+ .sort();
5834
+ if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
5835
+ previouslyManaged = existing;
5836
+ this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
5837
+ }
5838
+ }
5839
+ const eligible = bundled.filter(name => {
5840
+ const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
5841
+ // General is the coordinator and receives both coordinator and worker
5842
+ // playbooks. Workers receive only skills explicitly marked for workers.
5843
+ return role === "general" || roles.includes("worker");
5844
+ });
5845
+ const managed = [];
5846
+ for (const name of eligible) {
5847
+ const destDir = join(destSkills, name);
5848
+ const dest = join(destDir, "SKILL.md");
5849
+ const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
5850
+ if (!isOurs) {
5851
+ // Name collision with a user-authored skill: theirs wins, loudly.
5852
+ this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
5853
+ continue;
5854
+ }
5855
+ managed.push(name);
5856
+ const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
5857
+ try {
5858
+ if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
5859
+ continue;
5860
+ }
5861
+ catch { /* rewrite */ }
5862
+ mkdirSync(destDir, { recursive: true });
5863
+ writeFileSync(dest, newContent);
5864
+ }
5865
+ // Remove managed skills that are no longer bundled OR no longer eligible
5866
+ // for this role. This makes a shared → general-only metadata change take
5867
+ // effect on the next worker startup instead of leaving stale capability.
5868
+ for (const stale of previouslyManaged) {
5869
+ if (eligible.includes(stale))
5870
+ continue;
5871
+ try {
5872
+ rmSync(join(destSkills, stale), { recursive: true, force: true });
5873
+ this.logger.info({ skill: stale, destSkills }, "Removed retired AgEnD-managed skill");
5874
+ }
5875
+ catch (err) {
5876
+ this.logger.debug({ err, skill: stale }, "Failed to remove retired managed skill");
5692
5877
  }
5693
5878
  }
5694
- this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files");
5879
+ try {
5880
+ writeFileSync(manifestPath, JSON.stringify(managed, null, 2) + "\n");
5881
+ }
5882
+ catch (err) {
5883
+ this.logger.debug({ err, manifestPath }, "Failed to write managed-skills manifest");
5884
+ }
5885
+ }
5886
+ /** Read AgEnD's roles extension from SKILL.md YAML frontmatter. */
5887
+ readManagedSkillRoles(skillPath) {
5888
+ const fallback = ["general"];
5889
+ try {
5890
+ const content = readFileSync(skillPath, "utf-8");
5891
+ const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
5892
+ if (!match)
5893
+ return fallback;
5894
+ const document = parseDocument(match[1]);
5895
+ if (document.errors.length > 0)
5896
+ throw document.errors[0];
5897
+ const frontmatter = document.toJS();
5898
+ if (!frontmatter || frontmatter.roles === undefined)
5899
+ return fallback;
5900
+ if (!Array.isArray(frontmatter.roles))
5901
+ throw new Error("roles must be an array");
5902
+ const roles = [...new Set(frontmatter.roles)]
5903
+ .filter((value) => value === "general" || value === "worker");
5904
+ if (roles.length !== frontmatter.roles.length || roles.length === 0) {
5905
+ throw new Error("roles must contain only general and/or worker");
5906
+ }
5907
+ return roles;
5908
+ }
5909
+ catch (err) {
5910
+ // Fail closed for workers: malformed or unknown metadata keeps the
5911
+ // backwards-compatible General-only behavior instead of leaking an
5912
+ // administrative skill into worker workspaces.
5913
+ this.logger.warn({ err, skillPath }, "Invalid bundled skill roles — defaulting to General only");
5914
+ return fallback;
5915
+ }
5695
5916
  }
5696
5917
  /** Fetch forum topic icon stickers and pick emoji IDs for each state */
5697
5918
  async resolveTopicIcons() {