opencode-swarm-plugin 0.13.2 → 0.15.0

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/src/learning.ts CHANGED
@@ -524,6 +524,203 @@ export class InMemoryFeedbackStorage implements FeedbackStorage {
524
524
  }
525
525
  }
526
526
 
527
+ // ============================================================================
528
+ // 3-Strike Detection
529
+ // ============================================================================
530
+
531
+ /**
532
+ * Strike record for a bead
533
+ *
534
+ * Tracks consecutive fix failures to detect architectural problems.
535
+ * After 3 strikes, the system should STOP and question the architecture
536
+ * rather than attempting Fix #4.
537
+ */
538
+ export const StrikeRecordSchema = z.object({
539
+ /** The bead ID */
540
+ bead_id: z.string(),
541
+ /** Number of consecutive failures */
542
+ strike_count: z.number().int().min(0).max(3),
543
+ /** Failure descriptions for each strike */
544
+ failures: z.array(
545
+ z.object({
546
+ /** What fix was attempted */
547
+ attempt: z.string(),
548
+ /** Why it failed */
549
+ reason: z.string(),
550
+ /** When it failed */
551
+ timestamp: z.string(), // ISO-8601
552
+ }),
553
+ ),
554
+ /** When strikes were recorded */
555
+ first_strike_at: z.string().optional(), // ISO-8601
556
+ last_strike_at: z.string().optional(), // ISO-8601
557
+ });
558
+ export type StrikeRecord = z.infer<typeof StrikeRecordSchema>;
559
+
560
+ /**
561
+ * Storage interface for strike records
562
+ */
563
+ export interface StrikeStorage {
564
+ /** Store a strike record */
565
+ store(record: StrikeRecord): Promise<void>;
566
+ /** Get strike record for a bead */
567
+ get(beadId: string): Promise<StrikeRecord | null>;
568
+ /** Get all strike records */
569
+ getAll(): Promise<StrikeRecord[]>;
570
+ /** Clear strikes for a bead */
571
+ clear(beadId: string): Promise<void>;
572
+ }
573
+
574
+ /**
575
+ * In-memory strike storage
576
+ */
577
+ export class InMemoryStrikeStorage implements StrikeStorage {
578
+ private strikes: Map<string, StrikeRecord> = new Map();
579
+
580
+ async store(record: StrikeRecord): Promise<void> {
581
+ this.strikes.set(record.bead_id, record);
582
+ }
583
+
584
+ async get(beadId: string): Promise<StrikeRecord | null> {
585
+ return this.strikes.get(beadId) ?? null;
586
+ }
587
+
588
+ async getAll(): Promise<StrikeRecord[]> {
589
+ return Array.from(this.strikes.values());
590
+ }
591
+
592
+ async clear(beadId: string): Promise<void> {
593
+ this.strikes.delete(beadId);
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Add a strike to a bead's record
599
+ *
600
+ * Records a failure attempt and increments the strike count.
601
+ *
602
+ * @param beadId - Bead ID
603
+ * @param attempt - Description of what was attempted
604
+ * @param reason - Why it failed
605
+ * @param storage - Strike storage (defaults to in-memory)
606
+ * @returns Updated strike record
607
+ */
608
+ export async function addStrike(
609
+ beadId: string,
610
+ attempt: string,
611
+ reason: string,
612
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
613
+ ): Promise<StrikeRecord> {
614
+ const existing = await storage.get(beadId);
615
+ const now = new Date().toISOString();
616
+
617
+ const record: StrikeRecord = existing ?? {
618
+ bead_id: beadId,
619
+ strike_count: 0,
620
+ failures: [],
621
+ };
622
+
623
+ record.strike_count = Math.min(3, record.strike_count + 1);
624
+ record.failures.push({ attempt, reason, timestamp: now });
625
+ record.last_strike_at = now;
626
+
627
+ if (!record.first_strike_at) {
628
+ record.first_strike_at = now;
629
+ }
630
+
631
+ await storage.store(record);
632
+ return record;
633
+ }
634
+
635
+ /**
636
+ * Get strike count for a bead
637
+ *
638
+ * @param beadId - Bead ID
639
+ * @param storage - Strike storage
640
+ * @returns Strike count (0-3)
641
+ */
642
+ export async function getStrikes(
643
+ beadId: string,
644
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
645
+ ): Promise<number> {
646
+ const record = await storage.get(beadId);
647
+ return record?.strike_count ?? 0;
648
+ }
649
+
650
+ /**
651
+ * Check if a bead has struck out (3 strikes)
652
+ *
653
+ * @param beadId - Bead ID
654
+ * @param storage - Strike storage
655
+ * @returns True if bead has 3 strikes
656
+ */
657
+ export async function isStrikedOut(
658
+ beadId: string,
659
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
660
+ ): Promise<boolean> {
661
+ const count = await getStrikes(beadId, storage);
662
+ return count >= 3;
663
+ }
664
+
665
+ /**
666
+ * Generate architecture review prompt for a struck-out bead
667
+ *
668
+ * When a bead hits 3 strikes, this generates a prompt that forces
669
+ * the human to question the architecture instead of attempting Fix #4.
670
+ *
671
+ * @param beadId - Bead ID
672
+ * @param storage - Strike storage
673
+ * @returns Architecture review prompt
674
+ */
675
+ export async function getArchitecturePrompt(
676
+ beadId: string,
677
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
678
+ ): Promise<string> {
679
+ const record = await storage.get(beadId);
680
+
681
+ if (!record || record.strike_count < 3) {
682
+ return "";
683
+ }
684
+
685
+ const failuresList = record.failures
686
+ .map((f, i) => `${i + 1}. **${f.attempt}** - Failed: ${f.reason}`)
687
+ .join("\n");
688
+
689
+ return `## Architecture Review Required
690
+
691
+ This bead (\`${beadId}\`) has failed 3 consecutive fix attempts:
692
+
693
+ ${failuresList}
694
+
695
+ This pattern suggests an **architectural problem**, not a bug.
696
+
697
+ **Questions to consider:**
698
+ - Is the current approach fundamentally sound?
699
+ - Should we refactor the architecture instead?
700
+ - Are we fixing symptoms instead of root cause?
701
+
702
+ **Options:**
703
+ 1. **Refactor architecture** (describe new approach)
704
+ 2. **Continue with Fix #4** (explain why this time is different)
705
+ 3. **Abandon this approach entirely**
706
+
707
+ **DO NOT attempt Fix #4 without answering these questions.**
708
+ `;
709
+ }
710
+
711
+ /**
712
+ * Clear strikes for a bead (e.g., after successful fix)
713
+ *
714
+ * @param beadId - Bead ID
715
+ * @param storage - Strike storage
716
+ */
717
+ export async function clearStrikes(
718
+ beadId: string,
719
+ storage: StrikeStorage = new InMemoryStrikeStorage(),
720
+ ): Promise<void> {
721
+ await storage.clear(beadId);
722
+ }
723
+
527
724
  // ============================================================================
528
725
  // Error Accumulator
529
726
  // ============================================================================
@@ -772,4 +969,5 @@ export const learningSchemas = {
772
969
  DecompositionStrategySchema,
773
970
  ErrorTypeSchema,
774
971
  ErrorEntrySchema,
972
+ StrikeRecordSchema,
775
973
  };
@@ -357,6 +357,200 @@ describe("ES module compatibility", () => {
357
357
  });
358
358
  });
359
359
 
360
+ // ============================================================================
361
+ // Tests: CSO Validation
362
+ // ============================================================================
363
+
364
+ import { validateCSOCompliance } from "./skills";
365
+
366
+ describe("validateCSOCompliance", () => {
367
+ describe("description validation", () => {
368
+ it("passes for CSO-compliant description with 'Use when'", () => {
369
+ const warnings = validateCSOCompliance(
370
+ "testing-async",
371
+ "Use when tests have race conditions - replaces arbitrary timeouts with condition polling",
372
+ );
373
+
374
+ expect(warnings.critical).toHaveLength(0);
375
+ expect(warnings.suggestions).toHaveLength(0);
376
+ });
377
+
378
+ it("warns when missing 'Use when...' pattern", () => {
379
+ const warnings = validateCSOCompliance(
380
+ "testing-async",
381
+ "For async testing patterns",
382
+ );
383
+
384
+ expect(warnings.critical).toContain(
385
+ "Description should include 'Use when...' to focus on triggering conditions",
386
+ );
387
+ });
388
+
389
+ it("warns for first-person voice", () => {
390
+ const warnings = validateCSOCompliance(
391
+ "testing-async",
392
+ "I can help you with async tests when I detect race conditions",
393
+ );
394
+
395
+ expect(warnings.critical.some((w) => w.includes("first-person"))).toBe(
396
+ true,
397
+ );
398
+ });
399
+
400
+ it("warns for second-person voice", () => {
401
+ const warnings = validateCSOCompliance(
402
+ "testing-async",
403
+ "Use when you need to test async code and your tests have race conditions",
404
+ );
405
+
406
+ expect(warnings.critical.some((w) => w.includes("second-person"))).toBe(
407
+ true,
408
+ );
409
+ });
410
+
411
+ it("rejects description > 1024 chars", () => {
412
+ const longDesc = "a".repeat(1025);
413
+ const warnings = validateCSOCompliance("test", longDesc);
414
+
415
+ expect(
416
+ warnings.critical.some(
417
+ (w) => w.includes("1025") && w.includes("max 1024"),
418
+ ),
419
+ ).toBe(true);
420
+ });
421
+
422
+ it("suggests improvement for description > 500 chars", () => {
423
+ const mediumDesc = "Use when testing. " + "a".repeat(490);
424
+ const warnings = validateCSOCompliance("test", mediumDesc);
425
+
426
+ expect(warnings.critical).toHaveLength(0); // Not critical
427
+ expect(warnings.suggestions.some((w) => w.includes("aim for <500"))).toBe(
428
+ true,
429
+ );
430
+ });
431
+
432
+ it("accepts description < 500 chars with no length warnings", () => {
433
+ const shortDesc =
434
+ "Use when tests have race conditions - replaces timeouts";
435
+ const warnings = validateCSOCompliance("testing-async", shortDesc);
436
+
437
+ const hasLengthWarning =
438
+ warnings.critical.some((w) => w.includes("chars")) ||
439
+ warnings.suggestions.some((w) => w.includes("chars"));
440
+
441
+ expect(hasLengthWarning).toBe(false);
442
+ });
443
+ });
444
+
445
+ describe("name validation", () => {
446
+ it("accepts gerund-based names", () => {
447
+ const warnings = validateCSOCompliance(
448
+ "testing-async",
449
+ "Use when testing async code",
450
+ );
451
+
452
+ const hasNameWarning = warnings.suggestions.some((w) =>
453
+ w.includes("verb-first"),
454
+ );
455
+ expect(hasNameWarning).toBe(false);
456
+ });
457
+
458
+ it("accepts verb-first names", () => {
459
+ const warnings = validateCSOCompliance(
460
+ "validate-schemas",
461
+ "Use when validating schemas",
462
+ );
463
+
464
+ const hasNameWarning = warnings.suggestions.some((w) =>
465
+ w.includes("verb-first"),
466
+ );
467
+ expect(hasNameWarning).toBe(false);
468
+ });
469
+
470
+ it("accepts action verbs", () => {
471
+ const actionVerbs = [
472
+ "test-runner",
473
+ "debug-tools",
474
+ "scan-code",
475
+ "check-types",
476
+ "build-artifacts",
477
+ ];
478
+
479
+ for (const name of actionVerbs) {
480
+ const warnings = validateCSOCompliance(name, "Use when testing");
481
+ const hasNameWarning = warnings.suggestions.some((w) =>
482
+ w.includes("verb-first"),
483
+ );
484
+ expect(hasNameWarning).toBe(false);
485
+ }
486
+ });
487
+
488
+ it("suggests verb-first for noun-first names", () => {
489
+ const warnings = validateCSOCompliance(
490
+ "async-test",
491
+ "Use when testing async",
492
+ );
493
+
494
+ expect(
495
+ warnings.suggestions.some((w) =>
496
+ w.includes("doesn't follow verb-first"),
497
+ ),
498
+ ).toBe(true);
499
+ });
500
+
501
+ it("warns for name > 64 chars", () => {
502
+ const longName = "a".repeat(65);
503
+ const warnings = validateCSOCompliance(longName, "Use when testing");
504
+
505
+ expect(warnings.critical.some((w) => w.includes("64 character"))).toBe(
506
+ true,
507
+ );
508
+ });
509
+
510
+ it("warns for invalid name format", () => {
511
+ const warnings = validateCSOCompliance(
512
+ "Invalid_Name",
513
+ "Use when testing",
514
+ );
515
+
516
+ expect(
517
+ warnings.critical.some((w) =>
518
+ w.includes("lowercase letters, numbers, and hyphens"),
519
+ ),
520
+ ).toBe(true);
521
+ });
522
+ });
523
+
524
+ describe("comprehensive examples", () => {
525
+ it("perfect CSO compliance", () => {
526
+ const warnings = validateCSOCompliance(
527
+ "testing-race-conditions",
528
+ "Use when tests have race conditions - replaces arbitrary timeouts with condition polling and retry logic",
529
+ );
530
+
531
+ expect(warnings.critical).toHaveLength(0);
532
+ expect(warnings.suggestions).toHaveLength(0);
533
+ });
534
+
535
+ it("multiple critical issues", () => {
536
+ const warnings = validateCSOCompliance(
537
+ "BadName_123",
538
+ "I can help you test async code when you need to avoid race conditions. " +
539
+ "a".repeat(1000),
540
+ );
541
+
542
+ expect(warnings.critical.length).toBeGreaterThan(2);
543
+ expect(warnings.critical.some((w) => w.includes("first-person"))).toBe(
544
+ true,
545
+ );
546
+ expect(warnings.critical.some((w) => w.includes("second-person"))).toBe(
547
+ true,
548
+ );
549
+ expect(warnings.critical.some((w) => w.includes("lowercase"))).toBe(true);
550
+ });
551
+ });
552
+ });
553
+
360
554
  // ============================================================================
361
555
  // Tests: Edge Cases
362
556
  // ============================================================================
package/src/skills.ts CHANGED
@@ -628,6 +628,167 @@ Use this to access supplementary skill resources.`,
628
628
  */
629
629
  const DEFAULT_SKILLS_DIR = ".opencode/skills";
630
630
 
631
+ // =============================================================================
632
+ // CSO (Claude Search Optimization) Validation
633
+ // =============================================================================
634
+
635
+ /**
636
+ * CSO validation warnings for skill metadata
637
+ */
638
+ export interface CSOValidationWarnings {
639
+ /** Critical warnings (strong indicators of poor discoverability) */
640
+ critical: string[];
641
+ /** Suggestions for improvement */
642
+ suggestions: string[];
643
+ }
644
+
645
+ /**
646
+ * Validate skill metadata against Claude Search Optimization best practices
647
+ *
648
+ * Checks:
649
+ * - 'Use when...' format in description
650
+ * - Description length (warn > 500, max 1024)
651
+ * - Third-person voice (no 'I', 'you')
652
+ * - Name conventions (verb-first, gerunds, hyphens)
653
+ *
654
+ * @returns Warnings object with critical issues and suggestions
655
+ */
656
+ export function validateCSOCompliance(
657
+ name: string,
658
+ description: string,
659
+ ): CSOValidationWarnings {
660
+ const warnings: CSOValidationWarnings = {
661
+ critical: [],
662
+ suggestions: [],
663
+ };
664
+
665
+ // Description: Check for 'Use when...' pattern
666
+ const hasUseWhen = /\buse when\b/i.test(description);
667
+ if (!hasUseWhen) {
668
+ warnings.critical.push(
669
+ "Description should include 'Use when...' to focus on triggering conditions",
670
+ );
671
+ }
672
+
673
+ // Description: Length checks
674
+ if (description.length > 1024) {
675
+ warnings.critical.push(
676
+ `Description is ${description.length} chars (max 1024) - will be rejected`,
677
+ );
678
+ } else if (description.length > 500) {
679
+ warnings.suggestions.push(
680
+ `Description is ${description.length} chars (aim for <500 for optimal discoverability)`,
681
+ );
682
+ }
683
+
684
+ // Description: Third-person check (no 'I', 'you')
685
+ const firstPersonPattern = /\b(I|I'm|I'll|my|mine|myself)\b/i;
686
+ const secondPersonPattern = /\b(you|you're|you'll|your|yours|yourself)\b/i;
687
+
688
+ if (firstPersonPattern.test(description)) {
689
+ warnings.critical.push(
690
+ "Description uses first-person ('I', 'my') - skills are injected into system prompt, use third-person only",
691
+ );
692
+ }
693
+
694
+ if (secondPersonPattern.test(description)) {
695
+ warnings.critical.push(
696
+ "Description uses second-person ('you', 'your') - use third-person voice (e.g., 'Handles X' not 'You can handle X')",
697
+ );
698
+ }
699
+
700
+ // Name: Check for verb-first/gerund patterns
701
+ const nameWords = name.split("-");
702
+ const firstWord = nameWords[0];
703
+
704
+ // Common gerund endings: -ing
705
+ // Common verb forms: -ing, -ize, -ify, -ate
706
+ const isGerund = /ing$/.test(firstWord);
707
+ const isVerbForm = /(ing|ize|ify|ate)$/.test(firstWord);
708
+
709
+ if (!isGerund && !isVerbForm) {
710
+ // Check if it's a common action verb
711
+ const actionVerbs = [
712
+ "test",
713
+ "debug",
714
+ "fix",
715
+ "scan",
716
+ "check",
717
+ "validate",
718
+ "create",
719
+ "build",
720
+ "deploy",
721
+ "run",
722
+ "load",
723
+ "fetch",
724
+ "parse",
725
+ ];
726
+ const startsWithAction = actionVerbs.includes(firstWord);
727
+
728
+ if (!startsWithAction) {
729
+ warnings.suggestions.push(
730
+ `Name '${name}' doesn't follow verb-first pattern. Consider gerunds (e.g., 'testing-skills' not 'test-skill') or action verbs for better clarity`,
731
+ );
732
+ }
733
+ }
734
+
735
+ // Name: Check length
736
+ if (name.length > 64) {
737
+ warnings.critical.push(
738
+ `Name exceeds 64 character limit (${name.length} chars)`,
739
+ );
740
+ }
741
+
742
+ // Name: Validate format (already enforced by schema, but good to document)
743
+ if (!/^[a-z0-9-]+$/.test(name)) {
744
+ warnings.critical.push(
745
+ "Name must be lowercase letters, numbers, and hyphens only",
746
+ );
747
+ }
748
+
749
+ return warnings;
750
+ }
751
+
752
+ /**
753
+ * Format CSO warnings into a readable message for tool output
754
+ */
755
+ function formatCSOWarnings(warnings: CSOValidationWarnings): string | null {
756
+ if (warnings.critical.length === 0 && warnings.suggestions.length === 0) {
757
+ return null;
758
+ }
759
+
760
+ const parts: string[] = [];
761
+
762
+ if (warnings.critical.length > 0) {
763
+ parts.push("**CSO Critical Issues:**");
764
+ for (const warning of warnings.critical) {
765
+ parts.push(` ⚠️ ${warning}`);
766
+ }
767
+ }
768
+
769
+ if (warnings.suggestions.length > 0) {
770
+ parts.push("\n**CSO Suggestions:**");
771
+ for (const suggestion of warnings.suggestions) {
772
+ parts.push(` 💡 ${suggestion}`);
773
+ }
774
+ }
775
+
776
+ parts.push("\n**CSO Guide:**");
777
+ parts.push(
778
+ " • Start description with 'Use when...' (focus on triggering conditions)",
779
+ );
780
+ parts.push(" • Keep description <500 chars (max 1024)");
781
+ parts.push(" • Use third-person voice only (injected into system prompt)");
782
+ parts.push(
783
+ " • Name: verb-first or gerunds (e.g., 'testing-async' not 'async-test')",
784
+ );
785
+ parts.push(
786
+ "\n Example: 'Use when tests have race conditions - replaces arbitrary timeouts with condition polling'",
787
+ );
788
+
789
+ return parts.join("\n");
790
+ }
791
+
631
792
  /**
632
793
  * Quote a YAML scalar if it contains special characters
633
794
  * Uses double quotes and escapes internal quotes/newlines
@@ -749,6 +910,9 @@ Good skills have:
749
910
  return `Skill '${args.name}' already exists at ${existing.path}. Use skills_update to modify it.`;
750
911
  }
751
912
 
913
+ // Validate CSO compliance (advisory warnings only)
914
+ const csoWarnings = validateCSOCompliance(args.name, args.description);
915
+
752
916
  // Determine target directory
753
917
  let skillDir: string;
754
918
  if (args.directory === "global") {
@@ -778,21 +942,26 @@ Good skills have:
778
942
  // Invalidate cache so new skill is discoverable
779
943
  invalidateSkillsCache();
780
944
 
781
- return JSON.stringify(
782
- {
783
- success: true,
784
- skill: args.name,
785
- path: skillPath,
786
- message: `Created skill '${args.name}'. It's now discoverable via skills_list.`,
787
- next_steps: [
788
- "Test with skills_use to verify instructions are clear",
789
- "Add examples.md or reference.md for supplementary content",
790
- "Add scripts/ directory for executable helpers",
791
- ],
792
- },
793
- null,
794
- 2,
795
- );
945
+ // Build response with CSO warnings if present
946
+ const response: Record<string, unknown> = {
947
+ success: true,
948
+ skill: args.name,
949
+ path: skillPath,
950
+ message: `Created skill '${args.name}'. It's now discoverable via skills_list.`,
951
+ next_steps: [
952
+ "Test with skills_use to verify instructions are clear",
953
+ "Add examples.md or reference.md for supplementary content",
954
+ "Add scripts/ directory for executable helpers",
955
+ ],
956
+ };
957
+
958
+ // Add CSO warnings if any
959
+ const warningsMessage = formatCSOWarnings(csoWarnings);
960
+ if (warningsMessage) {
961
+ response.cso_warnings = warningsMessage;
962
+ }
963
+
964
+ return JSON.stringify(response, null, 2);
796
965
  } catch (error) {
797
966
  return `Failed to create skill: ${error instanceof Error ? error.message : String(error)}`;
798
967
  }
@@ -1231,10 +1231,10 @@ describe("Graceful Degradation", () => {
1231
1231
  mockContext,
1232
1232
  );
1233
1233
 
1234
- // Check that agent-mail discipline is in the prompt
1234
+ // Check that swarm-mail discipline is in the prompt
1235
1235
  expect(result).toContain("MANDATORY");
1236
- expect(result).toContain("Agent Mail");
1237
- expect(result).toContain("agentmail_send");
1236
+ expect(result).toContain("Swarm Mail");
1237
+ expect(result).toContain("swarmmail_send");
1238
1238
  expect(result).toContain("Report progress");
1239
1239
  });
1240
1240
  });
@@ -1243,7 +1243,7 @@ describe("Graceful Degradation", () => {
1243
1243
  // Coordinator-Centric Swarm Tools (V2)
1244
1244
  // ============================================================================
1245
1245
 
1246
- describe("Swarm Prompt V2 (with Agent Mail/Beads)", () => {
1246
+ describe("Swarm Prompt V2 (with Swarm Mail/Beads)", () => {
1247
1247
  describe("formatSubtaskPromptV2", () => {
1248
1248
  it("generates correct prompt with all fields", () => {
1249
1249
  const result = formatSubtaskPromptV2({