opencode-swarm 5.1.8 β†’ 6.0.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
@@ -1,9 +1,9 @@
1
1
  <p align="center">
2
- <img src="https://img.shields.io/badge/version-5.1.5-blue" alt="Version">
2
+ <img src="https://img.shields.io/badge/version-6.0.0-blue" alt="Version">
3
3
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
4
4
  <img src="https://img.shields.io/badge/opencode-plugin-purple" alt="OpenCode Plugin">
5
5
  <img src="https://img.shields.io/badge/agents-7-orange" alt="Agents">
6
- <img src="https://img.shields.io/badge/tests-1034-brightgreen" alt="Tests">
6
+ <img src="https://img.shields.io/badge/tests-1188-brightgreen" alt="Tests">
7
7
  </p>
8
8
 
9
9
  <h1 align="center">🐝 OpenCode Swarm</h1>
@@ -138,15 +138,24 @@ OpenCode Swarm:
138
138
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
139
139
  β”‚ PHASE 5: Execute (per task) β”‚
140
140
  β”‚ β”‚
141
- β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
142
- β”‚ β”‚ @coder β”‚ β†’ β”‚ @reviewer β”‚ β†’ β”‚ @test β”‚ β”‚
143
- β”‚ β”‚ 1 task β”‚ β”‚ check all β”‚ β”‚ write + run β”‚ β”‚
144
- β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
145
- β”‚ β”‚ β”‚ β”‚ β”‚
146
- β”‚ β”‚ If REJECTED: retry If FAIL: fix + retest β”‚
147
- β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
141
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
142
+ β”‚ β”‚ @coder β”‚ β†’ β”‚ diff β”‚ β†’ β”‚ @reviewer β”‚ β†’ β”‚ @test β”‚ β”‚
143
+ β”‚ β”‚ 1 task β”‚ β”‚ tool β”‚ β”‚ check all β”‚ β”‚ write + run β”‚ β”‚
144
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
145
+ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚
146
+ β”‚ β”‚ Contract β”‚ If REJECTED: If FAIL: fix β”‚
147
+ β”‚ β”‚ changes? β”‚ retry from coder + retest β”‚
148
+ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚
149
+ β”‚ β”‚ β–Ό β”‚ β–Ό β”‚
150
+ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
151
+ β”‚ β”‚ β”‚@explorerβ”‚ β”‚ β”‚ @reviewer β”‚ β†’ β”‚ @test β”‚ β”‚
152
+ β”‚ β”‚ β”‚ impact β”‚ β”‚ β”‚ security-onlyβ”‚ β”‚ adversarial β”‚ β”‚
153
+ β”‚ β”‚ β”‚analysis β”‚ β”‚ β”‚ (if match) β”‚ β”‚ (attacks) β”‚ β”‚
154
+ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
155
+ β”‚ β”‚ β”‚ β”‚
156
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
148
157
  β”‚ β”‚
149
- β”‚ Update plan.md: [x] Task complete (only if PASS) β”‚
158
+ β”‚ Update plan.md: [x] Task complete (only after ALL gates pass) β”‚
150
159
  β”‚ Next task... β”‚
151
160
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
152
161
  β”‚
@@ -334,6 +343,19 @@ bunx opencode-swarm uninstall --clean
334
343
 
335
344
  ## What's New
336
345
 
346
+ ### v6.0.0 β€” Core QA & Security Gates
347
+ - **Dual-pass security reviewer** β€” After the general reviewer APPROVES, the architect automatically triggers a second security-only review pass when the changed file matches security-sensitive paths (`auth`, `crypto`, `session`, `token`, `middleware`, `api`, `security`) or the coder's output contains security keywords. Configurable via `review_passes` config.
348
+ - **Adversarial testing** β€” After verification tests PASS, the test engineer is re-delegated with adversarial-only framing: attack vectors, boundary violations, and injection attempts. Pure prompt engineering, no new infrastructure.
349
+ - **Integration impact analysis** β€” After the coder completes, the `diff` tool detects contract changes (exported functions, interfaces, types). If found, the explorer runs impact analysis across dependents before review begins.
350
+ - **`diff` tool** β€” New agent-accessible tool providing structured git diff with numstat parsing, contract change detection, configurable base ref (`HEAD`/staged/unstaged), path filtering, and 500-line truncation.
351
+ - **87 new tests** β€” 1188 total tests across 53+ files (up from 1101 in v5.2.0).
352
+
353
+ ### v5.2.0 β€” Per-Invocation Guardrails
354
+ - **Per-invocation budget isolation** β€” Guardrail limits (tool calls, duration, errors) now reset with each agent delegation. Second invocation of the same agent gets a fresh budget, preventing false circuit breaker trips in long-running projects.
355
+ - **Architect protocol enforcement** β€” New mandatory QA gate rules: every coder task must go through reviewer approval + test_engineer verification before the next coder task. Protocol violations detected at runtime with warning injection.
356
+ - **Invocation window observability** β€” Circuit breaker logs now include `invocationId` and `windowKey` for precise debugging of which specific agent invocation hit limits.
357
+ - **67 new tests** β€” 1101 total tests across 48 files (up from 1034 in v5.1.x).
358
+
337
359
  ### v5.0.0 β€” Verifiable Execution
338
360
  - **Canonical plan schema** β€” Machine-readable `plan.json` with Zod-validated `PlanSchema`/`TaskSchema`/`PhaseSchema`. Automatic migration from legacy `plan.md` format. Structured status tracking (`pending`, `in_progress`, `completed`, `blocked`).
339
361
  - **Evidence bundles** β€” Per-task execution evidence persisted to `.swarm/evidence/`. Five evidence types: `review`, `test`, `diff`, `approval`, `note`. Sanitized task IDs, atomic writes, configurable size limits. `/swarm evidence` to view, `/swarm archive` to manage retention.
@@ -403,7 +425,7 @@ All features are opt-in via configuration. See [Installation Guide](docs/install
403
425
  ### βœ… Quality Assurance
404
426
  | Agent | Role |
405
427
  |-------|------|
406
- | `reviewer` | Combined correctness + security review. The architect specifies CHECK dimensions (security, correctness, edge-cases, performance, etc.) per call. |
428
+ | `reviewer` | Dual-pass review: correctness review first, then automatic security-only pass for security-sensitive files. The architect specifies CHECK dimensions per call. OWASP Top 10 categories built in. |
407
429
  | `critic` | Plan review gate. Reviews the architect's plan BEFORE implementation β€” checks completeness, feasibility, scope, dependencies, and flags AI-slop. |
408
430
 
409
431
  ---
@@ -510,8 +532,52 @@ Override limits for specific agents that need more (or less) room:
510
532
 
511
533
  Profiles merge with base config β€” only specified fields are overridden.
512
534
 
535
+ ### Review Passes
536
+
537
+ Control the dual-pass security review behavior:
538
+
539
+ ```jsonc
540
+ {
541
+ "review_passes": {
542
+ "always_security_review": false, // default: false (only on security-sensitive files)
543
+ "security_globs": [ // default patterns:
544
+ "**/*auth*", "**/*crypto*",
545
+ "**/*session*", "**/*token*",
546
+ "**/*middleware*", "**/*api*",
547
+ "**/*security*"
548
+ ]
549
+ }
550
+ }
551
+ ```
552
+
553
+ Set `always_security_review: true` to run the security pass on every task, regardless of file path.
554
+
555
+ ### Integration Analysis
556
+
557
+ Control whether contract change detection triggers impact analysis:
558
+
559
+ ```jsonc
560
+ {
561
+ "integration_analysis": {
562
+ "enabled": true // default: true
563
+ }
564
+ }
565
+ ```
566
+
513
567
  > **Architect is exempt/unlimited by default:** The architect agent has no guardrail limits by default. To override, add a `profiles.architect` entry in your guardrails config.
514
568
 
569
+ ### Per-Invocation Budgets
570
+
571
+ Guardrail limits are enforced **per-invocation**, not per-session. Each time the architect delegates to an agent, that agent gets a fresh budget of tool calls, duration, and error tolerance.
572
+
573
+ **Example**: If `max_tool_calls: 200`, then:
574
+ - Architect β†’ Coder (task 1) β†’ 200 calls available
575
+ - Coder finishes β†’ Architect β†’ Coder (task 2) β†’ 200 calls available again
576
+
577
+ This prevents long-running projects from accumulating session-wide counters that incorrectly trip the circuit breaker on later tasks.
578
+
579
+ > **Architect is unlimited**: The architect never creates invocation windows and has no guardrail limits by default.
580
+
515
581
  ### Disable Guardrails
516
582
 
517
583
  ```json
@@ -531,7 +597,7 @@ Profiles merge with base config β€” only specified fields are overridden.
531
597
  | Execution | Serial (predictable) | Parallel (chaotic) | Parallel | Configurable |
532
598
  | Planning | Phased with acceptance criteria | Ad-hoc | Role-based | Graph-based |
533
599
  | Memory | Persistent `.swarm/` files | Session only | Session only | Checkpoints |
534
- | QA | Per-task (unified review) | Optional | Optional | Manual |
600
+ | QA | Dual-pass per-task (review + security + adversarial) | Optional | Optional | Manual |
535
601
  | Model mixing | Per-agent configuration | Limited | Limited | Manual |
536
602
  | Resume projects | βœ… Native | ❌ | ❌ | Partial |
537
603
  | SME domains | Open-domain (any) | Generic | Generic | Generic |
@@ -543,7 +609,7 @@ Profiles merge with base config β€” only specified fields are overridden.
543
609
 
544
610
  1. **Plan before code** - Documented phases with acceptance criteria
545
611
  2. **One task at a time** - Focused work, quality output
546
- 3. **Review everything immediately** - Correctness + security review per task, not per project
612
+ 3. **Review everything immediately** - Dual-pass review (correctness + security) with adversarial testing per task
547
613
  4. **Cache SME knowledge** - Don't re-ask answered questions
548
614
  5. **Persistent memory** - `.swarm/` files survive sessions
549
615
  6. **Serial execution** - Predictable, debuggable, no race conditions
@@ -564,7 +630,7 @@ bun test
564
630
  bun test tests/unit/config/schema.test.ts
565
631
  ```
566
632
 
567
- 1034 tests across 45 files covering config, tools, agents, hooks, commands, state, guardrails, evidence, plan schemas, and circuit breaker race conditions. Uses Bun's built-in test runner β€” zero additional test dependencies.
633
+ 1188 tests across 53+ files covering config, tools, agents, hooks, commands, state, guardrails, evidence, plan schemas, circuit breaker race conditions, invocation windows, multi-invocation isolation, security categories, review/integration schemas, and diff tool. Uses Bun's built-in test runner β€” zero additional test dependencies.
568
634
 
569
635
  ## Troubleshooting
570
636
 
@@ -20,6 +20,6 @@ export { createArchitectAgent } from './architect';
20
20
  export { createCoderAgent } from './coder';
21
21
  export { createCriticAgent } from './critic';
22
22
  export { createExplorerAgent } from './explorer';
23
- export { createReviewerAgent } from './reviewer';
23
+ export { createReviewerAgent, SECURITY_CATEGORIES, type SecurityCategory, } from './reviewer';
24
24
  export { createSMEAgent } from './sme';
25
25
  export { createTestEngineerAgent } from './test-engineer';
@@ -1,2 +1,5 @@
1
1
  import type { AgentDefinition } from './architect';
2
+ /** OWASP Top 10 2021 categories for security-focused review passes */
3
+ export declare const SECURITY_CATEGORIES: readonly ["broken-access-control", "cryptographic-failures", "injection", "insecure-design", "security-misconfiguration", "vulnerable-components", "auth-failures", "data-integrity-failures", "logging-monitoring-failures", "ssrf"];
4
+ export type SecurityCategory = (typeof SECURITY_CATEGORIES)[number];
2
5
  export declare function createReviewerAgent(model: string, customPrompt?: string, customAppendPrompt?: string): AgentDefinition;
@@ -13,6 +13,8 @@ export declare function deepMerge<T extends Record<string, unknown>>(base?: T, o
13
13
  * 2. Project config: <directory>/.opencode/opencode-swarm.json
14
14
  *
15
15
  * Project config takes precedence. Nested objects are deep-merged.
16
+ * IMPORTANT: Raw configs are merged BEFORE Zod parsing so that
17
+ * Zod defaults don't override explicit user values.
16
18
  */
17
19
  export declare function loadPluginConfig(directory: string): PluginConfig;
18
20
  /**
@@ -128,6 +128,15 @@ export declare const SummaryConfigSchema: z.ZodObject<{
128
128
  retention_days: z.ZodDefault<z.ZodNumber>;
129
129
  }, z.core.$strip>;
130
130
  export type SummaryConfig = z.infer<typeof SummaryConfigSchema>;
131
+ export declare const ReviewPassesConfigSchema: z.ZodObject<{
132
+ always_security_review: z.ZodDefault<z.ZodBoolean>;
133
+ security_globs: z.ZodDefault<z.ZodArray<z.ZodString>>;
134
+ }, z.core.$strip>;
135
+ export type ReviewPassesConfig = z.infer<typeof ReviewPassesConfigSchema>;
136
+ export declare const IntegrationAnalysisConfigSchema: z.ZodObject<{
137
+ enabled: z.ZodDefault<z.ZodBoolean>;
138
+ }, z.core.$strip>;
139
+ export type IntegrationAnalysisConfig = z.infer<typeof IntegrationAnalysisConfigSchema>;
131
140
  export declare const GuardrailsProfileSchema: z.ZodObject<{
132
141
  max_tool_calls: z.ZodOptional<z.ZodNumber>;
133
142
  max_duration_minutes: z.ZodOptional<z.ZodNumber>;
@@ -282,6 +291,14 @@ export declare const PluginConfigSchema: z.ZodObject<{
282
291
  max_stored_bytes: z.ZodDefault<z.ZodNumber>;
283
292
  retention_days: z.ZodDefault<z.ZodNumber>;
284
293
  }, z.core.$strip>>;
294
+ review_passes: z.ZodOptional<z.ZodObject<{
295
+ always_security_review: z.ZodDefault<z.ZodBoolean>;
296
+ security_globs: z.ZodDefault<z.ZodArray<z.ZodString>>;
297
+ }, z.core.$strip>>;
298
+ integration_analysis: z.ZodOptional<z.ZodObject<{
299
+ enabled: z.ZodDefault<z.ZodBoolean>;
300
+ }, z.core.$strip>>;
301
+ _loadedFromFile: z.ZodDefault<z.ZodBoolean>;
285
302
  }, z.core.$strip>;
286
303
  export type PluginConfig = z.infer<typeof PluginConfigSchema>;
287
304
  export type { AgentName, PipelineAgentName, QAAgentName, } from './constants';
@@ -8,7 +8,7 @@ import type { PluginConfig } from '../config/schema';
8
8
  /**
9
9
  * Creates the chat.message hook for delegation tracking.
10
10
  */
11
- export declare function createDelegationTrackerHook(config: PluginConfig): (input: {
11
+ export declare function createDelegationTrackerHook(config: PluginConfig, guardrailsEnabled?: boolean): (input: {
12
12
  sessionID: string;
13
13
  agent?: string;
14
14
  }, output: Record<string, unknown>) => Promise<void>;
package/dist/index.js CHANGED
@@ -13630,6 +13630,21 @@ var SummaryConfigSchema = exports_external.object({
13630
13630
  max_stored_bytes: exports_external.number().min(10240).max(104857600).default(10485760),
13631
13631
  retention_days: exports_external.number().min(1).max(365).default(7)
13632
13632
  });
13633
+ var ReviewPassesConfigSchema = exports_external.object({
13634
+ always_security_review: exports_external.boolean().default(false),
13635
+ security_globs: exports_external.array(exports_external.string()).default([
13636
+ "**/auth/**",
13637
+ "**/api/**",
13638
+ "**/crypto/**",
13639
+ "**/security/**",
13640
+ "**/middleware/**",
13641
+ "**/session/**",
13642
+ "**/token/**"
13643
+ ])
13644
+ });
13645
+ var IntegrationAnalysisConfigSchema = exports_external.object({
13646
+ enabled: exports_external.boolean().default(true)
13647
+ });
13633
13648
  var GuardrailsProfileSchema = exports_external.object({
13634
13649
  max_tool_calls: exports_external.number().min(0).max(1000).optional(),
13635
13650
  max_duration_minutes: exports_external.number().min(0).max(480).optional(),
@@ -13729,7 +13744,10 @@ var PluginConfigSchema = exports_external.object({
13729
13744
  context_budget: ContextBudgetConfigSchema.optional(),
13730
13745
  guardrails: GuardrailsConfigSchema.optional(),
13731
13746
  evidence: EvidenceConfigSchema.optional(),
13732
- summaries: SummaryConfigSchema.optional()
13747
+ summaries: SummaryConfigSchema.optional(),
13748
+ review_passes: ReviewPassesConfigSchema.optional(),
13749
+ integration_analysis: IntegrationAnalysisConfigSchema.optional(),
13750
+ _loadedFromFile: exports_external.boolean().default(false)
13733
13751
  });
13734
13752
 
13735
13753
  // src/config/loader.ts
@@ -13739,25 +13757,26 @@ var MAX_CONFIG_FILE_BYTES = 102400;
13739
13757
  function getUserConfigDir() {
13740
13758
  return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
13741
13759
  }
13742
- function loadConfigFromPath(configPath) {
13760
+ function loadRawConfigFromPath(configPath) {
13743
13761
  try {
13744
13762
  const stats = fs.statSync(configPath);
13745
13763
  if (stats.size > MAX_CONFIG_FILE_BYTES) {
13746
13764
  console.warn(`[opencode-swarm] Config file too large (max 100 KB): ${configPath}`);
13765
+ console.warn("[opencode-swarm] \u26A0\uFE0F Guardrails will be DISABLED as a safety precaution. Fix the config file to restore normal operation.");
13747
13766
  return null;
13748
13767
  }
13749
13768
  const content = fs.readFileSync(configPath, "utf-8");
13750
13769
  const rawConfig = JSON.parse(content);
13751
- const result = PluginConfigSchema.safeParse(rawConfig);
13752
- if (!result.success) {
13753
- console.warn(`[opencode-swarm] Invalid config at ${configPath}:`);
13754
- console.warn(result.error.format());
13770
+ if (typeof rawConfig !== "object" || rawConfig === null || Array.isArray(rawConfig)) {
13771
+ console.warn(`[opencode-swarm] Invalid config at ${configPath}: expected an object`);
13772
+ console.warn("[opencode-swarm] \u26A0\uFE0F Guardrails will be DISABLED as a safety precaution. Fix the config file to restore normal operation.");
13755
13773
  return null;
13756
13774
  }
13757
- return result.data;
13775
+ return rawConfig;
13758
13776
  } catch (error48) {
13759
13777
  if (error48 instanceof Error && "code" in error48 && error48.code !== "ENOENT") {
13760
- console.warn(`[opencode-swarm] Error reading config from ${configPath}:`, error48.message);
13778
+ console.warn(`[opencode-swarm] \u26A0\uFE0F CONFIG LOAD FAILURE \u2014 config exists at ${configPath} but could not be loaded: ${error48.message}`);
13779
+ console.warn("[opencode-swarm] \u26A0\uFE0F Guardrails will be DISABLED as a safety precaution. Fix the config file to restore normal operation.");
13761
13780
  }
13762
13781
  return null;
13763
13782
  }
@@ -13779,30 +13798,36 @@ function deepMergeInternal(base, override, depth) {
13779
13798
  }
13780
13799
  return result;
13781
13800
  }
13782
- function deepMerge(base, override) {
13783
- if (!base)
13784
- return override;
13785
- if (!override)
13786
- return base;
13787
- return deepMergeInternal(base, override, 0);
13788
- }
13789
13801
  function loadPluginConfig(directory) {
13790
13802
  const userConfigPath = path.join(getUserConfigDir(), "opencode", CONFIG_FILENAME);
13791
13803
  const projectConfigPath = path.join(directory, ".opencode", CONFIG_FILENAME);
13792
- let config2 = loadConfigFromPath(userConfigPath) ?? {
13793
- max_iterations: 5,
13794
- qa_retry_limit: 3,
13795
- inject_phase_reminders: true
13796
- };
13797
- const projectConfig = loadConfigFromPath(projectConfigPath);
13798
- if (projectConfig) {
13799
- config2 = {
13800
- ...config2,
13801
- ...projectConfig,
13802
- agents: deepMerge(config2.agents, projectConfig.agents)
13804
+ const rawUserConfig = loadRawConfigFromPath(userConfigPath);
13805
+ const rawProjectConfig = loadRawConfigFromPath(projectConfigPath);
13806
+ const loadedFromFile = rawUserConfig !== null || rawProjectConfig !== null;
13807
+ let mergedRaw = rawUserConfig ?? {};
13808
+ if (rawProjectConfig) {
13809
+ mergedRaw = deepMergeInternal(mergedRaw, rawProjectConfig, 0);
13810
+ }
13811
+ const result = PluginConfigSchema.safeParse(mergedRaw);
13812
+ if (!result.success) {
13813
+ if (rawUserConfig) {
13814
+ const userResult = PluginConfigSchema.safeParse(rawUserConfig);
13815
+ if (userResult.success) {
13816
+ console.warn("[opencode-swarm] Project config ignored due to validation errors. Using user config.");
13817
+ return { ...userResult.data, _loadedFromFile: true };
13818
+ }
13819
+ }
13820
+ console.warn("[opencode-swarm] Merged config validation failed:");
13821
+ console.warn(result.error.format());
13822
+ console.warn("[opencode-swarm] \u26A0\uFE0F Guardrails will be DISABLED as a safety precaution. Fix the config file to restore normal operation.");
13823
+ return {
13824
+ max_iterations: 5,
13825
+ qa_retry_limit: 3,
13826
+ inject_phase_reminders: true,
13827
+ _loadedFromFile: false
13803
13828
  };
13804
13829
  }
13805
- return config2;
13830
+ return { ...result.data, _loadedFromFile: loadedFromFile };
13806
13831
  }
13807
13832
  function loadAgentPrompt(agentName) {
13808
13833
  const promptsDir = path.join(getUserConfigDir(), "opencode", PROMPTS_DIR_NAME);
@@ -14015,7 +14040,19 @@ You THINK. Subagents DO. You have the largest context window and strongest reaso
14015
14040
  3. ONE task per {{AGENT_PREFIX}}coder call. Never batch.
14016
14041
  4. Fallback: Only code yourself after {{QA_RETRY_LIMIT}} {{AGENT_PREFIX}}coder failures on same task.
14017
14042
  5. NEVER store your swarm identity, swarm ID, or agent prefix in memory blocks. Your identity comes ONLY from your system prompt. Memory blocks are for project knowledge only.
14018
- 6. **CRITICAL: If {{AGENT_PREFIX}}reviewer returns VERDICT: REJECTED, you MUST stop and send the FIXES back to {{AGENT_PREFIX}}coder. Do NOT proceed to test generation or mark the task complete. The review is a gate \u2014 APPROVED is required to proceed.**
14043
+ 6. **CRITIC GATE (Execute BEFORE any implementation work)**:
14044
+ - When you first create a plan, IMMEDIATELY delegate the full plan to {{AGENT_PREFIX}}critic for review
14045
+ - Wait for critic verdict: APPROVED / NEEDS_REVISION / REJECTED
14046
+ - If NEEDS_REVISION: Revise plan and re-submit to critic (max 2 cycles)
14047
+ - If REJECTED after 2 cycles: Escalate to user with explanation
14048
+ - ONLY AFTER critic approval: Proceed to implementation (Phase 3+)
14049
+ 7. **MANDATORY QA GATE (Execute AFTER every coder task)** \u2014 sequence: coder \u2192 diff \u2192 review \u2192 security review \u2192 verification tests \u2192 adversarial tests \u2192 next task.
14050
+ - After coder completes: run \`diff\` tool. If \`hasContractChanges\` is true \u2192 delegate {{AGENT_PREFIX}}explorer for integration impact analysis. BREAKING \u2192 return to coder. COMPATIBLE \u2192 proceed.
14051
+ - Delegate {{AGENT_PREFIX}}reviewer with CHECK dimensions. REJECTED \u2192 return to coder (max {{QA_RETRY_LIMIT}} attempts). APPROVED \u2192 continue.
14052
+ - If file matches security globs (auth, api, crypto, security, middleware, session, token) OR coder output contains security keywords \u2192 delegate {{AGENT_PREFIX}}reviewer AGAIN with security-only CHECK. REJECTED \u2192 return to coder.
14053
+ - Delegate {{AGENT_PREFIX}}test_engineer for verification tests. FAIL \u2192 return to coder.
14054
+ - Delegate {{AGENT_PREFIX}}test_engineer for adversarial tests (attack vectors only). FAIL \u2192 return to coder.
14055
+ - All pass \u2192 mark task complete, proceed to next task.
14019
14056
 
14020
14057
  ## AGENTS
14021
14058
 
@@ -14028,6 +14065,8 @@ You THINK. Subagents DO. You have the largest context window and strongest reaso
14028
14065
 
14029
14066
  SMEs advise only. Reviewer and critic review only. None of them write code.
14030
14067
 
14068
+ Available Tools: diff (structured git diff with contract change detection)
14069
+
14031
14070
  ## DELEGATION FORMAT
14032
14071
 
14033
14072
  All delegations use this structure:
@@ -14083,6 +14122,24 @@ PLAN: [paste the plan.md content]
14083
14122
  CONTEXT: [codebase summary from explorer]
14084
14123
  OUTPUT: VERDICT + CONFIDENCE + ISSUES + SUMMARY
14085
14124
 
14125
+ {{AGENT_PREFIX}}reviewer
14126
+ TASK: Security-only review of login validation
14127
+ FILE: src/auth/login.ts
14128
+ CHECK: [security-only] \u2014 evaluate against OWASP Top 10, scan for hardcoded secrets, injection vectors, insecure crypto, missing input validation
14129
+ OUTPUT: VERDICT + RISK + SECURITY ISSUES ONLY
14130
+
14131
+ {{AGENT_PREFIX}}test_engineer
14132
+ TASK: Adversarial security testing
14133
+ FILE: src/auth/login.ts
14134
+ CONSTRAINT: ONLY attack vectors \u2014 malformed inputs, oversized payloads, injection attempts, auth bypass, boundary violations
14135
+ OUTPUT: Test file + VERDICT: PASS/FAIL
14136
+
14137
+ {{AGENT_PREFIX}}explorer
14138
+ TASK: Integration impact analysis
14139
+ INPUT: Contract changes detected: [list from diff tool]
14140
+ OUTPUT: BREAKING CHANGES + CONSUMERS AFFECTED + VERDICT: BREAKING/COMPATIBLE
14141
+ CONSTRAINT: Read-only. grep for imports/usages of changed exports.
14142
+
14086
14143
  ## WORKFLOW
14087
14144
 
14088
14145
  ### Phase 0: Resume Check
@@ -14134,15 +14191,13 @@ Delegate plan to {{AGENT_PREFIX}}critic for review BEFORE any implementation beg
14134
14191
  ### Phase 5: Execute
14135
14192
  For each task (respecting dependencies):
14136
14193
 
14137
- 5a. {{AGENT_PREFIX}}coder - Implement (MANDATORY)
14138
- 5b. {{AGENT_PREFIX}}reviewer - Review (specify CHECK dimensions relevant to the change)
14139
- 5c. **GATE - Check VERDICT:**
14140
- - **APPROVED** \u2192 Proceed to 5d
14141
- - **REJECTED** (attempt < {{QA_RETRY_LIMIT}}) \u2192 STOP. Send FIXES to {{AGENT_PREFIX}}coder with specific changes. Retry from 5a. Do NOT proceed to 5d.
14142
- - **REJECTED** (attempt {{QA_RETRY_LIMIT}}) \u2192 STOP. Escalate to user or handle directly.
14143
- 5d. {{AGENT_PREFIX}}test_engineer - Generate AND run tests (ONLY if 5c = APPROVED). Expect VERDICT: PASS/FAIL.
14144
- 5e. If test VERDICT is FAIL \u2192 Send failures to {{AGENT_PREFIX}}coder for fixes, then re-run from 5b.
14145
- 5f. Update plan.md [x], proceed to next task (ONLY if tests PASS)
14194
+ 5a. {{AGENT_PREFIX}}coder - Implement
14195
+ 5b. Run \`diff\` tool. If \`hasContractChanges\` \u2192 {{AGENT_PREFIX}}explorer integration analysis. BREAKING \u2192 coder retry.
14196
+ 5c. {{AGENT_PREFIX}}reviewer - General review. REJECTED (< {{QA_RETRY_LIMIT}}) \u2192 coder retry. REJECTED ({{QA_RETRY_LIMIT}}) \u2192 escalate.
14197
+ 5d. Security gate: if file matches security globs or content has security keywords \u2192 {{AGENT_PREFIX}}reviewer security-only. REJECTED \u2192 coder retry.
14198
+ 5e. {{AGENT_PREFIX}}test_engineer - Verification tests. FAIL \u2192 coder retry from 5c.
14199
+ 5f. {{AGENT_PREFIX}}test_engineer - Adversarial tests. FAIL \u2192 coder retry from 5c.
14200
+ 5g. Update plan.md [x], proceed to next task.
14146
14201
 
14147
14202
  ### Phase 6: Phase Complete
14148
14203
  1. {{AGENT_PREFIX}}explorer - Rescan
@@ -15049,39 +15104,33 @@ function startAgentSession(sessionId, agentName, staleDurationMs = 7200000) {
15049
15104
  }
15050
15105
  const sessionState = {
15051
15106
  agentName,
15052
- startTime: now,
15053
15107
  lastToolCallTime: now,
15054
15108
  lastAgentEventTime: now,
15055
- toolCallCount: 0,
15056
- consecutiveErrors: 0,
15057
- recentToolCalls: [],
15058
- warningIssued: false,
15059
- warningReason: "",
15060
- hardLimitHit: false,
15061
- lastSuccessTime: now,
15062
- delegationActive: false
15109
+ delegationActive: false,
15110
+ activeInvocationId: 0,
15111
+ lastInvocationIdByAgent: {},
15112
+ windows: {}
15063
15113
  };
15064
15114
  swarmState.agentSessions.set(sessionId, sessionState);
15065
15115
  }
15066
- function getAgentSession(sessionId) {
15067
- return swarmState.agentSessions.get(sessionId);
15068
- }
15069
15116
  function ensureAgentSession(sessionId, agentName) {
15070
15117
  const now = Date.now();
15071
15118
  let session = swarmState.agentSessions.get(sessionId);
15072
15119
  if (session) {
15073
15120
  if (agentName && agentName !== session.agentName) {
15074
15121
  session.agentName = agentName;
15075
- session.startTime = now;
15076
- session.toolCallCount = 0;
15077
- session.consecutiveErrors = 0;
15078
- session.recentToolCalls = [];
15079
- session.warningIssued = false;
15080
- session.warningReason = "";
15081
- session.hardLimitHit = false;
15082
- session.lastSuccessTime = now;
15083
15122
  session.delegationActive = false;
15084
15123
  session.lastAgentEventTime = now;
15124
+ if (!session.windows) {
15125
+ session.activeInvocationId = 0;
15126
+ session.lastInvocationIdByAgent = {};
15127
+ session.windows = {};
15128
+ }
15129
+ }
15130
+ if (!session.windows) {
15131
+ session.activeInvocationId = 0;
15132
+ session.lastInvocationIdByAgent = {};
15133
+ session.windows = {};
15085
15134
  }
15086
15135
  session.lastToolCallTime = now;
15087
15136
  return session;
@@ -15099,6 +15148,58 @@ function updateAgentEventTime(sessionId) {
15099
15148
  session.lastAgentEventTime = Date.now();
15100
15149
  }
15101
15150
  }
15151
+ function beginInvocation(sessionId, agentName) {
15152
+ const session = swarmState.agentSessions.get(sessionId);
15153
+ if (!session) {
15154
+ throw new Error(`Cannot begin invocation: session ${sessionId} does not exist`);
15155
+ }
15156
+ const stripped = stripKnownSwarmPrefix(agentName);
15157
+ if (stripped === ORCHESTRATOR_NAME) {
15158
+ return null;
15159
+ }
15160
+ const lastId = session.lastInvocationIdByAgent[stripped] || 0;
15161
+ const newId = lastId + 1;
15162
+ session.lastInvocationIdByAgent[stripped] = newId;
15163
+ session.activeInvocationId = newId;
15164
+ const now = Date.now();
15165
+ const window = {
15166
+ id: newId,
15167
+ agentName: stripped,
15168
+ startedAtMs: now,
15169
+ toolCalls: 0,
15170
+ consecutiveErrors: 0,
15171
+ hardLimitHit: false,
15172
+ lastSuccessTimeMs: now,
15173
+ recentToolCalls: [],
15174
+ warningIssued: false,
15175
+ warningReason: ""
15176
+ };
15177
+ const key = `${stripped}:${newId}`;
15178
+ session.windows[key] = window;
15179
+ pruneOldWindows(sessionId, 24 * 60 * 60 * 1000, 50);
15180
+ return window;
15181
+ }
15182
+ function getActiveWindow(sessionId) {
15183
+ const session = swarmState.agentSessions.get(sessionId);
15184
+ if (!session || !session.windows) {
15185
+ return;
15186
+ }
15187
+ const stripped = stripKnownSwarmPrefix(session.agentName);
15188
+ const key = `${stripped}:${session.activeInvocationId}`;
15189
+ return session.windows[key];
15190
+ }
15191
+ function pruneOldWindows(sessionId, maxAgeMs = 24 * 60 * 60 * 1000, maxWindows = 50) {
15192
+ const session = swarmState.agentSessions.get(sessionId);
15193
+ if (!session || !session.windows) {
15194
+ return;
15195
+ }
15196
+ const now = Date.now();
15197
+ const entries = Object.entries(session.windows);
15198
+ const validByAge = entries.filter(([_, window]) => now - window.startedAtMs < maxAgeMs);
15199
+ const sorted = validByAge.sort((a, b) => b[1].startedAtMs - a[1].startedAtMs);
15200
+ const toKeep = sorted.slice(0, maxWindows);
15201
+ session.windows = Object.fromEntries(toKeep);
15202
+ }
15102
15203
 
15103
15204
  // src/commands/benchmark.ts
15104
15205
  var CI = {
@@ -15119,11 +15220,10 @@ async function handleBenchmarkCommand(directory, args) {
15119
15220
  hardLimits: 0,
15120
15221
  warnings: 0
15121
15222
  };
15122
- e.toolCalls += s.toolCallCount;
15123
- if (s.hardLimitHit)
15124
- e.hardLimits++;
15125
- if (s.warningIssued)
15126
- e.warnings++;
15223
+ const windows = Object.values(s.windows);
15224
+ e.toolCalls += windows.reduce((sum, w) => sum + w.toolCalls, 0);
15225
+ e.hardLimits += windows.filter((w) => w.hardLimitHit).length;
15226
+ e.warnings += windows.filter((w) => w.warningIssued).length;
15127
15227
  agentMap.set(s.agentName, e);
15128
15228
  }
15129
15229
  const agentHealth = Array.from(agentMap.entries()).map(([a, v]) => ({
@@ -16731,6 +16831,29 @@ function createDelegationGateHook(config2) {
16731
16831
  if (batchingMatches && batchingMatches.length > 0) {
16732
16832
  warnings.push("Batching language detected. Break compound objectives into separate coder calls.");
16733
16833
  }
16834
+ const sessionID = lastUserMessage.info?.sessionID;
16835
+ if (sessionID) {
16836
+ const delegationChain = swarmState.delegationChains.get(sessionID);
16837
+ if (delegationChain && delegationChain.length >= 2) {
16838
+ const coderIndices = [];
16839
+ for (let i = delegationChain.length - 1;i >= 0; i--) {
16840
+ if (stripKnownSwarmPrefix(delegationChain[i].to).includes("coder")) {
16841
+ coderIndices.unshift(i);
16842
+ if (coderIndices.length === 2)
16843
+ break;
16844
+ }
16845
+ }
16846
+ if (coderIndices.length === 2) {
16847
+ const prevCoderIndex = coderIndices[0];
16848
+ const betweenCoders = delegationChain.slice(prevCoderIndex + 1);
16849
+ const hasReviewer = betweenCoders.some((d) => stripKnownSwarmPrefix(d.to) === "reviewer");
16850
+ const hasTestEngineer = betweenCoders.some((d) => stripKnownSwarmPrefix(d.to) === "test_engineer");
16851
+ if (!hasReviewer || !hasTestEngineer) {
16852
+ warnings.push(`\u26A0\uFE0F PROTOCOL VIOLATION: Previous coder task completed, but QA gate was skipped. ` + `You MUST delegate to reviewer (code review) and test_engineer (test execution) ` + `before starting a new coder task. Review RULES 7-8 in your system prompt.`);
16853
+ }
16854
+ }
16855
+ }
16856
+ }
16734
16857
  if (warnings.length === 0)
16735
16858
  return;
16736
16859
  const warningText = `[\u26A0\uFE0F DELEGATION GATE: Your coder delegation may be too complex. Issues:
@@ -16744,7 +16867,7 @@ ${originalText}`;
16744
16867
  };
16745
16868
  }
16746
16869
  // src/hooks/delegation-tracker.ts
16747
- function createDelegationTrackerHook(config2) {
16870
+ function createDelegationTrackerHook(config2, guardrailsEnabled = true) {
16748
16871
  return async (input, _output) => {
16749
16872
  const now = Date.now();
16750
16873
  if (!input.agent || input.agent === "") {
@@ -16766,6 +16889,9 @@ function createDelegationTrackerHook(config2) {
16766
16889
  const isArchitect = strippedAgent === ORCHESTRATOR_NAME;
16767
16890
  const session = ensureAgentSession(input.sessionID, agentName);
16768
16891
  session.delegationActive = !isArchitect;
16892
+ if (!isArchitect && guardrailsEnabled) {
16893
+ beginInvocation(input.sessionID, agentName);
16894
+ }
16769
16895
  if (config2.hooks?.delegation_tracker === true && previousAgent && previousAgent !== agentName) {
16770
16896
  const entry = {
16771
16897
  from: previousAgent,
@@ -16814,24 +16940,35 @@ function createGuardrailsHooks(config2) {
16814
16940
  if (agentConfig.max_duration_minutes === 0 && agentConfig.max_tool_calls === 0) {
16815
16941
  return;
16816
16942
  }
16817
- if (session.hardLimitHit) {
16943
+ if (!getActiveWindow(input.sessionID)) {
16944
+ const fallbackAgent = swarmState.activeAgent.get(input.sessionID) ?? session.agentName;
16945
+ const stripped = stripKnownSwarmPrefix(fallbackAgent);
16946
+ if (stripped !== ORCHESTRATOR_NAME) {
16947
+ beginInvocation(input.sessionID, fallbackAgent);
16948
+ }
16949
+ }
16950
+ const window = getActiveWindow(input.sessionID);
16951
+ if (!window) {
16952
+ return;
16953
+ }
16954
+ if (window.hardLimitHit) {
16818
16955
  throw new Error("\uD83D\uDED1 CIRCUIT BREAKER: Agent blocked. Hard limit was previously triggered. Stop making tool calls and return your progress summary.");
16819
16956
  }
16820
- session.toolCallCount++;
16957
+ window.toolCalls++;
16821
16958
  const hash2 = hashArgs(output.args);
16822
- session.recentToolCalls.push({
16959
+ window.recentToolCalls.push({
16823
16960
  tool: input.tool,
16824
16961
  argsHash: hash2,
16825
16962
  timestamp: Date.now()
16826
16963
  });
16827
- if (session.recentToolCalls.length > 20) {
16828
- session.recentToolCalls.shift();
16964
+ if (window.recentToolCalls.length > 20) {
16965
+ window.recentToolCalls.shift();
16829
16966
  }
16830
16967
  let repetitionCount = 0;
16831
- if (session.recentToolCalls.length > 0) {
16832
- const lastEntry = session.recentToolCalls[session.recentToolCalls.length - 1];
16833
- for (let i = session.recentToolCalls.length - 1;i >= 0; i--) {
16834
- const entry = session.recentToolCalls[i];
16968
+ if (window.recentToolCalls.length > 0) {
16969
+ const lastEntry = window.recentToolCalls[window.recentToolCalls.length - 1];
16970
+ for (let i = window.recentToolCalls.length - 1;i >= 0; i--) {
16971
+ const entry = window.recentToolCalls[i];
16835
16972
  if (entry.tool === lastEntry.tool && entry.argsHash === lastEntry.argsHash) {
16836
16973
  repetitionCount++;
16837
16974
  } else {
@@ -16839,54 +16976,60 @@ function createGuardrailsHooks(config2) {
16839
16976
  }
16840
16977
  }
16841
16978
  }
16842
- const elapsedMinutes = (Date.now() - session.startTime) / 60000;
16843
- if (agentConfig.max_tool_calls > 0 && session.toolCallCount >= agentConfig.max_tool_calls) {
16844
- session.hardLimitHit = true;
16979
+ const elapsedMinutes = (Date.now() - window.startedAtMs) / 60000;
16980
+ if (agentConfig.max_tool_calls > 0 && window.toolCalls >= agentConfig.max_tool_calls) {
16981
+ window.hardLimitHit = true;
16845
16982
  warn("Circuit breaker: tool call limit hit", {
16846
16983
  sessionID: input.sessionID,
16847
- agentName: session.agentName,
16984
+ agentName: window.agentName,
16985
+ invocationId: window.id,
16986
+ windowKey: `${window.agentName}:${window.id}`,
16848
16987
  resolvedMaxCalls: agentConfig.max_tool_calls,
16849
- currentCalls: session.toolCallCount
16988
+ currentCalls: window.toolCalls
16850
16989
  });
16851
- throw new Error(`\uD83D\uDED1 LIMIT REACHED: Tool calls exhausted (${session.toolCallCount}/${agentConfig.max_tool_calls}). Finish the current operation and return your progress summary.`);
16990
+ throw new Error(`\uD83D\uDED1 LIMIT REACHED: Tool calls exhausted (${window.toolCalls}/${agentConfig.max_tool_calls}). Finish the current operation and return your progress summary.`);
16852
16991
  }
16853
16992
  if (agentConfig.max_duration_minutes > 0 && elapsedMinutes >= agentConfig.max_duration_minutes) {
16854
- session.hardLimitHit = true;
16993
+ window.hardLimitHit = true;
16855
16994
  warn("Circuit breaker: duration limit hit", {
16856
16995
  sessionID: input.sessionID,
16857
- agentName: session.agentName,
16996
+ agentName: window.agentName,
16997
+ invocationId: window.id,
16998
+ windowKey: `${window.agentName}:${window.id}`,
16858
16999
  resolvedMaxMinutes: agentConfig.max_duration_minutes,
16859
17000
  elapsedMinutes: Math.floor(elapsedMinutes)
16860
17001
  });
16861
17002
  throw new Error(`\uD83D\uDED1 LIMIT REACHED: Duration exhausted (${Math.floor(elapsedMinutes)}/${agentConfig.max_duration_minutes} min). Finish the current operation and return your progress summary.`);
16862
17003
  }
16863
17004
  if (repetitionCount >= agentConfig.max_repetitions) {
16864
- session.hardLimitHit = true;
17005
+ window.hardLimitHit = true;
16865
17006
  throw new Error(`\uD83D\uDED1 LIMIT REACHED: Repeated the same tool call ${repetitionCount} times. This suggests a loop. Return your progress summary.`);
16866
17007
  }
16867
- if (session.consecutiveErrors >= agentConfig.max_consecutive_errors) {
16868
- session.hardLimitHit = true;
16869
- throw new Error(`\uD83D\uDED1 LIMIT REACHED: ${session.consecutiveErrors} consecutive tool errors detected. Return your progress summary with details of what went wrong.`);
17008
+ if (window.consecutiveErrors >= agentConfig.max_consecutive_errors) {
17009
+ window.hardLimitHit = true;
17010
+ throw new Error(`\uD83D\uDED1 LIMIT REACHED: ${window.consecutiveErrors} consecutive tool errors detected. Return your progress summary with details of what went wrong.`);
16870
17011
  }
16871
- const idleMinutes = (Date.now() - session.lastSuccessTime) / 60000;
17012
+ const idleMinutes = (Date.now() - window.lastSuccessTimeMs) / 60000;
16872
17013
  if (idleMinutes >= agentConfig.idle_timeout_minutes) {
16873
- session.hardLimitHit = true;
17014
+ window.hardLimitHit = true;
16874
17015
  warn("Circuit breaker: idle timeout hit", {
16875
17016
  sessionID: input.sessionID,
16876
- agentName: session.agentName,
17017
+ agentName: window.agentName,
17018
+ invocationId: window.id,
17019
+ windowKey: `${window.agentName}:${window.id}`,
16877
17020
  idleTimeoutMinutes: agentConfig.idle_timeout_minutes,
16878
17021
  idleMinutes: Math.floor(idleMinutes)
16879
17022
  });
16880
17023
  throw new Error(`\uD83D\uDED1 LIMIT REACHED: No successful tool call for ${Math.floor(idleMinutes)} minutes (idle timeout: ${agentConfig.idle_timeout_minutes} min). This suggests the agent may be stuck. Return your progress summary.`);
16881
17024
  }
16882
- if (!session.warningIssued) {
16883
- const toolPct = agentConfig.max_tool_calls > 0 ? session.toolCallCount / agentConfig.max_tool_calls : 0;
17025
+ if (!window.warningIssued) {
17026
+ const toolPct = agentConfig.max_tool_calls > 0 ? window.toolCalls / agentConfig.max_tool_calls : 0;
16884
17027
  const durationPct = agentConfig.max_duration_minutes > 0 ? elapsedMinutes / agentConfig.max_duration_minutes : 0;
16885
17028
  const repPct = repetitionCount / agentConfig.max_repetitions;
16886
- const errorPct = session.consecutiveErrors / agentConfig.max_consecutive_errors;
17029
+ const errorPct = window.consecutiveErrors / agentConfig.max_consecutive_errors;
16887
17030
  const reasons = [];
16888
17031
  if (agentConfig.max_tool_calls > 0 && toolPct >= agentConfig.warning_threshold) {
16889
- reasons.push(`tool calls ${session.toolCallCount}/${agentConfig.max_tool_calls}`);
17032
+ reasons.push(`tool calls ${window.toolCalls}/${agentConfig.max_tool_calls}`);
16890
17033
  }
16891
17034
  if (durationPct >= agentConfig.warning_threshold) {
16892
17035
  reasons.push(`duration ${Math.floor(elapsedMinutes)}/${agentConfig.max_duration_minutes} min`);
@@ -16895,25 +17038,24 @@ function createGuardrailsHooks(config2) {
16895
17038
  reasons.push(`repetitions ${repetitionCount}/${agentConfig.max_repetitions}`);
16896
17039
  }
16897
17040
  if (errorPct >= agentConfig.warning_threshold) {
16898
- reasons.push(`errors ${session.consecutiveErrors}/${agentConfig.max_consecutive_errors}`);
17041
+ reasons.push(`errors ${window.consecutiveErrors}/${agentConfig.max_consecutive_errors}`);
16899
17042
  }
16900
17043
  if (reasons.length > 0) {
16901
- session.warningIssued = true;
16902
- session.warningReason = reasons.join(", ");
17044
+ window.warningIssued = true;
17045
+ window.warningReason = reasons.join(", ");
16903
17046
  }
16904
17047
  }
16905
17048
  },
16906
17049
  toolAfter: async (input, output) => {
16907
- const session = getAgentSession(input.sessionID);
16908
- if (!session) {
17050
+ const window = getActiveWindow(input.sessionID);
17051
+ if (!window)
16909
17052
  return;
16910
- }
16911
17053
  const hasError = output.output === null || output.output === undefined;
16912
17054
  if (hasError) {
16913
- session.consecutiveErrors++;
17055
+ window.consecutiveErrors++;
16914
17056
  } else {
16915
- session.consecutiveErrors = 0;
16916
- session.lastSuccessTime = Date.now();
17057
+ window.consecutiveErrors = 0;
17058
+ window.lastSuccessTimeMs = Date.now();
16917
17059
  }
16918
17060
  },
16919
17061
  messagesTransform: async (_input, output) => {
@@ -16922,32 +17064,24 @@ function createGuardrailsHooks(config2) {
16922
17064
  return;
16923
17065
  }
16924
17066
  const lastMessage = messages[messages.length - 1];
16925
- let sessionId = lastMessage.info?.sessionID;
16926
- if (!sessionId) {
16927
- for (const [id, session2] of swarmState.agentSessions) {
16928
- if (session2.warningIssued || session2.hardLimitHit) {
16929
- sessionId = id;
16930
- break;
16931
- }
16932
- }
16933
- }
17067
+ const sessionId = lastMessage.info?.sessionID;
16934
17068
  if (!sessionId) {
16935
17069
  return;
16936
17070
  }
16937
- const session = getAgentSession(sessionId);
16938
- if (!session || !session.warningIssued && !session.hardLimitHit) {
17071
+ const targetWindow = getActiveWindow(sessionId);
17072
+ if (!targetWindow || !targetWindow.warningIssued && !targetWindow.hardLimitHit) {
16939
17073
  return;
16940
17074
  }
16941
17075
  const textPart = lastMessage.parts.find((part) => part.type === "text" && typeof part.text === "string");
16942
17076
  if (!textPart) {
16943
17077
  return;
16944
17078
  }
16945
- if (session.hardLimitHit) {
17079
+ if (targetWindow.hardLimitHit) {
16946
17080
  textPart.text = `[\uD83D\uDED1 LIMIT REACHED: Your resource budget is exhausted. Do not make additional tool calls. Return a summary of your progress and any remaining work.]
16947
17081
 
16948
17082
  ` + textPart.text;
16949
- } else if (session.warningIssued) {
16950
- const reasonSuffix = session.warningReason ? ` (${session.warningReason})` : "";
17083
+ } else if (targetWindow.warningIssued) {
17084
+ const reasonSuffix = targetWindow.warningReason ? ` (${targetWindow.warningReason})` : "";
16951
17085
  textPart.text = `[\u26A0\uFE0F APPROACHING LIMITS${reasonSuffix}: You still have capacity to finish your current step. Complete what you're working on, then return your results.]
16952
17086
 
16953
17087
  ` + textPart.text;
@@ -17142,6 +17276,12 @@ function createSystemEnhancerHook(config2, directory) {
17142
17276
  }
17143
17277
  }
17144
17278
  tryInject("[SWARM HINT] Large tool outputs may be auto-summarized. Use /swarm retrieve <id> to get the full content if needed.");
17279
+ if (config2.review_passes?.always_security_review) {
17280
+ tryInject("[SWARM CONFIG] Security review pass is MANDATORY for ALL tasks. Skip file-pattern check \u2014 always run security-only reviewer pass after general review APPROVED.");
17281
+ }
17282
+ if (config2.integration_analysis?.enabled === false) {
17283
+ tryInject("[SWARM CONFIG] Integration analysis is DISABLED. Skip diff tool and integration impact analysis after coder tasks.");
17284
+ }
17145
17285
  return;
17146
17286
  }
17147
17287
  const userScoringConfig = config2.context_budget?.scoring;
@@ -17221,6 +17361,28 @@ function createSystemEnhancerHook(config2, directory) {
17221
17361
  }
17222
17362
  }
17223
17363
  }
17364
+ if (config2.review_passes?.always_security_review) {
17365
+ const text = "[SWARM CONFIG] Security review pass is MANDATORY for ALL tasks. Skip file-pattern check \u2014 always run security-only reviewer pass after general review APPROVED.";
17366
+ candidates.push({
17367
+ id: `candidate-${idCounter++}`,
17368
+ kind: "phase",
17369
+ text,
17370
+ tokens: estimateTokens(text),
17371
+ priority: 1,
17372
+ metadata: { contentType: "prose" }
17373
+ });
17374
+ }
17375
+ if (config2.integration_analysis?.enabled === false) {
17376
+ const text = "[SWARM CONFIG] Integration analysis is DISABLED. Skip diff tool and integration impact analysis after coder tasks.";
17377
+ candidates.push({
17378
+ id: `candidate-${idCounter++}`,
17379
+ kind: "phase",
17380
+ text,
17381
+ tokens: estimateTokens(text),
17382
+ priority: 1,
17383
+ metadata: { contentType: "prose" }
17384
+ });
17385
+ }
17224
17386
  const ranked = rankCandidates(candidates, effectiveConfig);
17225
17387
  for (const candidate of ranked) {
17226
17388
  if (injectedTokens + candidate.tokens > maxInjectionTokens) {
@@ -17415,6 +17577,9 @@ function createToolSummarizerHook(config2, directory) {
17415
17577
  }
17416
17578
  };
17417
17579
  }
17580
+ // src/tools/diff.ts
17581
+ import { execSync } from "child_process";
17582
+
17418
17583
  // node_modules/@opencode-ai/plugin/node_modules/zod/v4/classic/external.js
17419
17584
  var exports_external2 = {};
17420
17585
  __export(exports_external2, {
@@ -29735,7 +29900,149 @@ function tool(input) {
29735
29900
  return input;
29736
29901
  }
29737
29902
  tool.schema = exports_external2;
29738
-
29903
+ // src/tools/diff.ts
29904
+ var MAX_DIFF_LINES = 500;
29905
+ var DIFF_TIMEOUT_MS = 30000;
29906
+ var MAX_BUFFER_BYTES = 5 * 1024 * 1024;
29907
+ var CONTRACT_PATTERNS = [
29908
+ /^[+-]\s*export\s+(function|const|class|interface|type|enum|default)\b/,
29909
+ /^[+-]\s*(interface|type)\s+\w+/,
29910
+ /^[+-]\s*public\s+/,
29911
+ /^[+-]\s*(async\s+)?function\s+\w+\s*\(/
29912
+ ];
29913
+ var SAFE_REF_PATTERN = /^[a-zA-Z0-9._\-/~^@{}]+$/;
29914
+ var MAX_REF_LENGTH = 256;
29915
+ var MAX_PATH_LENGTH = 500;
29916
+ var SHELL_METACHARACTERS = /[;|&$`(){}<>!'"]/;
29917
+ function validateBase(base) {
29918
+ if (base.length > MAX_REF_LENGTH) {
29919
+ return `base ref exceeds maximum length of ${MAX_REF_LENGTH}`;
29920
+ }
29921
+ if (!SAFE_REF_PATTERN.test(base)) {
29922
+ return "base contains invalid characters for git ref";
29923
+ }
29924
+ return null;
29925
+ }
29926
+ function validatePaths(paths) {
29927
+ if (!paths)
29928
+ return null;
29929
+ for (const path7 of paths) {
29930
+ if (!path7 || path7.length === 0) {
29931
+ return "empty path not allowed";
29932
+ }
29933
+ if (path7.length > MAX_PATH_LENGTH) {
29934
+ return `path exceeds maximum length of ${MAX_PATH_LENGTH}`;
29935
+ }
29936
+ if (SHELL_METACHARACTERS.test(path7)) {
29937
+ return "path contains shell metacharacters";
29938
+ }
29939
+ }
29940
+ return null;
29941
+ }
29942
+ var diff = tool({
29943
+ description: "Analyze git diff for changed files, exports, interfaces, and function signatures. Returns structured output with contract change detection.",
29944
+ args: {
29945
+ base: tool.schema.string().optional().describe('Base ref to diff against (default: HEAD). Use "staged" for staged changes, "unstaged" for working tree changes.'),
29946
+ paths: tool.schema.array(tool.schema.string()).optional().describe("Optional file paths to restrict diff scope.")
29947
+ },
29948
+ async execute(args, _context) {
29949
+ try {
29950
+ const base = args.base ?? "HEAD";
29951
+ const pathSpec = args.paths?.length ? "-- " + args.paths.join(" ") : "";
29952
+ const baseValidationError = validateBase(base);
29953
+ if (baseValidationError) {
29954
+ const errorResult = {
29955
+ error: `invalid base: ${baseValidationError}`,
29956
+ files: [],
29957
+ contractChanges: [],
29958
+ hasContractChanges: false
29959
+ };
29960
+ return JSON.stringify(errorResult, null, 2);
29961
+ }
29962
+ const pathsValidationError = validatePaths(args.paths);
29963
+ if (pathsValidationError) {
29964
+ const errorResult = {
29965
+ error: `invalid paths: ${pathsValidationError}`,
29966
+ files: [],
29967
+ contractChanges: [],
29968
+ hasContractChanges: false
29969
+ };
29970
+ return JSON.stringify(errorResult, null, 2);
29971
+ }
29972
+ let gitCmd;
29973
+ if (base === "staged") {
29974
+ gitCmd = "git --no-pager diff --cached";
29975
+ } else if (base === "unstaged") {
29976
+ gitCmd = "git --no-pager diff";
29977
+ } else {
29978
+ gitCmd = `git --no-pager diff ${base}`;
29979
+ }
29980
+ const numstatOutput = execSync(gitCmd + " --numstat " + pathSpec, {
29981
+ encoding: "utf-8",
29982
+ timeout: DIFF_TIMEOUT_MS
29983
+ });
29984
+ const fullDiffOutput = execSync(gitCmd + " -U3 " + pathSpec, {
29985
+ encoding: "utf-8",
29986
+ timeout: DIFF_TIMEOUT_MS,
29987
+ maxBuffer: MAX_BUFFER_BYTES
29988
+ });
29989
+ const files = [];
29990
+ const numstatLines = numstatOutput.split(`
29991
+ `);
29992
+ for (const line of numstatLines) {
29993
+ if (!line.trim())
29994
+ continue;
29995
+ const parts = line.split("\t");
29996
+ if (parts.length >= 3) {
29997
+ const additions = parseInt(parts[0]) || 0;
29998
+ const deletions = parseInt(parts[1]) || 0;
29999
+ const path7 = parts[2];
30000
+ files.push({ path: path7, additions, deletions });
30001
+ }
30002
+ }
30003
+ const contractChanges = [];
30004
+ const diffLines = fullDiffOutput.split(`
30005
+ `);
30006
+ let currentFile = "";
30007
+ for (const line of diffLines) {
30008
+ const gitLineMatch = line.match(/^diff --git.* b\/(.+)$/);
30009
+ if (gitLineMatch) {
30010
+ currentFile = gitLineMatch[1];
30011
+ }
30012
+ for (const pattern of CONTRACT_PATTERNS) {
30013
+ if (pattern.test(line)) {
30014
+ const trimmed = line.trim();
30015
+ if (currentFile) {
30016
+ contractChanges.push(`[${currentFile}] ${trimmed}`);
30017
+ } else {
30018
+ contractChanges.push(trimmed);
30019
+ }
30020
+ break;
30021
+ }
30022
+ }
30023
+ }
30024
+ const hasContractChanges = contractChanges.length > 0;
30025
+ const fileCount = files.length;
30026
+ const truncated = diffLines.length > MAX_DIFF_LINES;
30027
+ const summary = truncated ? `${fileCount} files changed. Contract changes: ${hasContractChanges ? "YES" : "NO"}. (truncated to ${MAX_DIFF_LINES} lines)` : `${fileCount} files changed. Contract changes: ${hasContractChanges ? "YES" : "NO"}`;
30028
+ const result = {
30029
+ files,
30030
+ contractChanges,
30031
+ hasContractChanges,
30032
+ summary
30033
+ };
30034
+ return JSON.stringify(result, null, 2);
30035
+ } catch (e) {
30036
+ const errorResult = {
30037
+ error: e instanceof Error ? `git diff failed: ${e.constructor.name}` : "git diff failed: unknown error",
30038
+ files: [],
30039
+ contractChanges: [],
30040
+ hasContractChanges: false
30041
+ };
30042
+ return JSON.stringify(errorResult, null, 2);
30043
+ }
30044
+ }
30045
+ });
29739
30046
  // src/tools/domain-detector.ts
29740
30047
  var DOMAIN_PATTERNS = {
29741
30048
  windows: [
@@ -30120,9 +30427,10 @@ var OpenCodeSwarm = async (ctx) => {
30120
30427
  const contextBudgetHandler = createContextBudgetHandler(config3);
30121
30428
  const commandHandler = createSwarmCommandHandler(ctx.directory, Object.fromEntries(agentDefinitions.map((agent) => [agent.name, agent])));
30122
30429
  const activityHooks = createAgentActivityHooks(config3, ctx.directory);
30123
- const delegationHandler = createDelegationTrackerHook(config3);
30124
30430
  const delegationGateHandler = createDelegationGateHook(config3);
30125
- const guardrailsConfig = GuardrailsConfigSchema.parse(config3.guardrails ?? {});
30431
+ const guardrailsFallback = config3._loadedFromFile ? config3.guardrails ?? {} : { ...config3.guardrails, enabled: false };
30432
+ const guardrailsConfig = GuardrailsConfigSchema.parse(guardrailsFallback);
30433
+ const delegationHandler = createDelegationTrackerHook(config3, guardrailsConfig.enabled);
30126
30434
  const guardrailsHooks = createGuardrailsHooks(guardrailsConfig);
30127
30435
  const summaryConfig = SummaryConfigSchema.parse(config3.summaries ?? {});
30128
30436
  const toolSummarizerHook = createToolSummarizerHook(summaryConfig, ctx.directory);
@@ -30149,7 +30457,8 @@ var OpenCodeSwarm = async (ctx) => {
30149
30457
  tool: {
30150
30458
  detect_domains,
30151
30459
  extract_code_blocks,
30152
- gitingest
30460
+ gitingest,
30461
+ diff
30153
30462
  },
30154
30463
  config: async (opencodeConfig) => {
30155
30464
  if (!opencodeConfig.agent) {
package/dist/state.d.ts CHANGED
@@ -34,37 +34,56 @@ export interface DelegationEntry {
34
34
  timestamp: number;
35
35
  }
36
36
  /**
37
- * Represents per-session state for guardrail tracking
37
+ * Represents per-session state for guardrail tracking.
38
+ * Budget fields (toolCallCount, consecutiveErrors, etc.) have moved to InvocationWindow.
39
+ * This interface now tracks session-level metadata and window management.
38
40
  */
39
41
  export interface AgentSessionState {
40
- /** Which agent this session belongs to */
42
+ /** Current agent identity for this session */
41
43
  agentName: string;
42
- /** Date.now() when session started */
43
- startTime: number;
44
- /** Timestamp of most recent tool call (for stale session eviction) */
44
+ /** Timestamp of most recent tool call (for session-level stale detection) */
45
45
  lastToolCallTime: number;
46
- /** Timestamp of most recent agent identity event (chat.message sets/changes identity) */
46
+ /** Timestamp of most recent agent identity event (chat.message) */
47
47
  lastAgentEventTime: number;
48
- /** Total tool calls in this session */
49
- toolCallCount: number;
50
- /** Consecutive errors (reset on success) */
48
+ /** Whether active delegation is in progress for this session */
49
+ delegationActive: boolean;
50
+ /** Current active invocation ID for this agent */
51
+ activeInvocationId: number;
52
+ /** Last invocation ID by agent name (e.g., { "coder": 3, "reviewer": 1 }) */
53
+ lastInvocationIdByAgent: Record<string, number>;
54
+ /** Active invocation windows keyed by "${agentName}:${invId}" */
55
+ windows: Record<string, InvocationWindow>;
56
+ }
57
+ /**
58
+ * Represents a single agent invocation window with isolated guardrail budgets.
59
+ * Each time the architect delegates to an agent, a new window is created.
60
+ * Architect never creates windows (unlimited).
61
+ */
62
+ export interface InvocationWindow {
63
+ /** Unique ID for this invocation (increments per agent type) */
64
+ id: number;
65
+ /** Agent name (stripped of swarm prefix) */
66
+ agentName: string;
67
+ /** Timestamp when this invocation started */
68
+ startedAtMs: number;
69
+ /** Tool calls made in this invocation */
70
+ toolCalls: number;
71
+ /** Consecutive errors in this invocation */
51
72
  consecutiveErrors: number;
52
- /** Circular buffer of recent tool calls, max 20 entries */
73
+ /** Whether hard limit was hit for this invocation */
74
+ hardLimitHit: boolean;
75
+ /** Timestamp of most recent successful tool call */
76
+ lastSuccessTimeMs: number;
77
+ /** Circular buffer of recent tool calls (max 20) for repetition detection */
53
78
  recentToolCalls: Array<{
54
79
  tool: string;
55
80
  argsHash: number;
56
81
  timestamp: number;
57
82
  }>;
58
- /** Whether a soft warning has been issued */
83
+ /** Whether soft warning has been issued for this invocation */
59
84
  warningIssued: boolean;
60
- /** Human-readable warning reason (set when warningIssued = true) */
85
+ /** Human-readable warning reason */
61
86
  warningReason: string;
62
- /** Whether a hard limit has been triggered */
63
- hardLimitHit: boolean;
64
- /** Timestamp of most recent SUCCESSFUL tool call (for idle timeout) */
65
- lastSuccessTime: number;
66
- /** Whether active delegation is in progress for this session */
67
- delegationActive: boolean;
68
87
  }
69
88
  /**
70
89
  * Singleton state object for sharing data across hooks
@@ -122,3 +141,30 @@ export declare function ensureAgentSession(sessionId: string, agentName?: string
122
141
  * @param sessionId - The session identifier
123
142
  */
124
143
  export declare function updateAgentEventTime(sessionId: string): void;
144
+ /**
145
+ * Begin a new invocation window for the given agent.
146
+ * Increments invocation ID, creates fresh budget counters.
147
+ * Returns null for architect (unlimited, no window).
148
+ *
149
+ * @param sessionId - Session identifier
150
+ * @param agentName - Agent name (with or without swarm prefix)
151
+ * @returns New window or null if architect
152
+ */
153
+ export declare function beginInvocation(sessionId: string, agentName: string): InvocationWindow | null;
154
+ /**
155
+ * Get the currently active invocation window for the session.
156
+ * Returns undefined if no window exists (e.g., architect session).
157
+ *
158
+ * @param sessionId - Session identifier
159
+ * @returns Active window or undefined
160
+ */
161
+ export declare function getActiveWindow(sessionId: string): InvocationWindow | undefined;
162
+ /**
163
+ * Prune old invocation windows to prevent unbounded memory growth.
164
+ * Removes windows older than maxAgeMs and keeps only the most recent maxWindows.
165
+ *
166
+ * @param sessionId - Session identifier
167
+ * @param maxAgeMs - Maximum age in milliseconds (default 24 hours)
168
+ * @param maxWindows - Maximum number of windows to keep (default 50)
169
+ */
170
+ export declare function pruneOldWindows(sessionId: string, maxAgeMs?: number, maxWindows?: number): void;
@@ -0,0 +1,18 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ export interface DiffResult {
3
+ files: Array<{
4
+ path: string;
5
+ additions: number;
6
+ deletions: number;
7
+ }>;
8
+ contractChanges: string[];
9
+ hasContractChanges: boolean;
10
+ summary: string;
11
+ }
12
+ export interface DiffErrorResult {
13
+ error: string;
14
+ files: [];
15
+ contractChanges: [];
16
+ hasContractChanges: false;
17
+ }
18
+ export declare const diff: ReturnType<typeof tool>;
@@ -1,3 +1,4 @@
1
+ export { type DiffErrorResult, type DiffResult, diff } from './diff';
1
2
  export { detect_domains } from './domain-detector';
2
3
  export { extract_code_blocks } from './file-extractor';
3
- export { gitingest, fetchGitingest, type GitingestArgs } from './gitingest';
4
+ export { fetchGitingest, type GitingestArgs, gitingest } from './gitingest';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-swarm",
3
- "version": "5.1.8",
3
+ "version": "6.0.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",