opencode-swarm 7.19.0 → 7.19.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/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.1",
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;
@@ -51158,6 +51292,25 @@ var init_context_budget_service = __esm(() => {
51158
51292
  });
51159
51293
 
51160
51294
  // src/services/status-service.ts
51295
+ import * as fsSync2 from "fs";
51296
+ import * as path46 from "path";
51297
+ function readSpecStalenessSnapshot(directory) {
51298
+ try {
51299
+ const p = path46.join(directory, ".swarm", "spec-staleness.json");
51300
+ if (!fsSync2.existsSync(p))
51301
+ return { stale: false };
51302
+ const raw = fsSync2.readFileSync(p, "utf-8");
51303
+ const parsed = JSON.parse(raw);
51304
+ return {
51305
+ stale: true,
51306
+ reason: typeof parsed?.reason === "string" ? parsed.reason : undefined,
51307
+ storedHash: typeof parsed?.specHash_plan === "string" ? parsed.specHash_plan : undefined,
51308
+ currentHash: typeof parsed?.specHash_current === "string" || parsed?.specHash_current === null ? parsed.specHash_current : undefined
51309
+ };
51310
+ } catch {
51311
+ return { stale: false };
51312
+ }
51313
+ }
51161
51314
  async function getStatusData(directory, agents) {
51162
51315
  const plan = await loadPlan(directory);
51163
51316
  let status;
@@ -51223,6 +51376,16 @@ async function getStatusData(directory, agents) {
51223
51376
  };
51224
51377
  }
51225
51378
  }
51379
+ const drift = readSpecStalenessSnapshot(directory);
51380
+ if (drift.stale) {
51381
+ status.specStale = true;
51382
+ status.specStaleReason = drift.reason;
51383
+ status.specStaleStoredHash = drift.storedHash;
51384
+ status.specStaleCurrentHash = drift.currentHash;
51385
+ } else if (plan && plan._specStale) {
51386
+ status.specStale = true;
51387
+ status.specStaleReason = plan._specStaleReason;
51388
+ }
51226
51389
  return enrichWithLeanTurbo(status, directory);
51227
51390
  }
51228
51391
  function enrichWithLeanTurbo(status, directory) {
@@ -51289,6 +51452,12 @@ function formatStatusMarkdown(status) {
51289
51452
  `**Tasks**: ${status.completedTasks}/${status.totalTasks} complete`,
51290
51453
  `**Agents**: ${status.agentCount} registered`
51291
51454
  ];
51455
+ if (status.specStale) {
51456
+ const reason = status.specStaleReason ?? "spec.md changed since plan saved";
51457
+ const stored = status.specStaleStoredHash ?? "unknown";
51458
+ const current = status.specStaleCurrentHash ?? "(spec.md missing)";
51459
+ lines.push("", `**Spec drift detected**: ${reason} (stored: ${stored}, current: ${current})`, "Run `/swarm clarify` to update the spec or `/swarm acknowledge-spec-drift` to dismiss.");
51460
+ }
51292
51461
  if (status.turboStrategy && status.turboStrategy !== "off") {
51293
51462
  lines.push("");
51294
51463
  if (status.turboStrategy === "lean") {
@@ -51338,6 +51507,18 @@ function formatStatusMarkdown(status) {
51338
51507
  async function handleStatusCommand(directory, agents) {
51339
51508
  const statusData = await getStatusData(directory, agents);
51340
51509
  if (!statusData.hasPlan) {
51510
+ if (statusData.specStale) {
51511
+ const reason = statusData.specStaleReason ?? "spec.md changed since plan saved";
51512
+ const stored = statusData.specStaleStoredHash ?? "unknown";
51513
+ const current = statusData.specStaleCurrentHash ?? "(spec.md missing)";
51514
+ return [
51515
+ "No active swarm plan found.",
51516
+ "",
51517
+ `**Spec drift detected**: ${reason} (stored: ${stored}, current: ${current})`,
51518
+ "Run `/swarm clarify` to update the spec or `/swarm acknowledge-spec-drift` to dismiss."
51519
+ ].join(`
51520
+ `);
51521
+ }
51341
51522
  return "No active swarm plan found.";
51342
51523
  }
51343
51524
  return formatStatusMarkdown(statusData);
@@ -51641,7 +51822,7 @@ var init_write_retro2 = __esm(() => {
51641
51822
 
51642
51823
  // src/commands/command-dispatch.ts
51643
51824
  import fs28 from "fs";
51644
- import path46 from "path";
51825
+ import path47 from "path";
51645
51826
  function normalizeSwarmCommandInput(command, argumentText) {
51646
51827
  if (command !== "swarm" && !command.startsWith("swarm-")) {
51647
51828
  return { isSwarmCommand: false, tokens: [] };
@@ -51677,9 +51858,9 @@ ${similar.map((cmd) => ` - /swarm ${cmd}`).join(`
51677
51858
  `);
51678
51859
  }
51679
51860
  function maybeMarkFirstRun(directory) {
51680
- const sentinelPath = path46.join(directory, ".swarm", ".first-run-complete");
51861
+ const sentinelPath = path47.join(directory, ".swarm", ".first-run-complete");
51681
51862
  try {
51682
- const swarmDir = path46.join(directory, ".swarm");
51863
+ const swarmDir = path47.join(directory, ".swarm");
51683
51864
  fs28.mkdirSync(swarmDir, { recursive: true });
51684
51865
  fs28.writeFileSync(sentinelPath, `first-run-complete: ${new Date().toISOString()}
51685
51866
  `, { flag: "wx" });
@@ -52347,24 +52528,24 @@ function validateAliases() {
52347
52528
  }
52348
52529
  aliasTargets.get(target).push(name);
52349
52530
  const visited = new Set;
52350
- const path47 = [];
52531
+ const path48 = [];
52351
52532
  let current = target;
52352
52533
  while (current) {
52353
52534
  const currentEntry = COMMAND_REGISTRY[current];
52354
52535
  if (!currentEntry)
52355
52536
  break;
52356
52537
  if (visited.has(current)) {
52357
- const cycleStart = path47.indexOf(current);
52538
+ const cycleStart = path48.indexOf(current);
52358
52539
  const fullChain = [
52359
52540
  name,
52360
- ...path47.slice(0, cycleStart > 0 ? cycleStart : path47.length),
52541
+ ...path48.slice(0, cycleStart > 0 ? cycleStart : path48.length),
52361
52542
  current
52362
52543
  ].join(" \u2192 ");
52363
52544
  errors5.push(`Circular alias detected: ${fullChain}`);
52364
52545
  break;
52365
52546
  }
52366
52547
  visited.add(current);
52367
- path47.push(current);
52548
+ path48.push(current);
52368
52549
  current = currentEntry.aliasOf || "";
52369
52550
  }
52370
52551
  }
@@ -52875,53 +53056,53 @@ init_cache_paths();
52875
53056
  init_constants();
52876
53057
  import * as fs29 from "fs";
52877
53058
  import * as os7 from "os";
52878
- import * as path47 from "path";
53059
+ import * as path48 from "path";
52879
53060
  var { version: version4 } = package_default;
52880
53061
  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");
53062
+ var OPENCODE_CONFIG_PATH = path48.join(CONFIG_DIR, "opencode.json");
53063
+ var PLUGIN_CONFIG_PATH = path48.join(CONFIG_DIR, "opencode-swarm.json");
53064
+ var PROMPTS_DIR = path48.join(CONFIG_DIR, "opencode-swarm");
52884
53065
  var OPENCODE_PLUGIN_CACHE_PATHS = getPluginCachePaths();
52885
53066
  var OPENCODE_PLUGIN_LOCK_FILE_PATHS = getPluginLockFilePaths();
52886
53067
  function isSafeCachePath(p) {
52887
- const resolved = path47.resolve(p);
52888
- const home = path47.resolve(os7.homedir());
53068
+ const resolved = path48.resolve(p);
53069
+ const home = path48.resolve(os7.homedir());
52889
53070
  if (resolved === "/" || resolved === home || resolved.length <= home.length) {
52890
53071
  return false;
52891
53072
  }
52892
- const segments = resolved.split(path47.sep).filter((s) => s.length > 0);
53073
+ const segments = resolved.split(path48.sep).filter((s) => s.length > 0);
52893
53074
  if (segments.length < 4) {
52894
53075
  return false;
52895
53076
  }
52896
- const leaf = path47.basename(resolved);
53077
+ const leaf = path48.basename(resolved);
52897
53078
  if (leaf !== "opencode-swarm@latest" && leaf !== "opencode-swarm") {
52898
53079
  return false;
52899
53080
  }
52900
- const parent = path47.basename(path47.dirname(resolved));
53081
+ const parent = path48.basename(path48.dirname(resolved));
52901
53082
  if (parent !== "packages" && parent !== "node_modules") {
52902
53083
  return false;
52903
53084
  }
52904
- const grandparent = path47.basename(path47.dirname(path47.dirname(resolved)));
53085
+ const grandparent = path48.basename(path48.dirname(path48.dirname(resolved)));
52905
53086
  if (grandparent !== "opencode") {
52906
53087
  return false;
52907
53088
  }
52908
53089
  return true;
52909
53090
  }
52910
53091
  function isSafeLockFilePath(p) {
52911
- const resolved = path47.resolve(p);
52912
- const home = path47.resolve(os7.homedir());
53092
+ const resolved = path48.resolve(p);
53093
+ const home = path48.resolve(os7.homedir());
52913
53094
  if (resolved === "/" || resolved === home || resolved.length <= home.length) {
52914
53095
  return false;
52915
53096
  }
52916
- const segments = resolved.split(path47.sep).filter((s) => s.length > 0);
53097
+ const segments = resolved.split(path48.sep).filter((s) => s.length > 0);
52917
53098
  if (segments.length < 4) {
52918
53099
  return false;
52919
53100
  }
52920
- const leaf = path47.basename(resolved);
53101
+ const leaf = path48.basename(resolved);
52921
53102
  if (leaf !== "bun.lock" && leaf !== "bun.lockb" && leaf !== "package-lock.json") {
52922
53103
  return false;
52923
53104
  }
52924
- const parent = path47.basename(path47.dirname(resolved));
53105
+ const parent = path48.basename(path48.dirname(resolved));
52925
53106
  if (parent !== "opencode") {
52926
53107
  return false;
52927
53108
  }
@@ -52947,8 +53128,8 @@ function saveJson(filepath, data) {
52947
53128
  }
52948
53129
  function writeProjectConfigIfMissing(cwd) {
52949
53130
  try {
52950
- const opencodeDir = path47.join(cwd, ".opencode");
52951
- const projectConfigPath = path47.join(opencodeDir, "opencode-swarm.json");
53131
+ const opencodeDir = path48.join(cwd, ".opencode");
53132
+ const projectConfigPath = path48.join(opencodeDir, "opencode-swarm.json");
52952
53133
  if (fs29.existsSync(projectConfigPath)) {
52953
53134
  return;
52954
53135
  }
@@ -52965,7 +53146,7 @@ async function install() {
52965
53146
  `);
52966
53147
  ensureDir(CONFIG_DIR);
52967
53148
  ensureDir(PROMPTS_DIR);
52968
- const LEGACY_CONFIG_PATH = path47.join(CONFIG_DIR, "config.json");
53149
+ const LEGACY_CONFIG_PATH = path48.join(CONFIG_DIR, "config.json");
52969
53150
  let opencodeConfig = loadJson(OPENCODE_CONFIG_PATH);
52970
53151
  if (!opencodeConfig) {
52971
53152
  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.