opencode-swarm 6.21.0 → 6.21.1

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.
package/README.md CHANGED
@@ -688,6 +688,24 @@ When truncation is active, a footer is appended:
688
688
  [output truncated to {maxLines} lines – use `tool_output.per_tool.<tool>` to adjust]
689
689
  ```
690
690
 
691
+ ## Summarization Settings
692
+
693
+ Control how tool outputs are summarized for LLM context.
694
+
695
+ ```json
696
+ {
697
+ "summaries": {
698
+ "threshold_bytes": 102400,
699
+ "exempt_tools": ["retrieve_summary", "task", "read"]
700
+ }
701
+ }
702
+ ```
703
+
704
+ - **threshold_bytes** – Output size threshold in bytes before summarization is triggered (default 102400 = 100KB).
705
+ - **exempt_tools** – Tools whose outputs are never summarized. Defaults to `["retrieve_summary", "task", "read"]` to prevent re-summarization loops.
706
+
707
+ > **Note:** The `retrieve_summary` tool supports paginated retrieval via `offset` and `limit` parameters to fetch large summarized outputs in chunks.
708
+
691
709
  ---
692
710
 
693
711
  ### Disabling Agents
@@ -717,7 +735,7 @@ When truncation is active, a footer is appended:
717
735
  | `/swarm evidence [task]` | Evidence bundles for a task or all tasks |
718
736
  | `/swarm archive [--dry-run]` | Archive old evidence with retention policy |
719
737
  | `/swarm benchmark` | Performance benchmarks |
720
- | `/swarm retrieve [id]` | Retrieve auto-summarized tool outputs |
738
+ | `/swarm retrieve [id]` | Retrieve auto-summarized tool outputs (supports offset/limit pagination) |
721
739
  | `/swarm reset --confirm` | Clear swarm state files |
722
740
  | `/swarm preflight` | Run phase preflight checks |
723
741
  | `/swarm config doctor [--fix]` | Config validation with optional auto-fix |
@@ -110,6 +110,11 @@ export declare class PlanSyncWorker {
110
110
  * to prevent callback errors from affecting worker stability
111
111
  */
112
112
  private safeCallback;
113
+ /**
114
+ * Advisory: check for unauthorized writes to plan.json outside of save_plan/savePlan
115
+ * Logs a warning if plan.json appears to have been modified after the write marker
116
+ */
117
+ private checkForUnauthorizedWrite;
113
118
  /**
114
119
  * Wrap a promise with a timeout
115
120
  */
package/dist/cli/index.js CHANGED
@@ -16231,6 +16231,17 @@ ${markdown}`;
16231
16231
  unlinkSync(mdTempPath);
16232
16232
  } catch {}
16233
16233
  }
16234
+ try {
16235
+ const markerPath = path6.join(swarmDir, ".plan-write-marker");
16236
+ const tasksCount = validated.phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
16237
+ const marker = JSON.stringify({
16238
+ source: "plan_manager",
16239
+ timestamp: new Date().toISOString(),
16240
+ phases_count: validated.phases.length,
16241
+ tasks_count: tasksCount
16242
+ });
16243
+ await Bun.write(markerPath, marker);
16244
+ } catch {}
16234
16245
  }
16235
16246
  function derivePlanMarkdown(plan) {
16236
16247
  const statusMap = {
@@ -16865,11 +16876,11 @@ var PhaseCompleteConfigSchema = exports_external.object({
16865
16876
  });
16866
16877
  var SummaryConfigSchema = exports_external.object({
16867
16878
  enabled: exports_external.boolean().default(true),
16868
- threshold_bytes: exports_external.number().min(1024).max(1048576).default(20480),
16879
+ threshold_bytes: exports_external.number().min(1024).max(1048576).default(102400),
16869
16880
  max_summary_chars: exports_external.number().min(100).max(5000).default(1000),
16870
16881
  max_stored_bytes: exports_external.number().min(10240).max(104857600).default(10485760),
16871
16882
  retention_days: exports_external.number().min(1).max(365).default(7),
16872
- exempt_tools: exports_external.array(exports_external.string()).default(["retrieve_summary", "task"])
16883
+ exempt_tools: exports_external.array(exports_external.string()).default(["retrieve_summary", "task", "read"])
16873
16884
  });
16874
16885
  var ReviewPassesConfigSchema = exports_external.object({
16875
16886
  always_security_review: exports_external.boolean().default(false),
package/dist/index.js CHANGED
@@ -14841,6 +14841,17 @@ ${markdown}`;
14841
14841
  unlinkSync(mdTempPath);
14842
14842
  } catch {}
14843
14843
  }
14844
+ try {
14845
+ const markerPath = path4.join(swarmDir, ".plan-write-marker");
14846
+ const tasksCount = validated.phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
14847
+ const marker = JSON.stringify({
14848
+ source: "plan_manager",
14849
+ timestamp: new Date().toISOString(),
14850
+ phases_count: validated.phases.length,
14851
+ tasks_count: tasksCount
14852
+ });
14853
+ await Bun.write(markerPath, marker);
14854
+ } catch {}
14844
14855
  }
14845
14856
  async function updateTaskStatus(directory, taskId, status) {
14846
14857
  const plan = await loadPlan(directory);
@@ -38485,11 +38496,11 @@ var PhaseCompleteConfigSchema = exports_external.object({
38485
38496
  });
38486
38497
  var SummaryConfigSchema = exports_external.object({
38487
38498
  enabled: exports_external.boolean().default(true),
38488
- threshold_bytes: exports_external.number().min(1024).max(1048576).default(20480),
38499
+ threshold_bytes: exports_external.number().min(1024).max(1048576).default(102400),
38489
38500
  max_summary_chars: exports_external.number().min(100).max(5000).default(1000),
38490
38501
  max_stored_bytes: exports_external.number().min(10240).max(104857600).default(10485760),
38491
38502
  retention_days: exports_external.number().min(1).max(365).default(7),
38492
- exempt_tools: exports_external.array(exports_external.string()).default(["retrieve_summary", "task"])
38503
+ exempt_tools: exports_external.array(exports_external.string()).default(["retrieve_summary", "task", "read"])
38493
38504
  });
38494
38505
  var ReviewPassesConfigSchema = exports_external.object({
38495
38506
  always_security_review: exports_external.boolean().default(false),
@@ -41666,6 +41677,7 @@ class PlanSyncWorker {
41666
41677
  this.syncing = true;
41667
41678
  try {
41668
41679
  log("[PlanSyncWorker] Syncing plan...");
41680
+ this.checkForUnauthorizedWrite();
41669
41681
  const plan = await this.withTimeout(loadPlan(this.directory), this.syncTimeoutMs, "Sync operation timed out");
41670
41682
  if (plan) {
41671
41683
  log("[PlanSyncWorker] Sync complete", {
@@ -41707,6 +41719,21 @@ class PlanSyncWorker {
41707
41719
  }
41708
41720
  }
41709
41721
  }
41722
+ checkForUnauthorizedWrite() {
41723
+ try {
41724
+ const swarmDir = this.getSwarmDir();
41725
+ const planJsonPath = path6.join(swarmDir, "plan.json");
41726
+ const markerPath = path6.join(swarmDir, ".plan-write-marker");
41727
+ const planStats = fs3.statSync(planJsonPath);
41728
+ const planMtimeMs = planStats.mtimeMs;
41729
+ const markerContent = fs3.readFileSync(markerPath, "utf8");
41730
+ const marker = JSON.parse(markerContent);
41731
+ const markerTimestampMs = new Date(marker.timestamp).getTime();
41732
+ if (planMtimeMs > markerTimestampMs + 5000) {
41733
+ log("[PlanSyncWorker] WARNING: plan.json may have been written outside save_plan/savePlan - unauthorized direct write suspected", { planMtimeMs, markerTimestampMs });
41734
+ }
41735
+ } catch {}
41736
+ }
41710
41737
  withTimeout(promise2, ms, timeoutMessage) {
41711
41738
  return new Promise((resolve4, reject) => {
41712
41739
  const timer = setTimeout(() => {
@@ -42074,6 +42101,9 @@ function recordPhaseAgentDispatch(sessionId, agentName) {
42074
42101
  session.phaseAgentsDispatched.add(normalizedName);
42075
42102
  }
42076
42103
  function advanceTaskState(session, taskId, newState) {
42104
+ if (!session.taskWorkflowStates) {
42105
+ session.taskWorkflowStates = new Map;
42106
+ }
42077
42107
  const STATE_ORDER = [
42078
42108
  "idle",
42079
42109
  "coder_delegated",
@@ -42094,6 +42124,9 @@ function advanceTaskState(session, taskId, newState) {
42094
42124
  session.taskWorkflowStates.set(taskId, newState);
42095
42125
  }
42096
42126
  function getTaskState(session, taskId) {
42127
+ if (!session.taskWorkflowStates) {
42128
+ session.taskWorkflowStates = new Map;
42129
+ }
42097
42130
  return session.taskWorkflowStates.get(taskId) ?? "idle";
42098
42131
  }
42099
42132
 
@@ -42985,7 +43018,7 @@ async function handleDarkMatterCommand(directory, args2) {
42985
43018
 
42986
43019
  // src/services/diagnose-service.ts
42987
43020
  import { execSync } from "child_process";
42988
- import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync4 } from "fs";
43021
+ import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
42989
43022
  import path12 from "path";
42990
43023
  init_manager();
42991
43024
  init_utils2();
@@ -43291,7 +43324,7 @@ async function checkConfigParseability(directory) {
43291
43324
  };
43292
43325
  }
43293
43326
  try {
43294
- const content = readFileSync3(configPath, "utf-8");
43327
+ const content = readFileSync4(configPath, "utf-8");
43295
43328
  JSON.parse(content);
43296
43329
  return {
43297
43330
  name: "Config Parseability",
@@ -43358,7 +43391,7 @@ async function checkCheckpointManifest(directory) {
43358
43391
  };
43359
43392
  }
43360
43393
  try {
43361
- const content = readFileSync3(manifestPath, "utf-8");
43394
+ const content = readFileSync4(manifestPath, "utf-8");
43362
43395
  const parsed = JSON.parse(content);
43363
43396
  if (!parsed.checkpoints || !Array.isArray(parsed.checkpoints)) {
43364
43397
  return {
@@ -43410,7 +43443,7 @@ async function checkEventStreamIntegrity(directory) {
43410
43443
  };
43411
43444
  }
43412
43445
  try {
43413
- const content = readFileSync3(eventsPath, "utf-8");
43446
+ const content = readFileSync4(eventsPath, "utf-8");
43414
43447
  const lines = content.split(`
43415
43448
  `).filter((line) => line.trim() !== "");
43416
43449
  let malformedCount = 0;
@@ -43451,7 +43484,7 @@ async function checkSteeringDirectives(directory) {
43451
43484
  };
43452
43485
  }
43453
43486
  try {
43454
- const content = readFileSync3(eventsPath, "utf-8");
43487
+ const content = readFileSync4(eventsPath, "utf-8");
43455
43488
  const lines = content.split(`
43456
43489
  `).filter((line) => line.trim() !== "");
43457
43490
  const directivesIssued = [];
@@ -44419,7 +44452,7 @@ async function handleHistoryCommand(directory, _args) {
44419
44452
  }
44420
44453
  // src/hooks/knowledge-migrator.ts
44421
44454
  import { randomUUID as randomUUID2 } from "crypto";
44422
- import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
44455
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
44423
44456
  import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
44424
44457
  import * as path16 from "path";
44425
44458
 
@@ -44976,7 +45009,7 @@ function inferProjectName(directory) {
44976
45009
  const packageJsonPath = path16.join(directory, "package.json");
44977
45010
  if (existsSync8(packageJsonPath)) {
44978
45011
  try {
44979
- const pkg = JSON.parse(readFileSync5(packageJsonPath, "utf-8"));
45012
+ const pkg = JSON.parse(readFileSync6(packageJsonPath, "utf-8"));
44980
45013
  if (pkg.name && typeof pkg.name === "string") {
44981
45014
  return pkg.name;
44982
45015
  }
@@ -47161,7 +47194,7 @@ function shouldMaskToolOutput(msg, index, totalMessages, recentWindowSize, thres
47161
47194
  return false;
47162
47195
  }
47163
47196
  const toolName = extractToolName(text);
47164
- if (toolName && ["retrieve_summary", "task"].includes(toolName.toLowerCase())) {
47197
+ if (toolName && ["retrieve_summary", "task", "read"].includes(toolName.toLowerCase())) {
47165
47198
  return false;
47166
47199
  }
47167
47200
  const age = totalMessages - 1 - index;
@@ -47235,6 +47268,16 @@ function createDelegationGateHook(config3) {
47235
47268
  session.qaSkipCount = 0;
47236
47269
  session.qaSkipTaskIds = [];
47237
47270
  }
47271
+ if (hasReviewer && session.currentTaskId) {
47272
+ try {
47273
+ advanceTaskState(session, session.currentTaskId, "reviewer_run");
47274
+ } catch {}
47275
+ }
47276
+ if (hasReviewer && hasTestEngineer && session.currentTaskId) {
47277
+ try {
47278
+ advanceTaskState(session, session.currentTaskId, "tests_run");
47279
+ } catch {}
47280
+ }
47238
47281
  }
47239
47282
  }
47240
47283
  };
@@ -47725,13 +47768,14 @@ function createGuardrailsHooks(directoryOrConfig, config3) {
47725
47768
  if (typeof targetPath === "string" && targetPath.length > 0) {
47726
47769
  const resolvedTarget = path25.resolve(directory, targetPath).toLowerCase();
47727
47770
  const planMdPath = path25.resolve(directory, ".swarm", "plan.md").toLowerCase();
47728
- if (resolvedTarget === planMdPath) {
47729
- throw new Error("PLAN STATE VIOLATION: Direct writes to .swarm/plan.md are blocked. " + "plan.md is auto-regenerated from plan.json by PlanSyncWorker. " + "Use update_task_status() to mark tasks complete, " + "phase_complete() for phase transitions, or " + "save_plan to create/restructure plans.");
47771
+ const planJsonPath = path25.resolve(directory, ".swarm", "plan.json").toLowerCase();
47772
+ if (resolvedTarget === planMdPath || resolvedTarget === planJsonPath) {
47773
+ throw new Error("PLAN STATE VIOLATION: Direct writes to .swarm/plan.md and .swarm/plan.json are blocked. " + "plan.md is auto-regenerated from plan.json by PlanSyncWorker. " + "Use update_task_status() to mark tasks complete, " + "phase_complete() for phase transitions, or " + "save_plan to create/restructure plans.");
47730
47774
  }
47731
47775
  }
47732
47776
  if (!targetPath && (input.tool === "apply_patch" || input.tool === "patch")) {
47733
47777
  const patchText = args2?.input ?? args2?.patch ?? (Array.isArray(args2?.cmd) ? args2.cmd[1] : undefined);
47734
- if (typeof patchText === "string") {
47778
+ if (typeof patchText === "string" && patchText.length <= 1e6) {
47735
47779
  const patchPathPattern = /\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)/gi;
47736
47780
  const diffPathPattern = /\+\+\+\s+b\/(.+)/gm;
47737
47781
  const paths = new Set;
@@ -47743,11 +47787,41 @@ function createGuardrailsHooks(directoryOrConfig, config3) {
47743
47787
  if (p !== "/dev/null")
47744
47788
  paths.add(p);
47745
47789
  }
47790
+ const gitDiffPathPattern = /^diff --git a\/(.+?) b\/(.+?)$/gm;
47791
+ for (const match of patchText.matchAll(gitDiffPathPattern)) {
47792
+ const aPath = match[1].trim();
47793
+ const bPath = match[2].trim();
47794
+ if (aPath !== "/dev/null")
47795
+ paths.add(aPath);
47796
+ if (bPath !== "/dev/null")
47797
+ paths.add(bPath);
47798
+ }
47799
+ const minusPathPattern = /^---\s+a\/(.+)$/gm;
47800
+ for (const match of patchText.matchAll(minusPathPattern)) {
47801
+ const p = match[1].trim();
47802
+ if (p !== "/dev/null")
47803
+ paths.add(p);
47804
+ }
47805
+ const traditionalMinusPattern = /^---\s+([^\s].+?)(?:\t.*)?$/gm;
47806
+ const traditionalPlusPattern = /^\+\+\+\s+([^\s].+?)(?:\t.*)?$/gm;
47807
+ for (const match of patchText.matchAll(traditionalMinusPattern)) {
47808
+ const p = match[1].trim();
47809
+ if (p !== "/dev/null" && !p.startsWith("a/") && !p.startsWith("b/")) {
47810
+ paths.add(p);
47811
+ }
47812
+ }
47813
+ for (const match of patchText.matchAll(traditionalPlusPattern)) {
47814
+ const p = match[1].trim();
47815
+ if (p !== "/dev/null" && !p.startsWith("a/") && !p.startsWith("b/")) {
47816
+ paths.add(p);
47817
+ }
47818
+ }
47746
47819
  for (const p of paths) {
47747
47820
  const resolvedP = path25.resolve(directory, p);
47748
- const planMdPath = path25.resolve(directory, ".swarm", "plan.md");
47749
- if (resolvedP.toLowerCase() === planMdPath.toLowerCase()) {
47750
- throw new Error("PLAN STATE VIOLATION: Direct writes to .swarm/plan.md are blocked. " + "plan.md is auto-regenerated from plan.json by PlanSyncWorker. " + "Use update_task_status() to mark tasks complete, " + "phase_complete() for phase transitions, or " + "save_plan to create/restructure plans.");
47821
+ const planMdPath = path25.resolve(directory, ".swarm", "plan.md").toLowerCase();
47822
+ const planJsonPath = path25.resolve(directory, ".swarm", "plan.json").toLowerCase();
47823
+ if (resolvedP.toLowerCase() === planMdPath || resolvedP.toLowerCase() === planJsonPath) {
47824
+ throw new Error("PLAN STATE VIOLATION: Direct writes to .swarm/plan.md and .swarm/plan.json are blocked. " + "plan.md is auto-regenerated from plan.json by PlanSyncWorker. " + "Use update_task_status() to mark tasks complete, " + "phase_complete() for phase transitions, or " + "save_plan to create/restructure plans.");
47751
47825
  }
47752
47826
  if (isOutsideSwarmDir(p, directory) && (isSourceCodePath(p) || hasTraversalSegments(p))) {
47753
47827
  const session2 = swarmState.agentSessions.get(input.sessionID);
@@ -47937,6 +48011,26 @@ function createGuardrailsHooks(directoryOrConfig, config3) {
47937
48011
  };
47938
48012
  } else {
47939
48013
  session.lastGateFailure = null;
48014
+ if (input.tool === "pre_check_batch") {
48015
+ const successStr = typeof output.output === "string" ? output.output : "";
48016
+ let isPassed = false;
48017
+ try {
48018
+ const result = JSON.parse(successStr);
48019
+ isPassed = result.gates_passed === true;
48020
+ } catch {
48021
+ isPassed = false;
48022
+ }
48023
+ if (isPassed && session.currentTaskId) {
48024
+ try {
48025
+ advanceTaskState(session, session.currentTaskId, "pre_check_passed");
48026
+ } catch (err2) {
48027
+ warn("Failed to advance task state after pre_check_batch pass", {
48028
+ taskId: session.currentTaskId,
48029
+ error: String(err2)
48030
+ });
48031
+ }
48032
+ }
48033
+ }
47940
48034
  }
47941
48035
  }
47942
48036
  const inputArgs = inputArgsByCallID.get(input.callID);
@@ -49937,7 +50031,11 @@ function createToolSummarizerHook(config3, directory) {
49937
50031
  if (typeof output.output !== "string" || output.output.length === 0) {
49938
50032
  return;
49939
50033
  }
49940
- const exemptTools = config3.exempt_tools ?? ["retrieve_summary", "task"];
50034
+ const exemptTools = config3.exempt_tools ?? [
50035
+ "retrieve_summary",
50036
+ "task",
50037
+ "read"
50038
+ ];
49941
50039
  if (exemptTools.includes(input.tool)) {
49942
50040
  return;
49943
50041
  }
@@ -57119,10 +57217,14 @@ var RETRIEVE_MAX_BYTES = 10 * 1024 * 1024;
57119
57217
  var retrieve_summary = tool({
57120
57218
  description: "Retrieve the full content of a stored tool output summary by its ID (e.g. S1, S2). Use this when a prior tool output was summarized and you need the full content.",
57121
57219
  args: {
57122
- id: tool.schema.string().describe("The summary ID to retrieve (e.g. S1, S2, S99). Must match pattern S followed by digits.")
57220
+ id: tool.schema.string().describe("The summary ID to retrieve (e.g. S1, S2, S99). Must match pattern S followed by digits."),
57221
+ offset: tool.schema.number().min(0).default(0).describe("Line offset to start from (default: 0)."),
57222
+ limit: tool.schema.number().min(1).max(500).default(100).describe("Number of lines to return (default: 100, max: 500).")
57123
57223
  },
57124
57224
  async execute(args2, context) {
57125
57225
  const directory = context.directory;
57226
+ const offset = args2.offset ?? 0;
57227
+ const limit = Math.min(args2.limit ?? 100, 500);
57126
57228
  let sanitizedId;
57127
57229
  try {
57128
57230
  sanitizedId = sanitizeSummaryId(args2.id);
@@ -57141,13 +57243,46 @@ var retrieve_summary = tool({
57141
57243
  if (fullOutput.length > RETRIEVE_MAX_BYTES) {
57142
57244
  return `Error: summary content exceeds maximum size limit (10 MB).`;
57143
57245
  }
57144
- return fullOutput;
57246
+ if (fullOutput.length === 0) {
57247
+ return `--- No content (0 lines) ---
57248
+
57249
+ (Summary is empty)`;
57250
+ }
57251
+ const lines = fullOutput.split(`
57252
+ `);
57253
+ const totalLines = lines.length;
57254
+ const clampedOffset = Math.max(0, offset);
57255
+ if (clampedOffset >= totalLines) {
57256
+ const response2 = `--- Offset beyond range ---
57257
+
57258
+ (Range exhausted. Valid offset range: 0-${totalLines - 1})
57259
+ (Content has ${totalLines} line${totalLines === 1 ? "" : "s"})`;
57260
+ return response2;
57261
+ }
57262
+ const startLine = Math.min(clampedOffset, totalLines);
57263
+ const endLine = Math.min(startLine + limit, totalLines);
57264
+ const paginatedLines = lines.slice(startLine, endLine);
57265
+ const paginatedContent = paginatedLines.join(`
57266
+ `);
57267
+ const headerStart = startLine + 1;
57268
+ const headerEnd = endLine;
57269
+ const rangeHeader = `--- Lines ${headerStart}-${headerEnd} of ${totalLines} ---`;
57270
+ let response = `${rangeHeader}
57271
+ ${paginatedContent}`;
57272
+ if (endLine < totalLines) {
57273
+ const remaining = totalLines - endLine;
57274
+ response += `
57275
+
57276
+ ... ${remaining} more line${remaining === 1 ? "" : "s"}. Use offset=${endLine} to retrieve more.`;
57277
+ }
57278
+ return response;
57145
57279
  }
57146
57280
  });
57147
57281
  // src/tools/save-plan.ts
57148
57282
  init_tool();
57149
57283
  init_manager2();
57150
57284
  init_create_tool();
57285
+ import * as fs28 from "fs";
57151
57286
  import * as path39 from "path";
57152
57287
  function detectPlaceholderContent(args2) {
57153
57288
  const issues = [];
@@ -57182,12 +57317,33 @@ function validateTargetWorkspace(target, source) {
57182
57317
  return;
57183
57318
  }
57184
57319
  async function executeSavePlan(args2, fallbackDir) {
57320
+ const validationErrors = [];
57321
+ for (const phase of args2.phases) {
57322
+ if (!Number.isInteger(phase.id) || phase.id <= 0) {
57323
+ validationErrors.push(`Phase ${phase.id} has invalid id: must be a positive integer`);
57324
+ }
57325
+ const taskIdPattern = /^\d+\.\d+(\.\d+)*$/;
57326
+ for (const task of phase.tasks) {
57327
+ if (!taskIdPattern.test(task.id)) {
57328
+ validationErrors.push(`Task '${task.id}' in phase ${phase.id} has invalid id format: must match N.M pattern (e.g. '1.1', '2.3')`);
57329
+ }
57330
+ }
57331
+ }
57332
+ if (validationErrors.length > 0) {
57333
+ return {
57334
+ success: false,
57335
+ message: "Plan rejected: invalid phase or task IDs",
57336
+ errors: validationErrors,
57337
+ recovery_guidance: "Use save_plan with corrected inputs to create or restructure plans. Never write .swarm/plan.json or .swarm/plan.md directly."
57338
+ };
57339
+ }
57185
57340
  const placeholderIssues = detectPlaceholderContent(args2);
57186
57341
  if (placeholderIssues.length > 0) {
57187
57342
  return {
57188
57343
  success: false,
57189
57344
  message: "Plan rejected: contains template placeholder content",
57190
- errors: placeholderIssues
57345
+ errors: placeholderIssues,
57346
+ recovery_guidance: "Use save_plan with corrected inputs to create or restructure plans. Never write .swarm/plan.json or .swarm/plan.md directly."
57191
57347
  };
57192
57348
  }
57193
57349
  const targetWorkspace = args2.working_directory ?? fallbackDir;
@@ -57195,8 +57351,9 @@ async function executeSavePlan(args2, fallbackDir) {
57195
57351
  if (workspaceError) {
57196
57352
  return {
57197
57353
  success: false,
57198
- message: "Target workspace validation failed",
57199
- errors: [workspaceError]
57354
+ message: "Target workspace validation failed: provide working_directory parameter to save_plan",
57355
+ errors: [workspaceError],
57356
+ recovery_guidance: "Use save_plan with corrected inputs to create or restructure plans. Never write .swarm/plan.json or .swarm/plan.md directly."
57200
57357
  };
57201
57358
  }
57202
57359
  const plan = {
@@ -57229,6 +57386,16 @@ async function executeSavePlan(args2, fallbackDir) {
57229
57386
  const dir = targetWorkspace;
57230
57387
  try {
57231
57388
  await savePlan(dir, plan);
57389
+ try {
57390
+ const markerPath = path39.join(dir, ".swarm", ".plan-write-marker");
57391
+ const marker = JSON.stringify({
57392
+ source: "save_plan",
57393
+ timestamp: new Date().toISOString(),
57394
+ phases_count: plan.phases.length,
57395
+ tasks_count: tasksCount
57396
+ });
57397
+ await fs28.promises.writeFile(markerPath, marker, "utf8");
57398
+ } catch {}
57232
57399
  return {
57233
57400
  success: true,
57234
57401
  message: "Plan saved successfully",
@@ -57239,8 +57406,9 @@ async function executeSavePlan(args2, fallbackDir) {
57239
57406
  } catch (error93) {
57240
57407
  return {
57241
57408
  success: false,
57242
- message: "Failed to save plan",
57243
- errors: [String(error93)]
57409
+ message: "Failed to save plan: retry with save_plan after resolving the error above",
57410
+ errors: [String(error93)],
57411
+ recovery_guidance: "Use save_plan with corrected inputs to create or restructure plans. Never write .swarm/plan.json or .swarm/plan.md directly."
57244
57412
  };
57245
57413
  }
57246
57414
  }
@@ -57269,7 +57437,7 @@ var save_plan = createSwarmTool({
57269
57437
  // src/tools/sbom-generate.ts
57270
57438
  init_dist();
57271
57439
  init_manager();
57272
- import * as fs28 from "fs";
57440
+ import * as fs29 from "fs";
57273
57441
  import * as path40 from "path";
57274
57442
 
57275
57443
  // src/sbom/detectors/dart.ts
@@ -58116,7 +58284,7 @@ function findManifestFiles(rootDir) {
58116
58284
  const patterns = [...new Set(allDetectors.flatMap((d) => d.patterns))];
58117
58285
  function searchDir(dir) {
58118
58286
  try {
58119
- const entries = fs28.readdirSync(dir, { withFileTypes: true });
58287
+ const entries = fs29.readdirSync(dir, { withFileTypes: true });
58120
58288
  for (const entry of entries) {
58121
58289
  const fullPath = path40.join(dir, entry.name);
58122
58290
  if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist" || entry.name === "build" || entry.name === "target") {
@@ -58145,7 +58313,7 @@ function findManifestFilesInDirs(directories, workingDir) {
58145
58313
  const patterns = [...new Set(allDetectors.flatMap((d) => d.patterns))];
58146
58314
  for (const dir of directories) {
58147
58315
  try {
58148
- const entries = fs28.readdirSync(dir, { withFileTypes: true });
58316
+ const entries = fs29.readdirSync(dir, { withFileTypes: true });
58149
58317
  for (const entry of entries) {
58150
58318
  const fullPath = path40.join(dir, entry.name);
58151
58319
  if (entry.isFile()) {
@@ -58183,7 +58351,7 @@ function getDirectoriesFromChangedFiles(changedFiles, workingDir) {
58183
58351
  }
58184
58352
  function ensureOutputDir(outputDir) {
58185
58353
  try {
58186
- fs28.mkdirSync(outputDir, { recursive: true });
58354
+ fs29.mkdirSync(outputDir, { recursive: true });
58187
58355
  } catch (error93) {
58188
58356
  if (!error93 || error93.code !== "EEXIST") {
58189
58357
  throw error93;
@@ -58276,10 +58444,10 @@ var sbom_generate = createSwarmTool({
58276
58444
  for (const manifestFile of manifestFiles) {
58277
58445
  try {
58278
58446
  const fullPath = path40.isAbsolute(manifestFile) ? manifestFile : path40.join(workingDir, manifestFile);
58279
- if (!fs28.existsSync(fullPath)) {
58447
+ if (!fs29.existsSync(fullPath)) {
58280
58448
  continue;
58281
58449
  }
58282
- const content = fs28.readFileSync(fullPath, "utf-8");
58450
+ const content = fs29.readFileSync(fullPath, "utf-8");
58283
58451
  const components = detectComponents(manifestFile, content);
58284
58452
  processedFiles.push(manifestFile);
58285
58453
  if (components.length > 0) {
@@ -58293,7 +58461,7 @@ var sbom_generate = createSwarmTool({
58293
58461
  const bomJson = serializeCycloneDX(bom);
58294
58462
  const filename = generateSbomFilename();
58295
58463
  const outputPath = path40.join(outputDir, filename);
58296
- fs28.writeFileSync(outputPath, bomJson, "utf-8");
58464
+ fs29.writeFileSync(outputPath, bomJson, "utf-8");
58297
58465
  const verdict = processedFiles.length > 0 ? "pass" : "pass";
58298
58466
  try {
58299
58467
  const timestamp = new Date().toISOString();
@@ -58335,7 +58503,7 @@ var sbom_generate = createSwarmTool({
58335
58503
  // src/tools/schema-drift.ts
58336
58504
  init_dist();
58337
58505
  init_create_tool();
58338
- import * as fs29 from "fs";
58506
+ import * as fs30 from "fs";
58339
58507
  import * as path41 from "path";
58340
58508
  var SPEC_CANDIDATES = [
58341
58509
  "openapi.json",
@@ -58377,19 +58545,19 @@ function discoverSpecFile(cwd, specFileArg) {
58377
58545
  if (!ALLOWED_EXTENSIONS.includes(ext)) {
58378
58546
  throw new Error(`Invalid spec_file: must end in .json, .yaml, or .yml, got ${ext}`);
58379
58547
  }
58380
- const stats = fs29.statSync(resolvedPath);
58548
+ const stats = fs30.statSync(resolvedPath);
58381
58549
  if (stats.size > MAX_SPEC_SIZE) {
58382
58550
  throw new Error(`Invalid spec_file: file exceeds ${MAX_SPEC_SIZE / 1024 / 1024}MB limit`);
58383
58551
  }
58384
- if (!fs29.existsSync(resolvedPath)) {
58552
+ if (!fs30.existsSync(resolvedPath)) {
58385
58553
  throw new Error(`Spec file not found: ${resolvedPath}`);
58386
58554
  }
58387
58555
  return resolvedPath;
58388
58556
  }
58389
58557
  for (const candidate of SPEC_CANDIDATES) {
58390
58558
  const candidatePath = path41.resolve(cwd, candidate);
58391
- if (fs29.existsSync(candidatePath)) {
58392
- const stats = fs29.statSync(candidatePath);
58559
+ if (fs30.existsSync(candidatePath)) {
58560
+ const stats = fs30.statSync(candidatePath);
58393
58561
  if (stats.size <= MAX_SPEC_SIZE) {
58394
58562
  return candidatePath;
58395
58563
  }
@@ -58398,7 +58566,7 @@ function discoverSpecFile(cwd, specFileArg) {
58398
58566
  return null;
58399
58567
  }
58400
58568
  function parseSpec(specFile) {
58401
- const content = fs29.readFileSync(specFile, "utf-8");
58569
+ const content = fs30.readFileSync(specFile, "utf-8");
58402
58570
  const ext = path41.extname(specFile).toLowerCase();
58403
58571
  if (ext === ".json") {
58404
58572
  return parseJsonSpec(content);
@@ -58465,7 +58633,7 @@ function extractRoutes(cwd) {
58465
58633
  function walkDir(dir) {
58466
58634
  let entries;
58467
58635
  try {
58468
- entries = fs29.readdirSync(dir, { withFileTypes: true });
58636
+ entries = fs30.readdirSync(dir, { withFileTypes: true });
58469
58637
  } catch {
58470
58638
  return;
58471
58639
  }
@@ -58498,7 +58666,7 @@ function extractRoutes(cwd) {
58498
58666
  }
58499
58667
  function extractRoutesFromFile(filePath) {
58500
58668
  const routes = [];
58501
- const content = fs29.readFileSync(filePath, "utf-8");
58669
+ const content = fs30.readFileSync(filePath, "utf-8");
58502
58670
  const lines = content.split(/\r?\n/);
58503
58671
  const expressRegex = /(?:app|router|server|express)\.(get|post|put|patch|delete|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/g;
58504
58672
  const flaskRegex = /@(?:app|blueprint|bp)\.route\s*\(\s*['"]([^'"]+)['"]/g;
@@ -58649,7 +58817,7 @@ init_secretscan();
58649
58817
  // src/tools/symbols.ts
58650
58818
  init_tool();
58651
58819
  init_create_tool();
58652
- import * as fs30 from "fs";
58820
+ import * as fs31 from "fs";
58653
58821
  import * as path42 from "path";
58654
58822
  var MAX_FILE_SIZE_BYTES7 = 1024 * 1024;
58655
58823
  var WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|:|$)/i;
@@ -58680,8 +58848,8 @@ function containsWindowsAttacks(str) {
58680
58848
  function isPathInWorkspace(filePath, workspace) {
58681
58849
  try {
58682
58850
  const resolvedPath = path42.resolve(workspace, filePath);
58683
- const realWorkspace = fs30.realpathSync(workspace);
58684
- const realResolvedPath = fs30.realpathSync(resolvedPath);
58851
+ const realWorkspace = fs31.realpathSync(workspace);
58852
+ const realResolvedPath = fs31.realpathSync(resolvedPath);
58685
58853
  const relativePath = path42.relative(realWorkspace, realResolvedPath);
58686
58854
  if (relativePath.startsWith("..") || path42.isAbsolute(relativePath)) {
58687
58855
  return false;
@@ -58701,11 +58869,11 @@ function extractTSSymbols(filePath, cwd) {
58701
58869
  }
58702
58870
  let content;
58703
58871
  try {
58704
- const stats = fs30.statSync(fullPath);
58872
+ const stats = fs31.statSync(fullPath);
58705
58873
  if (stats.size > MAX_FILE_SIZE_BYTES7) {
58706
58874
  throw new Error(`File too large: ${stats.size} bytes (max: ${MAX_FILE_SIZE_BYTES7})`);
58707
58875
  }
58708
- content = fs30.readFileSync(fullPath, "utf-8");
58876
+ content = fs31.readFileSync(fullPath, "utf-8");
58709
58877
  } catch {
58710
58878
  return [];
58711
58879
  }
@@ -58853,11 +59021,11 @@ function extractPythonSymbols(filePath, cwd) {
58853
59021
  }
58854
59022
  let content;
58855
59023
  try {
58856
- const stats = fs30.statSync(fullPath);
59024
+ const stats = fs31.statSync(fullPath);
58857
59025
  if (stats.size > MAX_FILE_SIZE_BYTES7) {
58858
59026
  throw new Error(`File too large: ${stats.size} bytes (max: ${MAX_FILE_SIZE_BYTES7})`);
58859
59027
  }
58860
- content = fs30.readFileSync(fullPath, "utf-8");
59028
+ content = fs31.readFileSync(fullPath, "utf-8");
58861
59029
  } catch {
58862
59030
  return [];
58863
59031
  }
@@ -59000,7 +59168,7 @@ init_test_runner();
59000
59168
  // src/tools/todo-extract.ts
59001
59169
  init_dist();
59002
59170
  init_create_tool();
59003
- import * as fs31 from "fs";
59171
+ import * as fs32 from "fs";
59004
59172
  import * as path43 from "path";
59005
59173
  var MAX_TEXT_LENGTH = 200;
59006
59174
  var MAX_FILE_SIZE_BYTES8 = 1024 * 1024;
@@ -59096,7 +59264,7 @@ function isSupportedExtension(filePath) {
59096
59264
  function findSourceFiles3(dir, files = []) {
59097
59265
  let entries;
59098
59266
  try {
59099
- entries = fs31.readdirSync(dir);
59267
+ entries = fs32.readdirSync(dir);
59100
59268
  } catch {
59101
59269
  return files;
59102
59270
  }
@@ -59108,7 +59276,7 @@ function findSourceFiles3(dir, files = []) {
59108
59276
  const fullPath = path43.join(dir, entry);
59109
59277
  let stat2;
59110
59278
  try {
59111
- stat2 = fs31.statSync(fullPath);
59279
+ stat2 = fs32.statSync(fullPath);
59112
59280
  } catch {
59113
59281
  continue;
59114
59282
  }
@@ -59201,7 +59369,7 @@ var todo_extract = createSwarmTool({
59201
59369
  return JSON.stringify(errorResult, null, 2);
59202
59370
  }
59203
59371
  const scanPath = resolvedPath;
59204
- if (!fs31.existsSync(scanPath)) {
59372
+ if (!fs32.existsSync(scanPath)) {
59205
59373
  const errorResult = {
59206
59374
  error: `path not found: ${pathsInput}`,
59207
59375
  total: 0,
@@ -59211,7 +59379,7 @@ var todo_extract = createSwarmTool({
59211
59379
  return JSON.stringify(errorResult, null, 2);
59212
59380
  }
59213
59381
  const filesToScan = [];
59214
- const stat2 = fs31.statSync(scanPath);
59382
+ const stat2 = fs32.statSync(scanPath);
59215
59383
  if (stat2.isFile()) {
59216
59384
  if (isSupportedExtension(scanPath)) {
59217
59385
  filesToScan.push(scanPath);
@@ -59230,11 +59398,11 @@ var todo_extract = createSwarmTool({
59230
59398
  const allEntries = [];
59231
59399
  for (const filePath of filesToScan) {
59232
59400
  try {
59233
- const fileStat = fs31.statSync(filePath);
59401
+ const fileStat = fs32.statSync(filePath);
59234
59402
  if (fileStat.size > MAX_FILE_SIZE_BYTES8) {
59235
59403
  continue;
59236
59404
  }
59237
- const content = fs31.readFileSync(filePath, "utf-8");
59405
+ const content = fs32.readFileSync(filePath, "utf-8");
59238
59406
  const entries = parseTodoComments(content, filePath, tagsSet);
59239
59407
  allEntries.push(...entries);
59240
59408
  } catch {}
@@ -59262,7 +59430,7 @@ var todo_extract = createSwarmTool({
59262
59430
  // src/tools/update-task-status.ts
59263
59431
  init_tool();
59264
59432
  init_manager2();
59265
- import * as fs32 from "fs";
59433
+ import * as fs33 from "fs";
59266
59434
  import * as path44 from "path";
59267
59435
  init_create_tool();
59268
59436
  var VALID_STATUSES = [
@@ -59360,9 +59528,9 @@ async function executeUpdateTaskStatus(args2, fallbackDir) {
59360
59528
  }
59361
59529
  const resolvedDir = path44.resolve(normalizedDir);
59362
59530
  try {
59363
- const realPath = fs32.realpathSync(resolvedDir);
59531
+ const realPath = fs33.realpathSync(resolvedDir);
59364
59532
  const planPath = path44.join(realPath, ".swarm", "plan.json");
59365
- if (!fs32.existsSync(planPath)) {
59533
+ if (!fs33.existsSync(planPath)) {
59366
59534
  return {
59367
59535
  success: false,
59368
59536
  message: `Invalid working_directory: plan not found in "${realPath}"`,
@@ -32,6 +32,7 @@ export interface SavePlanResult {
32
32
  phases_count?: number;
33
33
  tasks_count?: number;
34
34
  errors?: string[];
35
+ recovery_guidance?: string;
35
36
  }
36
37
  /**
37
38
  * Detect template placeholder content (e.g., [task], [Project], [description], [N]).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-swarm",
3
- "version": "6.21.0",
3
+ "version": "6.21.1",
4
4
  "description": "Architect-centric agentic swarm plugin for OpenCode - hub-and-spoke orchestration with SME consultation, code generation, and QA review",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",