@maestria/opencode 0.3.11 → 0.4.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.
@@ -94,6 +94,36 @@ These apply on every invocation without exception:
94
94
  - `ci`: CI/CD changes
95
95
  - `test`: Test additions or changes
96
96
 
97
+ ## Workflow Mode Override
98
+
99
+ Modes override the default delegation pipeline. A mode keyword in your
100
+ message activates the corresponding workflow for that turn only. The
101
+ keyword is stripped before processing. Detection is case-insensitive.
102
+ When detected, the hook injects `[MODE: fein]` at the front of your message.
103
+
104
+ | Mode | Pipeline | When to use |
105
+ | ------- | --------------------------------------------------------------------------------------- | ---------------------------------------- |
106
+ | `fein` | `@adventurer` → `@architect`/`@planner` → `@builder` → `@reviewer` | Production-grade, non-trivial changes |
107
+ | `sonar` | `@adventurer` → `@architect`/`@planner` → STOP | Discovery, research, feasibility |
108
+ | `blitz` | `@builder` directly — skip recon/design/review unless the codebase is genuinely unknown | Quick fixes, prototypes, known territory |
109
+
110
+ ### Precedence
111
+
112
+ 1. If the mode marker is present, it overrides any conflicting intent
113
+ inferred from trigger phrases. For example, `"fein fix this bug"`
114
+ runs the full pipeline, not just `@diagnose`.
115
+ 2. If no mode is present, the normal trigger-phrase matching applies
116
+ (see **Trigger phrases** below).
117
+ 3. Mode is per-turn — each message independently activates its own
118
+ mode. Conversation history (subagent handoffs) tracks progress across
119
+ turns.
120
+
121
+ ### Deactivated modes
122
+
123
+ If a mode keyword is disabled by the user's plugin config, it passes
124
+ through as plain text — no mode logic applies. The orchestrator
125
+ behaves as if no mode was specified.
126
+
97
127
  ## Available Specialists
98
128
 
99
129
  **Delegate to these specialists only — they are built-in agents for direct use, not for delegation.**
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@ import { readFileSync, readdirSync } from "fs";
2
2
  import { join, dirname, basename } from "path";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import { fileURLToPath } from "url";
5
+ import { maestriaOptionsSchema } from "./modes/types.js";
6
+ import { detectMode, stripKeyword, getModeMarker, getModePrompt } from "./modes/index.js";
5
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
8
  const agentsDir = join(__dirname, "..", "agents");
7
9
  const rulesPath = join(__dirname, "..", "rules", "AGENTS.md");
@@ -52,7 +54,10 @@ function loadAgents() {
52
54
  }
53
55
  return agents;
54
56
  }
55
- export const MaestriaPlugin = async () => {
57
+ export const MaestriaPlugin = async (_input, options) => {
58
+ // Validate and parse options with zod
59
+ const parsed = maestriaOptionsSchema.parse(options ?? {});
60
+ const disabledKeywords = new Set((parsed.modes?.disabledKeywords ?? []).map((k) => k.toLowerCase()));
56
61
  const agents = loadAgents();
57
62
  return {
58
63
  config: async (input) => {
@@ -67,6 +72,30 @@ export const MaestriaPlugin = async () => {
67
72
  "Active context (files, decisions, blockers) was captured before compaction. " +
68
73
  "Continue where you left off.");
69
74
  },
75
+ "chat.message": async (hookInput, hookOutput) => {
76
+ // Only fire for the orchestrator agent
77
+ if (hookInput.agent !== "orchestrator")
78
+ return;
79
+ // Find the first text part with user content
80
+ const textPart = hookOutput.parts.find((p) => p.type === "text");
81
+ if (!textPart)
82
+ return;
83
+ // Detect keyword in the text
84
+ const result = detectMode(textPart.text, disabledKeywords);
85
+ if (!result)
86
+ return;
87
+ // Strip keyword from text
88
+ textPart.text = stripKeyword(textPart.text, result);
89
+ // Inject mode marker + prompt at the front of parts
90
+ hookOutput.parts.unshift({
91
+ id: crypto.randomUUID(),
92
+ sessionID: hookInput.sessionID,
93
+ messageID: hookOutput.message.id,
94
+ type: "text",
95
+ text: [getModeMarker(result.mode), "", getModePrompt(result.mode)].join("\n"),
96
+ synthetic: true,
97
+ });
98
+ },
70
99
  };
71
100
  };
72
101
  export default MaestriaPlugin;
@@ -0,0 +1,39 @@
1
+ import type { ModeResult } from "./types.js";
2
+ /**
3
+ * Detect a workflow mode keyword in the given text.
4
+ *
5
+ * Detection rules (per ADR-008):
6
+ * - Word-boundary regex matching (`\bfein\b`, `\bsonar\b`, `\bblitz\b`)
7
+ * - Most restrictive match wins (fein > sonar > blitz)
8
+ * - Case-insensitive
9
+ * - Disabled keywords are ignored
10
+ * - Matches inside fenced code blocks (```) and inline backticks (`) are ignored
11
+ *
12
+ * @param text The user message to scan.
13
+ * @param disabled Optional set of disabled mode keywords (lowercase).
14
+ * @returns A `ModeResult` if a keyword was detected, or `null`.
15
+ */
16
+ export declare function detectMode(text: string, disabled?: Set<string>): ModeResult | null;
17
+ /**
18
+ * Remove the matched keyword from the text, cleaning up any trailing colon
19
+ * or whitespace that may follow it.
20
+ *
21
+ * @param text The original message text.
22
+ * @param result The `ModeResult` from `detectMode()`.
23
+ * @returns The text with the keyword stripped.
24
+ */
25
+ export declare function stripKeyword(text: string, result: ModeResult): string;
26
+ /**
27
+ * Get the mode prompt text for a given mode name.
28
+ *
29
+ * @param mode The mode keyword (e.g. "fein", "sonar", "blitz").
30
+ * @returns The prompt string, or empty string if mode is unknown.
31
+ */
32
+ export declare function getModePrompt(mode: string): string;
33
+ /**
34
+ * Get the mode marker string for a given mode name.
35
+ *
36
+ * @param mode The mode keyword (e.g. "fein", "sonar", "blitz").
37
+ * @returns The marker string (e.g. `[MODE: fein]`), or empty string if unknown.
38
+ */
39
+ export declare function getModeMarker(mode: string): string;
@@ -0,0 +1,144 @@
1
+ import { escapeRegExp } from "es-toolkit";
2
+ import { MODE_PROMPTS, MODE_MARKERS, VALID_KEYWORDS } from "./prompts.js";
3
+ /**
4
+ * Priority mapping for mode keyword restrictiveness.
5
+ * Higher number = more restrictive = wins when multiple keywords are present.
6
+ * fein (3): full pipeline with mandatory gates
7
+ * sonar (2): research only, no code
8
+ * blitz (1): fast implementation, skip all gates
9
+ */
10
+ const MODE_PRIORITY = {
11
+ fein: 3,
12
+ sonar: 2,
13
+ blitz: 1,
14
+ };
15
+ /**
16
+ * Regex matching fenced code blocks (```) and inline backtick spans (`).
17
+ * Used to exclude keyword matches inside code spans.
18
+ */
19
+ // Note: Unclosed fenced code blocks (``` without closing ```) are not
20
+ // excluded — the regex requires matching fences. This is an accepted
21
+ // false-positive risk (see ADR-008 consequences).
22
+ const CODE_BLOCK_RE = /```[\s\S]*?```|`[^`]*`/g;
23
+ /**
24
+ * Find ranges of code blocks and inline code spans in text.
25
+ * Returns [start, end) positions. Keywords inside these ranges
26
+ * are ignored during detection.
27
+ */
28
+ function findAllCodeBlockRanges(text) {
29
+ const ranges = [];
30
+ let match;
31
+ while ((match = CODE_BLOCK_RE.exec(text)) !== null) {
32
+ ranges.push([match.index, match.index + match[0].length]);
33
+ }
34
+ return ranges;
35
+ }
36
+ function isInRanges(index, ranges) {
37
+ return ranges.some(([start, end]) => index >= start && index < end);
38
+ }
39
+ /**
40
+ * Build a regex pattern for word-boundary matching of the given keyword.
41
+ *
42
+ * The pattern uses `\b` word boundaries to ensure we match whole words only,
43
+ * and is case-insensitive so `Fein`, `FEIN`, `fein` all match.
44
+ */
45
+ function buildKeywordRegex(keyword) {
46
+ return new RegExp(`\\b${escapeRegExp(keyword)}\\b`, "gi");
47
+ }
48
+ /**
49
+ * Detect a workflow mode keyword in the given text.
50
+ *
51
+ * Detection rules (per ADR-008):
52
+ * - Word-boundary regex matching (`\bfein\b`, `\bsonar\b`, `\bblitz\b`)
53
+ * - Most restrictive match wins (fein > sonar > blitz)
54
+ * - Case-insensitive
55
+ * - Disabled keywords are ignored
56
+ * - Matches inside fenced code blocks (```) and inline backticks (`) are ignored
57
+ *
58
+ * @param text The user message to scan.
59
+ * @param disabled Optional set of disabled mode keywords (lowercase).
60
+ * @returns A `ModeResult` if a keyword was detected, or `null`.
61
+ */
62
+ export function detectMode(text, disabled) {
63
+ const codeRanges = findAllCodeBlockRanges(text);
64
+ // Normalize disabled keywords to lowercase for case-insensitive comparison
65
+ const normalizedDisabled = disabled
66
+ ? new Set(Array.from(disabled).map((k) => k.toLowerCase()))
67
+ : undefined;
68
+ let bestMatch = null;
69
+ for (const keyword of VALID_KEYWORDS) {
70
+ if (normalizedDisabled?.has(keyword))
71
+ continue;
72
+ const regex = buildKeywordRegex(keyword);
73
+ let match;
74
+ while ((match = regex.exec(text)) !== null) {
75
+ if (isInRanges(match.index, codeRanges))
76
+ continue;
77
+ // Most-restrictive wins: prefer higher-priority mode over position
78
+ if (bestMatch === null || MODE_PRIORITY[keyword] > MODE_PRIORITY[bestMatch.mode]) {
79
+ bestMatch = {
80
+ keyword: match[0],
81
+ index: match.index,
82
+ mode: keyword,
83
+ };
84
+ }
85
+ }
86
+ }
87
+ if (bestMatch === null)
88
+ return null;
89
+ return {
90
+ mode: bestMatch.mode,
91
+ keyword: bestMatch.keyword,
92
+ index: bestMatch.index,
93
+ prompt: MODE_PROMPTS[bestMatch.mode],
94
+ marker: MODE_MARKERS[bestMatch.mode],
95
+ };
96
+ }
97
+ /**
98
+ * Remove the matched keyword from the text, cleaning up any trailing colon
99
+ * or whitespace that may follow it.
100
+ *
101
+ * @param text The original message text.
102
+ * @param result The `ModeResult` from `detectMode()`.
103
+ * @returns The text with the keyword stripped.
104
+ */
105
+ export function stripKeyword(text, result) {
106
+ const before = text.slice(0, result.index);
107
+ const after = text.slice(result.index + result.keyword.length);
108
+ // Remove any colon + optional whitespace after the keyword
109
+ // (e.g. "fein: do this" -> "do this")
110
+ const cleaned = after.replace(/^:\s*/, "");
111
+ // Collapse double spaces and trim both ends (handles keyword at start,
112
+ // end, or middle of text, plus extra whitespace around colon)
113
+ return (before + cleaned).replace(/\s{2,}/g, " ").trim();
114
+ }
115
+ /**
116
+ * Get the mode prompt text for a given mode name.
117
+ *
118
+ * @param mode The mode keyword (e.g. "fein", "sonar", "blitz").
119
+ * @returns The prompt string, or empty string if mode is unknown.
120
+ */
121
+ export function getModePrompt(mode) {
122
+ if (isModeKeyword(mode)) {
123
+ return MODE_PROMPTS[mode];
124
+ }
125
+ return "";
126
+ }
127
+ /**
128
+ * Get the mode marker string for a given mode name.
129
+ *
130
+ * @param mode The mode keyword (e.g. "fein", "sonar", "blitz").
131
+ * @returns The marker string (e.g. `[MODE: fein]`), or empty string if unknown.
132
+ */
133
+ export function getModeMarker(mode) {
134
+ if (isModeKeyword(mode)) {
135
+ return MODE_MARKERS[mode];
136
+ }
137
+ return "";
138
+ }
139
+ /**
140
+ * Type guard to check if a string is a valid ModeKeyword.
141
+ */
142
+ function isModeKeyword(value) {
143
+ return VALID_KEYWORDS.includes(value);
144
+ }
@@ -0,0 +1,17 @@
1
+ import type { ModeKeyword } from "./types.js";
2
+ /**
3
+ * Mode prompt text for each keyword.
4
+ * These are injected into the turn when a mode is detected.
5
+ *
6
+ * @see ADR-008 (section "Mode Prompts")
7
+ */
8
+ export declare const MODE_PROMPTS: Record<ModeKeyword, string>;
9
+ /**
10
+ * Marker strings for each mode keyword, used to signal the active mode.
11
+ * Format: `[MODE: <keyword>]`
12
+ */
13
+ export declare const MODE_MARKERS: Record<ModeKeyword, string>;
14
+ /**
15
+ * Array of all valid mode keywords for runtime iteration.
16
+ */
17
+ export declare const VALID_KEYWORDS: readonly ModeKeyword[];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Mode prompt text for each keyword.
3
+ * These are injected into the turn when a mode is detected.
4
+ *
5
+ * @see ADR-008 (section "Mode Prompts")
6
+ */
7
+ export const MODE_PROMPTS = {
8
+ fein: [
9
+ "## MODE: fein (Full Pipeline)",
10
+ "",
11
+ "Execute the complete fein pipeline: mandatory reconnaissance",
12
+ "(@adventurer) → design/plan (@architect or @planner) →",
13
+ "implementation (@builder) → review (@reviewer).",
14
+ "Do NOT skip any phase unless the user explicitly overrides",
15
+ "in the same turn.",
16
+ ].join("\n"),
17
+ sonar: [
18
+ "## MODE: sonar (Research Only)",
19
+ "",
20
+ "Research mode: reconnaissance and design only. Delegate to",
21
+ "@adventurer (recon) followed by @architect or @planner",
22
+ "(analysis/design). STOP after delivering findings and design.",
23
+ "Do NOT implement, write code, or create any production files.",
24
+ ].join("\n"),
25
+ blitz: [
26
+ "## MODE: blitz (Fast Implementation)",
27
+ "",
28
+ "Speed mode: skip reconnaissance and design gates. Go directly",
29
+ "to @builder for implementation. Only use @adventurer if the",
30
+ "codebase context is genuinely unknown (not as a default step).",
31
+ "Skip @reviewer unless the user explicitly requests review.",
32
+ ].join("\n"),
33
+ };
34
+ /**
35
+ * Marker strings for each mode keyword, used to signal the active mode.
36
+ * Format: `[MODE: <keyword>]`
37
+ */
38
+ export const MODE_MARKERS = {
39
+ fein: "[MODE: fein]",
40
+ sonar: "[MODE: sonar]",
41
+ blitz: "[MODE: blitz]",
42
+ };
43
+ /**
44
+ * Array of all valid mode keywords for runtime iteration.
45
+ */
46
+ export const VALID_KEYWORDS = ["fein", "sonar", "blitz"];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Types for keyword-triggered workflow modes.
3
+ *
4
+ * @see ADR-008 for full design context.
5
+ */
6
+ import { z } from "zod";
7
+ /**
8
+ * Valid mode keywords.
9
+ *
10
+ * - `"fein"` -- Full pipeline (recon -> design -> build -> review)
11
+ * - `"sonar"` -- Research only (recon + design, stop before build)
12
+ * - `"blitz"` -- Fast implementation (builder direct, skip recon/design/review)
13
+ */
14
+ export declare const modeKeywordSchema: z.ZodEnum<["fein", "sonar", "blitz"]>;
15
+ export type ModeKeyword = z.infer<typeof modeKeywordSchema>;
16
+ /**
17
+ * Plugin-level options for @maestria/opencode.
18
+ */
19
+ export declare const maestriaOptionsSchema: z.ZodObject<{
20
+ modes: z.ZodOptional<z.ZodObject<{
21
+ disabledKeywords: z.ZodOptional<z.ZodArray<z.ZodEnum<["fein", "sonar", "blitz"]>, "many">>;
22
+ }, "strip", z.ZodTypeAny, {
23
+ disabledKeywords?: ("fein" | "sonar" | "blitz")[] | undefined;
24
+ }, {
25
+ disabledKeywords?: ("fein" | "sonar" | "blitz")[] | undefined;
26
+ }>>;
27
+ }, "strip", z.ZodTypeAny, {
28
+ modes?: {
29
+ disabledKeywords?: ("fein" | "sonar" | "blitz")[] | undefined;
30
+ } | undefined;
31
+ }, {
32
+ modes?: {
33
+ disabledKeywords?: ("fein" | "sonar" | "blitz")[] | undefined;
34
+ } | undefined;
35
+ }>;
36
+ export type MaestriaPluginOptions = z.infer<typeof maestriaOptionsSchema>;
37
+ /**
38
+ * Result returned when a mode keyword is detected in a message.
39
+ */
40
+ export interface ModeResult {
41
+ /** The resolved mode keyword (lowercase). */
42
+ mode: ModeKeyword;
43
+ /** The keyword string as matched in the original text. */
44
+ keyword: string;
45
+ /** The character index where the keyword starts in the original text. */
46
+ index: number;
47
+ /** The mode prompt text to inject. */
48
+ prompt: string;
49
+ /** The mode marker string like `[MODE: fein]`. */
50
+ marker: string;
51
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Types for keyword-triggered workflow modes.
3
+ *
4
+ * @see ADR-008 for full design context.
5
+ */
6
+ import { z } from "zod";
7
+ /**
8
+ * Valid mode keywords.
9
+ *
10
+ * - `"fein"` -- Full pipeline (recon -> design -> build -> review)
11
+ * - `"sonar"` -- Research only (recon + design, stop before build)
12
+ * - `"blitz"` -- Fast implementation (builder direct, skip recon/design/review)
13
+ */
14
+ export const modeKeywordSchema = z.enum(["fein", "sonar", "blitz"]);
15
+ /**
16
+ * Plugin-level options for @maestria/opencode.
17
+ */
18
+ export const maestriaOptionsSchema = z.object({
19
+ modes: z
20
+ .object({
21
+ disabledKeywords: z.array(modeKeywordSchema).optional(),
22
+ })
23
+ .optional(),
24
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maestria/opencode",
3
- "version": "0.3.11",
3
+ "version": "0.4.1",
4
4
  "description": "OpenCode plugin encoding AI engineering praxis: rules, agents, and workflow discipline.",
5
5
  "keywords": [
6
6
  "agents",
@@ -40,18 +40,21 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@opencode-ai/plugin": "^1.17.0",
43
- "yaml": "^2.7.0"
43
+ "es-toolkit": "^1.47.1",
44
+ "yaml": "^2.7.0",
45
+ "zod": "^3.24.0"
44
46
  },
45
47
  "devDependencies": {
46
48
  "@types/node": "^24",
47
49
  "commit-and-tag-version": "^12.7.3",
48
- "typescript": "^5"
50
+ "typescript": "^5",
51
+ "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24"
49
52
  },
50
53
  "engines": {
51
54
  "node": ">=22.12.0"
52
55
  },
53
56
  "scripts": {
54
- "build": "tsc",
55
- "test": "vp test"
57
+ "build": "tsc && bash scripts/verify-imports.sh",
58
+ "test": "node ./node_modules/vitest/vitest.mjs run"
56
59
  }
57
60
  }
package/rules/AGENTS.md CHANGED
@@ -7,7 +7,7 @@
7
7
  - **!!! Read the docs first** — before writing code that touches
8
8
  unfamiliar tools, APIs, or migration paths, consult official
9
9
  documentation. Don't guess at API changes. This rule is scar
10
- tissue from repeated failures, not a preference.
10
+ tissue from repeated failures; treat it seriously.
11
11
  - **Don't reference internal project names in explanations** — avoid
12
12
  leaking context outside the workspace.
13
13
  - **Use `opensrc` for repos; `webfetch` for pages** — when analyzing a
@@ -19,6 +19,9 @@
19
19
  time — clone it once, then read locally. Use `--cwd` to resolve
20
20
  versions from the current project.
21
21
  - **Webfetch may hang — don't block on it** — if a `webfetch` request hangs after you've issued it, **proceed without the result** and surface the skip in your next user-facing message. Don't wait for a hung fetch to complete.
22
+ - **Workflow modes** — keywords `fein` (full pipeline), `sonar` (research only),
23
+ `blitz` (fast impl) activate per-turn workflow overrides. See the
24
+ orchestrator prompt for details.
22
25
  - **CLI references — use local tools first** — for CLI references, run `bash --help` or load the relevant `skill` instead of reaching for `webfetch`. Local tools are faster and more reliable than fetching docs.
23
26
  - **Local files — read directly** — use `read`, `glob`, or `grep` (or `lsp` when available) for any file you have path access to. Don't `webfetch` a local file or a file in a checked-out repo.
24
27
  - **Tool hierarchy for external information:**