opencode-swarm 7.19.0 → 7.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -34,7 +34,7 @@ var package_default;
34
34
  var init_package = __esm(() => {
35
35
  package_default = {
36
36
  name: "opencode-swarm",
37
- version: "7.19.0",
37
+ version: "7.19.2",
38
38
  description: "Architect-centric agentic swarm plugin for OpenCode - hub-and-spoke orchestration with SME consultation, code generation, and QA review",
39
39
  main: "dist/index.js",
40
40
  types: "dist/index.d.ts",
@@ -14893,6 +14893,17 @@ function applyEventToPlan(plan, event) {
14893
14893
  return plan;
14894
14894
  case "task_added":
14895
14895
  return plan;
14896
+ case "task_removed":
14897
+ if (event.task_id) {
14898
+ for (const phase of plan.phases) {
14899
+ const idx = phase.tasks.findIndex((t) => t.id === event.task_id);
14900
+ if (idx !== -1) {
14901
+ phase.tasks.splice(idx, 1);
14902
+ break;
14903
+ }
14904
+ }
14905
+ }
14906
+ return plan;
14896
14907
  case "task_updated":
14897
14908
  return plan;
14898
14909
  case "plan_rebuilt":
@@ -15000,7 +15011,7 @@ async function loadLastApprovedPlan(directory, expectedPlanId) {
15000
15011
  }
15001
15012
  return null;
15002
15013
  }
15003
- var LEDGER_SCHEMA_VERSION = "1.0.0", LEDGER_FILENAME = "plan-ledger.jsonl", PLAN_JSON_FILENAME = "plan.json", LedgerStaleWriterError;
15014
+ var LEDGER_SCHEMA_VERSION = "1.1.0", LEDGER_FILENAME = "plan-ledger.jsonl", PLAN_JSON_FILENAME = "plan.json", LedgerStaleWriterError;
15004
15015
  var init_ledger = __esm(() => {
15005
15016
  init_plan_schema();
15006
15017
  LedgerStaleWriterError = class LedgerStaleWriterError extends Error {
@@ -15290,7 +15301,13 @@ async function loadPlan(directory) {
15290
15301
  const planMdContent2 = await readSwarmFileAsync(directory, "plan.md");
15291
15302
  if (planMdContent2 !== null) {
15292
15303
  const migrated = migrateLegacyPlan(planMdContent2);
15293
- await savePlan(directory, migrated);
15304
+ const { removedCount } = await savePlanWithAutoAcknowledgedRemovals(directory, migrated, "load_plan_migration_from_md", "migrate legacy plan.md to plan.json");
15305
+ if (removedCount > 0) {
15306
+ migrated._midLoadRemovals = {
15307
+ count: removedCount,
15308
+ source: "load_plan_migration_from_md"
15309
+ };
15310
+ }
15294
15311
  return migrated;
15295
15312
  }
15296
15313
  }
@@ -15319,7 +15336,13 @@ async function loadPlan(directory) {
15319
15336
  try {
15320
15337
  const rebuilt = await replayFromLedger(directory);
15321
15338
  if (rebuilt) {
15322
- await savePlan(directory, rebuilt);
15339
+ const { removedCount } = await savePlanWithAutoAcknowledgedRemovals(directory, rebuilt, "load_plan_rebuild_from_ledger", "rebuild plan from ledger replay");
15340
+ if (removedCount > 0) {
15341
+ rebuilt._midLoadRemovals = {
15342
+ count: removedCount,
15343
+ source: "load_plan_rebuild_from_ledger"
15344
+ };
15345
+ }
15323
15346
  return rebuilt;
15324
15347
  }
15325
15348
  try {
@@ -15333,7 +15356,13 @@ async function loadPlan(directory) {
15333
15356
  if (approved) {
15334
15357
  const approvedPhase = approved.approval && typeof approved.approval === "object" && "phase" in approved.approval ? approved.approval.phase : undefined;
15335
15358
  warn(`[loadPlan] Ledger replay returned no plan \u2014 recovered from critic-approved snapshot seq=${approved.seq} timestamp=${approved.timestamp} (approval phase=${approvedPhase ?? "unknown"}). This may roll the plan back to an earlier phase \u2014 verify before continuing.`);
15336
- await savePlan(directory, approved.plan);
15359
+ const { removedCount: snapshotRemovedCount } = await savePlanWithAutoAcknowledgedRemovals(directory, approved.plan, "load_plan_recovery_from_approved_snapshot", "restore from critic-approved snapshot");
15360
+ if (snapshotRemovedCount > 0) {
15361
+ approved.plan._midLoadRemovals = {
15362
+ count: snapshotRemovedCount,
15363
+ source: "load_plan_recovery_from_approved_snapshot"
15364
+ };
15365
+ }
15337
15366
  try {
15338
15367
  await takeSnapshotEvent(directory, approved.plan, {
15339
15368
  source: "recovery_from_approved_snapshot",
@@ -15354,6 +15383,28 @@ async function loadPlan(directory) {
15354
15383
  }
15355
15384
  return null;
15356
15385
  }
15386
+ async function savePlanWithAutoAcknowledgedRemovals(directory, plan, source, reason, options) {
15387
+ const existing = await _internals3.loadPlanJsonOnly(directory);
15388
+ const newIds = new Set;
15389
+ for (const phase of plan.phases) {
15390
+ for (const task of phase.tasks)
15391
+ newIds.add(task.id);
15392
+ }
15393
+ const removedIds = [];
15394
+ if (existing) {
15395
+ for (const phase of existing.phases) {
15396
+ for (const task of phase.tasks) {
15397
+ if (!newIds.has(task.id))
15398
+ removedIds.push(task.id);
15399
+ }
15400
+ }
15401
+ }
15402
+ await savePlan(directory, plan, {
15403
+ ...options ?? {},
15404
+ acknowledged_removals: { ids: removedIds, reason, source }
15405
+ });
15406
+ return { removedCount: removedIds.length };
15407
+ }
15357
15408
  async function savePlan(directory, plan, options) {
15358
15409
  if (directory === null || directory === undefined || typeof directory !== "string" || directory.trim().length === 0) {
15359
15410
  throw new Error(`Invalid directory: directory must be a non-empty string`);
@@ -15490,6 +15541,73 @@ async function savePlan(directory, plan, options) {
15490
15541
  oldTaskMap.set(task.id, { phase: task.phase, status: task.status });
15491
15542
  }
15492
15543
  }
15544
+ const newTaskIds = new Set;
15545
+ for (const phase of validated.phases) {
15546
+ for (const task of phase.tasks)
15547
+ newTaskIds.add(task.id);
15548
+ }
15549
+ const missingTasks = [];
15550
+ for (const [id, info] of oldTaskMap.entries()) {
15551
+ if (!newTaskIds.has(id)) {
15552
+ missingTasks.push({ id, phase: info.phase, status: info.status });
15553
+ }
15554
+ }
15555
+ const ack = options?.acknowledged_removals;
15556
+ if (missingTasks.length > 0) {
15557
+ if (!ack) {
15558
+ throw new PlanTaskRemovalNotAcknowledgedError(missingTasks);
15559
+ }
15560
+ if (typeof ack.reason !== "string" || ack.reason.trim().length === 0) {
15561
+ throw new Error("PLAN_ACKNOWLEDGED_REMOVAL_INVALID: acknowledged_removals.reason must be a non-empty string.");
15562
+ }
15563
+ if (typeof ack.source !== "string" || ack.source.trim().length === 0) {
15564
+ throw new Error("PLAN_ACKNOWLEDGED_REMOVAL_INVALID: acknowledged_removals.source must be a non-empty string.");
15565
+ }
15566
+ const ackSet = new Set(ack.ids);
15567
+ const missingIdsSet = new Set(missingTasks.map((t) => t.id));
15568
+ const unacked = missingTasks.filter((t) => !ackSet.has(t.id));
15569
+ if (unacked.length > 0) {
15570
+ throw new PlanTaskRemovalNotAcknowledgedError(unacked);
15571
+ }
15572
+ for (const id of ack.ids) {
15573
+ if (!missingIdsSet.has(id)) {
15574
+ throw new Error(`PLAN_ACKNOWLEDGED_REMOVAL_INVALID: acknowledged_removals contains "${id}" but that task is not missing from the plan.`);
15575
+ }
15576
+ }
15577
+ try {
15578
+ for (const missing of missingTasks) {
15579
+ const eventInput = {
15580
+ plan_id: derivePlanId(validated),
15581
+ event_type: "task_removed",
15582
+ task_id: missing.id,
15583
+ phase_id: missing.phase,
15584
+ from_status: missing.status,
15585
+ source: ack.source,
15586
+ payload: { reason: ack.reason, source: ack.source }
15587
+ };
15588
+ const capturedTaskId = missing.id;
15589
+ await retryCasWithBackoff(directory, eventInput, {
15590
+ expectedHash: currentHash,
15591
+ planHashAfter: hashAfter,
15592
+ verifyValid: async () => {
15593
+ const onDisk = await _internals3.loadPlanJsonOnly(directory);
15594
+ if (!onDisk)
15595
+ return true;
15596
+ for (const p of onDisk.phases) {
15597
+ if (p.tasks.some((x) => x.id === capturedTaskId))
15598
+ return true;
15599
+ }
15600
+ return false;
15601
+ }
15602
+ });
15603
+ }
15604
+ } catch (error49) {
15605
+ if (error49 instanceof LedgerStaleWriterError) {
15606
+ throw new PlanConcurrentModificationError(`Concurrent plan modification detected after retries: ${error49.message}. Please retry the operation.`);
15607
+ }
15608
+ throw error49;
15609
+ }
15610
+ }
15493
15611
  try {
15494
15612
  for (const phase of validated.phases) {
15495
15613
  for (const task of phase.tasks) {
@@ -15890,7 +16008,7 @@ function migrateLegacyPlan(planContent, swarmId) {
15890
16008
  };
15891
16009
  return plan;
15892
16010
  }
15893
- var PlanConcurrentModificationError, startupLedgerCheckedWorkspaces, recoveryMutexes, _internals3, CAS_BACKOFF_START_MS = 5, CAS_BACKOFF_CAP_MS = 250, CAS_BACKOFF_JITTER = 0.25, CAS_MAX_RETRIES = 3;
16011
+ var PlanConcurrentModificationError, PlanTaskRemovalNotAcknowledgedError, startupLedgerCheckedWorkspaces, recoveryMutexes, _internals3, CAS_BACKOFF_START_MS = 5, CAS_BACKOFF_CAP_MS = 250, CAS_BACKOFF_JITTER = 0.25, CAS_MAX_RETRIES = 3;
15894
16012
  var init_manager = __esm(() => {
15895
16013
  init_plan_schema();
15896
16014
  init_utils2();
@@ -15905,6 +16023,15 @@ var init_manager = __esm(() => {
15905
16023
  this.name = "PlanConcurrentModificationError";
15906
16024
  }
15907
16025
  };
16026
+ PlanTaskRemovalNotAcknowledgedError = class PlanTaskRemovalNotAcknowledgedError extends Error {
16027
+ missingTasks;
16028
+ constructor(missingTasks) {
16029
+ const idList = missingTasks.map((t) => `${t.id}(${t.status})`).join(", ");
16030
+ super(`PLAN_TASK_REMOVAL_NOT_ACKNOWLEDGED: the following tasks were present in the prior plan but missing from the new save: ${idList}. Pass acknowledged_removals.ids covering all missing task IDs with a non-empty reason to proceed.`);
16031
+ this.name = "PlanTaskRemovalNotAcknowledgedError";
16032
+ this.missingTasks = missingTasks;
16033
+ }
16034
+ };
15908
16035
  startupLedgerCheckedWorkspaces = new Set;
15909
16036
  recoveryMutexes = new Map;
15910
16037
  _internals3 = {
@@ -20821,7 +20948,7 @@ var init_model_limits = __esm(() => {
20821
20948
  var init_normalize_tool_name = () => {};
20822
20949
 
20823
20950
  // src/hooks/guardrails.ts
20824
- var storedInputArgs, TRANSIENT_STATUS_CODES, toolCallsSinceLastWrite, noOpWarningIssued, consecutiveNoToolTurns, DC_SAFE_TARGETS, DC_FS_ROOTS, pathNormalizationCache, globMatcherCache;
20951
+ var SPEC_DRIFT_BLOCKED_TOOLS, storedInputArgs, TRANSIENT_STATUS_CODES, toolCallsSinceLastWrite, noOpWarningIssued, consecutiveNoToolTurns, DC_SAFE_TARGETS, DC_FS_ROOTS, pathNormalizationCache, globMatcherCache;
20825
20952
  var init_guardrails = __esm(() => {
20826
20953
  init_quick_lru();
20827
20954
  init_agents2();
@@ -20840,6 +20967,13 @@ var init_guardrails = __esm(() => {
20840
20967
  init_loop_detector();
20841
20968
  init_model_limits();
20842
20969
  init_normalize_tool_name();
20970
+ SPEC_DRIFT_BLOCKED_TOOLS = new Set([
20971
+ "save_plan",
20972
+ "update_task_status",
20973
+ "phase_complete",
20974
+ "lean_turbo_run_phase",
20975
+ "lean_turbo_acquire_locks"
20976
+ ]);
20843
20977
  storedInputArgs = new Map;
20844
20978
  TRANSIENT_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504, 529]);
20845
20979
  toolCallsSinceLastWrite = new Map;
@@ -48620,7 +48754,7 @@ function analyzeFailures(workingDir) {
48620
48754
  } catch {}
48621
48755
  return report;
48622
48756
  }
48623
- var MAX_OUTPUT_BYTES3 = 512000, MAX_COMMAND_LENGTH2 = 500, DEFAULT_TIMEOUT_MS = 60000, MAX_TIMEOUT_MS = 300000, MAX_SAFE_TEST_FILES = 50, POWERSHELL_METACHARACTERS, DISPATCH_FRAMEWORK_MAP, COMPOUND_TEST_EXTENSIONS, TEST_DIRECTORY_NAMES, SOURCE_EXTENSIONS, SKIP_DIRECTORIES, test_runner;
48757
+ var MAX_OUTPUT_BYTES3 = 512000, MAX_COMMAND_LENGTH2 = 500, DEFAULT_TIMEOUT_MS = 60000, MAX_TIMEOUT_MS = 300000, MAX_SAFE_TEST_FILES = 50, MAX_SAFE_SOURCE_FILES = 1, POWERSHELL_METACHARACTERS, DISPATCH_FRAMEWORK_MAP, COMPOUND_TEST_EXTENSIONS, TEST_DIRECTORY_NAMES, SOURCE_EXTENSIONS, SKIP_DIRECTORIES, test_runner;
48624
48758
  var init_test_runner = __esm(() => {
48625
48759
  init_zod();
48626
48760
  init_discovery();
@@ -48807,8 +48941,8 @@ var init_test_runner = __esm(() => {
48807
48941
  success: false,
48808
48942
  framework: "none",
48809
48943
  scope: "all",
48810
- error: 'scope "all" is not allowed without explicit files. Use scope "convention" or "graph" with a files array to run targeted tests.',
48811
- message: 'Running the full test suite without file targeting is blocked. Provide scope "convention" or "graph" with specific source files in the files array. Example: { scope: "convention", files: ["src/tools/test-runner.ts"] }',
48944
+ error: 'scope "all" is blocked for agent use. Use scope "convention" with specific test files, or scope "graph" with exactly one source file.',
48945
+ message: 'The full test suite is blocked in agent context. Use scope "convention" with specific test files, or scope "graph" with exactly one source file. Example: { scope: "convention", files: ["src/tools/test-runner.ts"] }',
48812
48946
  outcome: "error"
48813
48947
  };
48814
48948
  return JSON.stringify(errorResult, null, 2);
@@ -48889,6 +49023,17 @@ var init_test_runner = __esm(() => {
48889
49023
  };
48890
49024
  return JSON.stringify(errorResult, null, 2);
48891
49025
  }
49026
+ if (sourceFiles.length > MAX_SAFE_SOURCE_FILES) {
49027
+ const errorResult = {
49028
+ success: false,
49029
+ framework,
49030
+ scope,
49031
+ error: `scope "convention" accepts at most ${MAX_SAFE_SOURCE_FILES} source file for discovery (got ${sourceFiles.length}). Treat this as SKIP without retry.`,
49032
+ message: `Too many source files for scope "convention" discovery (${sourceFiles.length} provided, limit is ${MAX_SAFE_SOURCE_FILES}). Call test_runner once per source file, or pass direct test file paths instead of source files.`,
49033
+ outcome: "scope_exceeded"
49034
+ };
49035
+ return JSON.stringify(errorResult, null, 2);
49036
+ }
48892
49037
  testFiles = [
48893
49038
  ...directTestFiles,
48894
49039
  ...getTestFilesFromConvention(sourceFiles, workingDir)
@@ -48912,6 +49057,17 @@ var init_test_runner = __esm(() => {
48912
49057
  };
48913
49058
  return JSON.stringify(errorResult, null, 2);
48914
49059
  }
49060
+ if (sourceFiles.length > MAX_SAFE_SOURCE_FILES) {
49061
+ const errorResult = {
49062
+ success: false,
49063
+ framework,
49064
+ scope,
49065
+ error: `scope "graph" accepts at most ${MAX_SAFE_SOURCE_FILES} source file (got ${sourceFiles.length}). Treat this as SKIP without retry.`,
49066
+ message: `Too many source files for scope "graph" (${sourceFiles.length} provided, limit is ${MAX_SAFE_SOURCE_FILES}). Call test_runner once per source file, or use scope "convention" with direct test file paths.`,
49067
+ outcome: "scope_exceeded"
49068
+ };
49069
+ return JSON.stringify(errorResult, null, 2);
49070
+ }
48915
49071
  const graphTestFiles = await getTestFilesFromGraph(sourceFiles, workingDir);
48916
49072
  if (graphTestFiles.length > 0) {
48917
49073
  testFiles = graphTestFiles;
@@ -48939,6 +49095,17 @@ var init_test_runner = __esm(() => {
48939
49095
  };
48940
49096
  return JSON.stringify(errorResult, null, 2);
48941
49097
  }
49098
+ if (sourceFiles.length > MAX_SAFE_SOURCE_FILES) {
49099
+ const errorResult = {
49100
+ success: false,
49101
+ framework,
49102
+ scope,
49103
+ error: `scope "impact" accepts at most ${MAX_SAFE_SOURCE_FILES} source file (got ${sourceFiles.length}). Treat this as SKIP without retry.`,
49104
+ message: `Too many source files for scope "impact" (${sourceFiles.length} provided, limit is ${MAX_SAFE_SOURCE_FILES}). Call test_runner once per source file, or use scope "convention" with direct test file paths.`,
49105
+ outcome: "scope_exceeded"
49106
+ };
49107
+ return JSON.stringify(errorResult, null, 2);
49108
+ }
48942
49109
  try {
48943
49110
  const impactResult = await analyzeImpact(sourceFiles, workingDir);
48944
49111
  if (impactResult.impactedTests.length > 0) {
@@ -51158,6 +51325,25 @@ var init_context_budget_service = __esm(() => {
51158
51325
  });
51159
51326
 
51160
51327
  // src/services/status-service.ts
51328
+ import * as fsSync2 from "fs";
51329
+ import * as path46 from "path";
51330
+ function readSpecStalenessSnapshot(directory) {
51331
+ try {
51332
+ const p = path46.join(directory, ".swarm", "spec-staleness.json");
51333
+ if (!fsSync2.existsSync(p))
51334
+ return { stale: false };
51335
+ const raw = fsSync2.readFileSync(p, "utf-8");
51336
+ const parsed = JSON.parse(raw);
51337
+ return {
51338
+ stale: true,
51339
+ reason: typeof parsed?.reason === "string" ? parsed.reason : undefined,
51340
+ storedHash: typeof parsed?.specHash_plan === "string" ? parsed.specHash_plan : undefined,
51341
+ currentHash: typeof parsed?.specHash_current === "string" || parsed?.specHash_current === null ? parsed.specHash_current : undefined
51342
+ };
51343
+ } catch {
51344
+ return { stale: false };
51345
+ }
51346
+ }
51161
51347
  async function getStatusData(directory, agents) {
51162
51348
  const plan = await loadPlan(directory);
51163
51349
  let status;
@@ -51223,6 +51409,16 @@ async function getStatusData(directory, agents) {
51223
51409
  };
51224
51410
  }
51225
51411
  }
51412
+ const drift = readSpecStalenessSnapshot(directory);
51413
+ if (drift.stale) {
51414
+ status.specStale = true;
51415
+ status.specStaleReason = drift.reason;
51416
+ status.specStaleStoredHash = drift.storedHash;
51417
+ status.specStaleCurrentHash = drift.currentHash;
51418
+ } else if (plan && plan._specStale) {
51419
+ status.specStale = true;
51420
+ status.specStaleReason = plan._specStaleReason;
51421
+ }
51226
51422
  return enrichWithLeanTurbo(status, directory);
51227
51423
  }
51228
51424
  function enrichWithLeanTurbo(status, directory) {
@@ -51289,6 +51485,12 @@ function formatStatusMarkdown(status) {
51289
51485
  `**Tasks**: ${status.completedTasks}/${status.totalTasks} complete`,
51290
51486
  `**Agents**: ${status.agentCount} registered`
51291
51487
  ];
51488
+ if (status.specStale) {
51489
+ const reason = status.specStaleReason ?? "spec.md changed since plan saved";
51490
+ const stored = status.specStaleStoredHash ?? "unknown";
51491
+ const current = status.specStaleCurrentHash ?? "(spec.md missing)";
51492
+ lines.push("", `**Spec drift detected**: ${reason} (stored: ${stored}, current: ${current})`, "Run `/swarm clarify` to update the spec or `/swarm acknowledge-spec-drift` to dismiss.");
51493
+ }
51292
51494
  if (status.turboStrategy && status.turboStrategy !== "off") {
51293
51495
  lines.push("");
51294
51496
  if (status.turboStrategy === "lean") {
@@ -51338,6 +51540,18 @@ function formatStatusMarkdown(status) {
51338
51540
  async function handleStatusCommand(directory, agents) {
51339
51541
  const statusData = await getStatusData(directory, agents);
51340
51542
  if (!statusData.hasPlan) {
51543
+ if (statusData.specStale) {
51544
+ const reason = statusData.specStaleReason ?? "spec.md changed since plan saved";
51545
+ const stored = statusData.specStaleStoredHash ?? "unknown";
51546
+ const current = statusData.specStaleCurrentHash ?? "(spec.md missing)";
51547
+ return [
51548
+ "No active swarm plan found.",
51549
+ "",
51550
+ `**Spec drift detected**: ${reason} (stored: ${stored}, current: ${current})`,
51551
+ "Run `/swarm clarify` to update the spec or `/swarm acknowledge-spec-drift` to dismiss."
51552
+ ].join(`
51553
+ `);
51554
+ }
51341
51555
  return "No active swarm plan found.";
51342
51556
  }
51343
51557
  return formatStatusMarkdown(statusData);
@@ -51641,7 +51855,7 @@ var init_write_retro2 = __esm(() => {
51641
51855
 
51642
51856
  // src/commands/command-dispatch.ts
51643
51857
  import fs28 from "fs";
51644
- import path46 from "path";
51858
+ import path47 from "path";
51645
51859
  function normalizeSwarmCommandInput(command, argumentText) {
51646
51860
  if (command !== "swarm" && !command.startsWith("swarm-")) {
51647
51861
  return { isSwarmCommand: false, tokens: [] };
@@ -51677,9 +51891,9 @@ ${similar.map((cmd) => ` - /swarm ${cmd}`).join(`
51677
51891
  `);
51678
51892
  }
51679
51893
  function maybeMarkFirstRun(directory) {
51680
- const sentinelPath = path46.join(directory, ".swarm", ".first-run-complete");
51894
+ const sentinelPath = path47.join(directory, ".swarm", ".first-run-complete");
51681
51895
  try {
51682
- const swarmDir = path46.join(directory, ".swarm");
51896
+ const swarmDir = path47.join(directory, ".swarm");
51683
51897
  fs28.mkdirSync(swarmDir, { recursive: true });
51684
51898
  fs28.writeFileSync(sentinelPath, `first-run-complete: ${new Date().toISOString()}
51685
51899
  `, { flag: "wx" });
@@ -52347,24 +52561,24 @@ function validateAliases() {
52347
52561
  }
52348
52562
  aliasTargets.get(target).push(name);
52349
52563
  const visited = new Set;
52350
- const path47 = [];
52564
+ const path48 = [];
52351
52565
  let current = target;
52352
52566
  while (current) {
52353
52567
  const currentEntry = COMMAND_REGISTRY[current];
52354
52568
  if (!currentEntry)
52355
52569
  break;
52356
52570
  if (visited.has(current)) {
52357
- const cycleStart = path47.indexOf(current);
52571
+ const cycleStart = path48.indexOf(current);
52358
52572
  const fullChain = [
52359
52573
  name,
52360
- ...path47.slice(0, cycleStart > 0 ? cycleStart : path47.length),
52574
+ ...path48.slice(0, cycleStart > 0 ? cycleStart : path48.length),
52361
52575
  current
52362
52576
  ].join(" \u2192 ");
52363
52577
  errors5.push(`Circular alias detected: ${fullChain}`);
52364
52578
  break;
52365
52579
  }
52366
52580
  visited.add(current);
52367
- path47.push(current);
52581
+ path48.push(current);
52368
52582
  current = currentEntry.aliasOf || "";
52369
52583
  }
52370
52584
  }
@@ -52875,53 +53089,53 @@ init_cache_paths();
52875
53089
  init_constants();
52876
53090
  import * as fs29 from "fs";
52877
53091
  import * as os7 from "os";
52878
- import * as path47 from "path";
53092
+ import * as path48 from "path";
52879
53093
  var { version: version4 } = package_default;
52880
53094
  var CONFIG_DIR = getPluginConfigDir();
52881
- var OPENCODE_CONFIG_PATH = path47.join(CONFIG_DIR, "opencode.json");
52882
- var PLUGIN_CONFIG_PATH = path47.join(CONFIG_DIR, "opencode-swarm.json");
52883
- var PROMPTS_DIR = path47.join(CONFIG_DIR, "opencode-swarm");
53095
+ var OPENCODE_CONFIG_PATH = path48.join(CONFIG_DIR, "opencode.json");
53096
+ var PLUGIN_CONFIG_PATH = path48.join(CONFIG_DIR, "opencode-swarm.json");
53097
+ var PROMPTS_DIR = path48.join(CONFIG_DIR, "opencode-swarm");
52884
53098
  var OPENCODE_PLUGIN_CACHE_PATHS = getPluginCachePaths();
52885
53099
  var OPENCODE_PLUGIN_LOCK_FILE_PATHS = getPluginLockFilePaths();
52886
53100
  function isSafeCachePath(p) {
52887
- const resolved = path47.resolve(p);
52888
- const home = path47.resolve(os7.homedir());
53101
+ const resolved = path48.resolve(p);
53102
+ const home = path48.resolve(os7.homedir());
52889
53103
  if (resolved === "/" || resolved === home || resolved.length <= home.length) {
52890
53104
  return false;
52891
53105
  }
52892
- const segments = resolved.split(path47.sep).filter((s) => s.length > 0);
53106
+ const segments = resolved.split(path48.sep).filter((s) => s.length > 0);
52893
53107
  if (segments.length < 4) {
52894
53108
  return false;
52895
53109
  }
52896
- const leaf = path47.basename(resolved);
53110
+ const leaf = path48.basename(resolved);
52897
53111
  if (leaf !== "opencode-swarm@latest" && leaf !== "opencode-swarm") {
52898
53112
  return false;
52899
53113
  }
52900
- const parent = path47.basename(path47.dirname(resolved));
53114
+ const parent = path48.basename(path48.dirname(resolved));
52901
53115
  if (parent !== "packages" && parent !== "node_modules") {
52902
53116
  return false;
52903
53117
  }
52904
- const grandparent = path47.basename(path47.dirname(path47.dirname(resolved)));
53118
+ const grandparent = path48.basename(path48.dirname(path48.dirname(resolved)));
52905
53119
  if (grandparent !== "opencode") {
52906
53120
  return false;
52907
53121
  }
52908
53122
  return true;
52909
53123
  }
52910
53124
  function isSafeLockFilePath(p) {
52911
- const resolved = path47.resolve(p);
52912
- const home = path47.resolve(os7.homedir());
53125
+ const resolved = path48.resolve(p);
53126
+ const home = path48.resolve(os7.homedir());
52913
53127
  if (resolved === "/" || resolved === home || resolved.length <= home.length) {
52914
53128
  return false;
52915
53129
  }
52916
- const segments = resolved.split(path47.sep).filter((s) => s.length > 0);
53130
+ const segments = resolved.split(path48.sep).filter((s) => s.length > 0);
52917
53131
  if (segments.length < 4) {
52918
53132
  return false;
52919
53133
  }
52920
- const leaf = path47.basename(resolved);
53134
+ const leaf = path48.basename(resolved);
52921
53135
  if (leaf !== "bun.lock" && leaf !== "bun.lockb" && leaf !== "package-lock.json") {
52922
53136
  return false;
52923
53137
  }
52924
- const parent = path47.basename(path47.dirname(resolved));
53138
+ const parent = path48.basename(path48.dirname(resolved));
52925
53139
  if (parent !== "opencode") {
52926
53140
  return false;
52927
53141
  }
@@ -52947,8 +53161,8 @@ function saveJson(filepath, data) {
52947
53161
  }
52948
53162
  function writeProjectConfigIfMissing(cwd) {
52949
53163
  try {
52950
- const opencodeDir = path47.join(cwd, ".opencode");
52951
- const projectConfigPath = path47.join(opencodeDir, "opencode-swarm.json");
53164
+ const opencodeDir = path48.join(cwd, ".opencode");
53165
+ const projectConfigPath = path48.join(opencodeDir, "opencode-swarm.json");
52952
53166
  if (fs29.existsSync(projectConfigPath)) {
52953
53167
  return;
52954
53168
  }
@@ -52965,7 +53179,7 @@ async function install() {
52965
53179
  `);
52966
53180
  ensureDir(CONFIG_DIR);
52967
53181
  ensureDir(PROMPTS_DIR);
52968
- const LEGACY_CONFIG_PATH = path47.join(CONFIG_DIR, "config.json");
53182
+ const LEGACY_CONFIG_PATH = path48.join(CONFIG_DIR, "config.json");
52969
53183
  let opencodeConfig = loadJson(OPENCODE_CONFIG_PATH);
52970
53184
  if (!opencodeConfig) {
52971
53185
  const legacyConfig = loadJson(LEGACY_CONFIG_PATH);
@@ -172,10 +172,18 @@ export type Plan = z.infer<typeof PlanSchema>;
172
172
  /**
173
173
  * Runtime plan with spec staleness tracking.
174
174
  * Extends Plan with runtime-only fields that are not persisted.
175
+ *
176
+ * `_midLoadRemovals` is attached by loadPlan-recovery paths that auto-
177
+ * acknowledged task removals (issue #853) so the system-enhancer Layer A
178
+ * can disclose the count to the model without re-reading the ledger.
175
179
  */
176
180
  export type RuntimePlan = Plan & {
177
181
  _specStale?: boolean;
178
182
  _specStaleReason?: string;
183
+ _midLoadRemovals?: {
184
+ count: number;
185
+ source: string;
186
+ };
179
187
  };
180
188
  /**
181
189
  * Find the first phase that is in progress.
@@ -9,6 +9,27 @@
9
9
  import * as path from 'node:path';
10
10
  import { type AuthorityConfig, type GuardrailsConfig } from '../config/schema';
11
11
  import { type FileZone } from '../context/zone-classifier';
12
+ /**
13
+ * Issue #853 Layer B: tools that are structurally blocked while
14
+ * `.swarm/spec-staleness.json` exists. Every blocked tool mutates plan
15
+ * state (save_plan, update_task_status, phase_complete) or proceeds with
16
+ * lean-turbo execution (lean_turbo_run_phase, lean_turbo_acquire_locks).
17
+ * The architect must run /swarm clarify or /swarm acknowledge-spec-drift
18
+ * before any of these will succeed.
19
+ *
20
+ * Read tools (get_approved_plan, lint_spec, set_qa_gates, convene_*,
21
+ * lean_turbo_plan_lanes, lean_turbo_runner_status, lean_turbo_review) are
22
+ * intentionally NOT blocked — drift surfacing should not block exploration.
23
+ */
24
+ export declare const SPEC_DRIFT_BLOCKED_TOOLS: Set<string>;
25
+ /**
26
+ * Throw SPEC_DRIFT_BLOCK if the tool is on the block-list and the
27
+ * spec-staleness marker file exists. Layer B is structural (not a
28
+ * retryable error) — deterministic disk read every call, no cache, so
29
+ * /swarm acknowledge-spec-drift (which removes the marker) is reflected
30
+ * immediately on the next tool call.
31
+ */
32
+ export declare function enforceSpecDriftGate(directory: string | undefined, toolName: string): void;
12
33
  /**
13
34
  * Retrieves stored input args for a given callID.
14
35
  * Used by other hooks (e.g., delegation-gate) to access tool input args.
@@ -6,6 +6,25 @@
6
6
  * Reads plan.md and injects phase context into the system prompt.
7
7
  */
8
8
  import type { PluginConfig } from '../config';
9
+ /**
10
+ * Build the [spec-drift] advisory injected into the model's system prompt
11
+ * after every loadPlan whenever spec staleness is detected (issue #853
12
+ * Layer A). The text is appended to `output.system` and survives the
13
+ * single-system-message collapse at `experimental.chat.system.transform`.
14
+ *
15
+ * The "Do NOT proceed" line enumerates every tool in SPEC_DRIFT_BLOCKED_TOOLS
16
+ * so the architect knows exactly which calls will return SPEC_DRIFT_BLOCK
17
+ * from Layer B.
18
+ */
19
+ export declare function buildSpecDriftAdvisory(args: {
20
+ reason: string;
21
+ currentHash: string | null;
22
+ storedHash: string;
23
+ midLoadRemovals?: {
24
+ count: number;
25
+ source: string;
26
+ };
27
+ }): string;
9
28
  /**
10
29
  * Build a retrospective injection string for the architect system message.
11
30
  * Tier 1: direct phase-scoped lookup for same-plan previous phase.