impulso 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +425 -0
  2. package/agents/ImpulsoOrchestrator.md +112 -0
  3. package/agents/debugger.md +38 -0
  4. package/agents/generalist.md +26 -0
  5. package/agents/implementer-frontend.md +41 -0
  6. package/agents/implementer-infra.md +41 -0
  7. package/agents/implementer.md +35 -0
  8. package/agents/planner.md +52 -0
  9. package/agents/reviewer.md +40 -0
  10. package/agents/scout-writer.md +24 -0
  11. package/agents/scout.md +22 -0
  12. package/agents/shipper.md +64 -0
  13. package/commands/impulso-address-pr-comments.md +162 -0
  14. package/commands/impulso-brainstorm.md +54 -0
  15. package/commands/impulso-context-pack.md +55 -0
  16. package/commands/impulso-debug.md +50 -0
  17. package/commands/impulso-directspeech-compress.md +137 -0
  18. package/commands/impulso-directspeech-help.md +33 -0
  19. package/commands/impulso-directspeech-stats.md +16 -0
  20. package/commands/impulso-implement.md +86 -0
  21. package/commands/impulso-plan.md +92 -0
  22. package/commands/impulso-ship.md +46 -0
  23. package/commands/impulso-spec.md +106 -0
  24. package/commands/impulso-verify.md +99 -0
  25. package/commands/plannotator-annotate.md +47 -0
  26. package/commands/plannotator-last.md +45 -0
  27. package/commands/plannotator-review.md +46 -0
  28. package/harnesses/claude/README.md +109 -0
  29. package/harnesses/claude/hooks/bash-state-guard.cjs +84 -0
  30. package/harnesses/claude/hooks/directspeech-context.cjs +29 -0
  31. package/harnesses/claude/hooks/path-guard.cjs +106 -0
  32. package/harnesses/claude/hooks/preflight.cjs +39 -0
  33. package/harnesses/claude/hooks/pretooluse-bash.cjs +69 -0
  34. package/harnesses/claude/hooks/rtk.cjs +87 -0
  35. package/harnesses/claude/hooks/subagent-start.cjs +83 -0
  36. package/harnesses/claude/hooks/task-exit.cjs +189 -0
  37. package/harnesses/claude/hooks/tdd-observer.cjs +118 -0
  38. package/harnesses/claude/hooks/tokensave-stale.cjs +63 -0
  39. package/harnesses/claude/hooks/userpromptsubmit.cjs +42 -0
  40. package/harnesses/claude/install.sh +120 -0
  41. package/harnesses/claude/merge-settings.cjs +71 -0
  42. package/harnesses/claude/uninstall.sh +89 -0
  43. package/harnesses/opencode/bin/darwin-amd64/impulso-hub +0 -0
  44. package/harnesses/opencode/bin/darwin-arm64/impulso-hub +0 -0
  45. package/harnesses/opencode/bin/impulso-hub +0 -0
  46. package/harnesses/opencode/bin/linux-amd64/impulso-hub +0 -0
  47. package/harnesses/opencode/bin/linux-arm64/impulso-hub +0 -0
  48. package/harnesses/opencode/commands-loader.js +101 -0
  49. package/harnesses/opencode/compose.js +111 -0
  50. package/harnesses/opencode/directspeech/README.md +43 -0
  51. package/harnesses/opencode/directspeech/plugin.js +47 -0
  52. package/harnesses/opencode/frontmatter.js +60 -0
  53. package/harnesses/opencode/install.sh +121 -0
  54. package/harnesses/opencode/orchestration/index.js +362 -0
  55. package/harnesses/opencode/plugin.js +84 -0
  56. package/harnesses/opencode/rtk/README.md +100 -0
  57. package/harnesses/opencode/rtk/rtk.ts +41 -0
  58. package/harnesses/opencode/session-name/plugin.js +27 -0
  59. package/harnesses/opencode/sync-commands.js +98 -0
  60. package/harnesses/opencode/uninstall.sh +36 -0
  61. package/opencode/opencode.jsonc +35 -0
  62. package/package.json +95 -0
  63. package/scripts/bump-plugin-version.cjs +28 -0
  64. package/scripts/install-hub.js +78 -0
  65. package/scripts/sync-agents.cjs +167 -0
  66. package/shared/directspeech-config.cjs +18 -0
  67. package/shared/orchestration-config.cjs +281 -0
  68. package/shared/orchestration-data.json +48 -0
  69. package/shared/state-guard.cjs +56 -0
  70. package/shared/tokensave-freshness.cjs +62 -0
  71. package/skills/impulso-address-pr-comments/SKILL.md +159 -0
  72. package/skills/impulso-brainstorm/SKILL.md +51 -0
  73. package/skills/impulso-context-pack/SKILL.md +52 -0
  74. package/skills/impulso-debug/SKILL.md +47 -0
  75. package/skills/impulso-directspeech-compress/SKILL.md +134 -0
  76. package/skills/impulso-directspeech-help/SKILL.md +30 -0
  77. package/skills/impulso-directspeech-stats/SKILL.md +13 -0
  78. package/skills/impulso-implement/SKILL.md +83 -0
  79. package/skills/impulso-plan/SKILL.md +89 -0
  80. package/skills/impulso-ship/SKILL.md +43 -0
  81. package/skills/impulso-spec/SKILL.md +103 -0
  82. package/skills/impulso-verify/SKILL.md +96 -0
  83. package/skills/plannotator-annotate/SKILL.md +45 -0
  84. package/skills/plannotator-last/SKILL.md +43 -0
  85. package/skills/plannotator-review/SKILL.md +44 -0
@@ -0,0 +1,362 @@
1
+ // impulso-orchestration — opencode plugin
2
+ //
3
+ // Registers all ROSTER agents in config.agent, enforces path-guard on
4
+ // file-write tools, mirrors tdd-observer for bash test runs, and appends
5
+ // the phase reminder to the system prompt when an active workspace exists.
6
+ //
7
+ // Hook mapping (opencode >= 1.16.x):
8
+ // - config: register agents
9
+ // - tool.execute.before: path-guard (state file + read-only role)
10
+ // - tool.execute.after: record-test observer
11
+ // - experimental.chat.system.transform: phase reminder (reads state.json directly)
12
+ //
13
+ // opencode permission schema used (from @opencode-ai/sdk@1.17.14 AgentConfig):
14
+ // { edit?: "ask"|"allow"|"deny", bash?: same, webfetch?, doom_loop?, external_directory? }
15
+ // No wildcard "*" key exists in the typed schema. Categories are mapped to
16
+ // the two main keys (edit, bash); see buildPermission() for the full mapping
17
+ // and rationale in the task-7-report.md.
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { dirname, join, resolve, posix } from 'node:path';
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const pkgRoot = resolve(here, '../../..');
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Load shared orchestration config (CJS) via Function-constructor shim.
30
+ // Same pattern as directspeech/plugin.js uses for directspeech-config.cjs.
31
+ // ---------------------------------------------------------------------------
32
+ function loadOrchestrationConfig() {
33
+ const target = join(pkgRoot, 'shared', 'orchestration-config.cjs');
34
+ const code = readFileSync(target, 'utf8').replace(/^#![^\n]*\n/, '');
35
+ const mod = { exports: {} };
36
+ new Function('module', 'exports', 'require', '__dirname', '__filename', code)(
37
+ mod,
38
+ mod.exports,
39
+ createRequire(target),
40
+ dirname(target),
41
+ target,
42
+ );
43
+ return mod.exports;
44
+ }
45
+
46
+ const { ROSTER, resolveModels, resolveEffort, phaseReminderLine, DISABLED_BUILTINS } =
47
+ loadOrchestrationConfig();
48
+
49
+ // Load shared state-guard (CJS) via createRequire — same harness as above.
50
+ const { isStateGuardHit, isGateInvocation } = createRequire(import.meta.url)(
51
+ join(pkgRoot, 'shared', 'state-guard.cjs'),
52
+ );
53
+
54
+ // State-file deny pattern: .impulso/(work/<slug>/state.json | active)
55
+ const STATE_FILE_RE = /(^|\/)\.impulso\/(work\/[^/]+\/state\.json|active)$/;
56
+
57
+ // Read-only roles that cannot write outside .impulso/
58
+ const READ_ONLY_ROLES = new Set(['scout', 'scout-writer', 'planner', 'reviewer']);
59
+
60
+ // File-write tool names (only these are checked by the path guard)
61
+ const WRITE_TOOLS = new Set(['edit', 'write', 'str_replace', 'patch', 'multiedit']);
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // stripFrontmatter
65
+ // ---------------------------------------------------------------------------
66
+ export function stripFrontmatter(content) {
67
+ const normalized = content.replace(/\r\n/g, '\n');
68
+ const match = /^---\n[\s\S]*?\n---\n([\s\S]*)/.exec(normalized);
69
+ if (!match) return normalized.trimStart();
70
+ return match[1].trimStart();
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // buildPermission
75
+ //
76
+ // Maps a PERMISSION_CLASSES entry to the opencode AgentConfig.permission shape.
77
+ //
78
+ // The opencode SDK (v1.17.14) permission schema has keys: edit, bash, webfetch,
79
+ // doom_loop, external_directory — no wildcard "*". The "wildcard-deny-first"
80
+ // brief language maps to denying the two main action keys (edit + bash) where
81
+ // appropriate. Full rationale is in task-7-report.md.
82
+ //
83
+ // read-only (scout) → { edit:"deny", bash:"deny" }
84
+ // read-only (planner/reviewer)→ { bash:"deny" } (edit allowed; before-guard
85
+ // scopes writes to .impulso/)
86
+ // git-only (shipper) → { edit:"deny" } (bash allowed for git)
87
+ // dispatch-only (orchestrator)→ { edit:"deny" } (bash allowed for task)
88
+ // full → undefined (no restriction)
89
+ // ---------------------------------------------------------------------------
90
+ function buildPermission(permissionClass, agentName) {
91
+ switch (permissionClass) {
92
+ case 'read-only':
93
+ if (agentName === 'planner' || agentName === 'reviewer' || agentName === 'scout-writer') {
94
+ // These agents write plan/review/context artifacts to .impulso/ — edit
95
+ // is allowed, but tool.execute.before guards them to .impulso/ at runtime.
96
+ return { bash: 'deny' };
97
+ }
98
+ return { edit: 'deny', bash: 'deny' };
99
+ case 'git-only':
100
+ return { edit: 'deny' };
101
+ case 'dispatch-only':
102
+ return { edit: 'deny', question: 'allow' };
103
+ case 'full':
104
+ return undefined;
105
+ default:
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // resolveGate
112
+ //
113
+ // Returns { cmd, direct } where direct=true means cmd is a path to run via
114
+ // process.execPath (node), direct=false means cmd is a binary on PATH.
115
+ // Priority: package-relative path > 'impulso-hub' on PATH (standalone install).
116
+ // ---------------------------------------------------------------------------
117
+ function resolveGate() {
118
+ return { cmd: 'impulso-hub', direct: false };
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // spawnGate
123
+ //
124
+ // Run impulso-hub <args> non-blocking. Never throws.
125
+ // ---------------------------------------------------------------------------
126
+ function spawnGate(gate, args) {
127
+ if (!gate) return;
128
+ try {
129
+ if (gate.direct) {
130
+ spawnSync(process.execPath, [gate.cmd, ...args], { stdio: 'ignore', timeout: 5000 });
131
+ } else {
132
+ spawnSync(gate.cmd, args, { stdio: 'ignore', timeout: 5000 });
133
+ }
134
+ } catch (_e) {
135
+ // Best-effort
136
+ }
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // ImpulsoOrchestrationPlugin
141
+ // ---------------------------------------------------------------------------
142
+ export const ImpulsoOrchestrationPlugin = async (ctx) => {
143
+ // projectDir from PluginInput.directory (opencode v1.16+)
144
+ const projectDir = ctx?.directory ?? process.cwd();
145
+
146
+ // Resolve models. Throws on bad JSON or unknown keys (hard-fail at boot,
147
+ // matching enforceRequiredBinaries philosophy — a bad models.json is a config
148
+ // error the operator must fix, not a degraded-mode scenario).
149
+ const models = resolveModels(projectDir, 'opencode');
150
+ const efforts = resolveEffort(projectDir, 'opencode');
151
+
152
+ const agentsDir = join(pkgRoot, 'agents');
153
+
154
+ // Resolve gate binary once at plugin init — closed over by hook handlers.
155
+ const gate = resolveGate();
156
+
157
+ return {
158
+ // -----------------------------------------------------------------------
159
+ // config: register roster agents
160
+ // -----------------------------------------------------------------------
161
+ config: async (config) => {
162
+ config.agent = config.agent || {};
163
+
164
+ // Disable opencode built-in subagents that would otherwise compete
165
+ // with impulso-owned roster agents at dispatch time. Built-ins
166
+ // have no impulso rules, no rtk integration, no path-guard. scout is
167
+ // NOT disabled — impulso's own scout registration overrides by name.
168
+ for (const name of DISABLED_BUILTINS) {
169
+ config.agent[name] = { disable: true };
170
+ }
171
+
172
+ for (const [name, entry] of Object.entries(ROSTER)) {
173
+ const isOrchestrator = name === 'ImpulsoOrchestrator';
174
+ const mode = isOrchestrator ? 'primary' : 'subagent';
175
+
176
+ // Agent prompt body (frontmatter stripped)
177
+ let prompt = '';
178
+ try {
179
+ prompt = stripFrontmatter(readFileSync(join(agentsDir, `${name}.md`), 'utf8'));
180
+ } catch (_e) {
181
+ // Missing agent file — proceed without prompt
182
+ }
183
+
184
+ // Model/effort resolution:
185
+ // orchestrator → omitted (unpinned; inherits session model)
186
+ // others → direct name key
187
+ // effort is registered as a reasoningEffort provider-passthrough
188
+ // (untyped AgentConfig extension; interpretation is provider-specific).
189
+ let model;
190
+ let effort;
191
+ if (!isOrchestrator) {
192
+ const modelKey = name;
193
+ const raw = models[modelKey] ?? undefined;
194
+ // opencode DEFAULT_MODELS aliases are `llm/<key>` strings; the impulso
195
+ // profile registers them under the litellm provider. Prefix here so
196
+ // opencode parses provider=litellm, model=llm/<key>. Concrete model
197
+ // strings (e.g. user overrides like "anthropic/...") pass through.
198
+ model = typeof raw === 'string' && raw.startsWith('llm/') ? `litellm/${raw}` : raw;
199
+ effort = efforts[modelKey] ?? undefined;
200
+ }
201
+
202
+ const permission = buildPermission(entry.permissionClass, name);
203
+
204
+ config.agent[name] = {
205
+ mode,
206
+ prompt,
207
+ description: entry.description,
208
+ ...(model !== undefined ? { model } : {}),
209
+ ...(effort != null ? { reasoningEffort: effort } : {}),
210
+ ...(permission !== undefined ? { permission } : {}),
211
+ };
212
+ }
213
+ },
214
+
215
+ // -----------------------------------------------------------------------
216
+ // tool.execute.before: path-guard + bash state-guard
217
+ //
218
+ // Deny 1 (file-write tools): gate-owned state files — unconditional.
219
+ // Deny 2 (file-write tools): read-only-role agent writing outside .impulso/
220
+ // — fails-open when agent identity is unavailable (see LIMITATIONS below).
221
+ // Deny 3 (bash tool): any command touching gate-owned state paths.
222
+ //
223
+ // LIMITATIONS (I3 / v1 scope guard):
224
+ // @opencode-ai/plugin v1.17.14 tool.execute.before input type is
225
+ // { tool, sessionID, callID } — no agent field in the typed API.
226
+ // Deny 2 checks input?.agent defensively: enforced if opencode passes the
227
+ // agent name as an untyped extension; fails-open if absent.
228
+ // The Claude-side bash-state-guard.cjs + path-guard.cjs are the enforced
229
+ // paths (role file written by SubagentStart). This opencode fail-open is
230
+ // documented in docs/specs/2026-07-12-agent-orchestration-design.md
231
+ // § v1 scope guards.
232
+ // -----------------------------------------------------------------------
233
+ 'tool.execute.before': async (input, output) => {
234
+ const tool = String(input?.tool ?? '').toLowerCase();
235
+
236
+ // Deny 3: bash commands touching gate-owned state
237
+ if (tool === 'bash' || tool === 'shell') {
238
+ const args = output?.args;
239
+ const command =
240
+ args && typeof args === 'object' && typeof args.command === 'string' ? args.command : '';
241
+ if (command && !isGateInvocation(command) && isStateGuardHit(command)) {
242
+ throw new Error('state file is gate-owned; use impulso-hub');
243
+ }
244
+ return;
245
+ }
246
+
247
+ // Only intercept file-write tools for Deny 1 + Deny 2
248
+ if (!WRITE_TOOLS.has(tool)) return;
249
+
250
+ const args = output?.args;
251
+ const filePath =
252
+ args && typeof args === 'object' && typeof args.file_path === 'string'
253
+ ? args.file_path
254
+ : '';
255
+ if (!filePath) return;
256
+
257
+ // Normalize: replace backslashes first, then resolve dot-segments
258
+ const normalized = posix.normalize(filePath.replace(/\\/g, '/'));
259
+ const normalizedLower = normalized.toLowerCase();
260
+
261
+ // Deny 1: gate-owned state files — unconditional
262
+ if (STATE_FILE_RE.test(normalizedLower)) {
263
+ throw new Error('state file is gate-owned; use impulso-hub');
264
+ }
265
+
266
+ // Deny 2: read-only-role agent writing outside .impulso/
267
+ const agentName = input?.agent ?? null;
268
+ if (agentName && READ_ONLY_ROLES.has(agentName)) {
269
+ const isWorkspace =
270
+ normalizedLower.includes('/.impulso/') || normalizedLower.startsWith('.impulso/');
271
+ if (!isWorkspace) {
272
+ throw new Error(
273
+ `read-only agent (${agentName}): source tree writes denied; workspace (.impulso/) only`,
274
+ );
275
+ }
276
+ }
277
+ },
278
+
279
+ // -----------------------------------------------------------------------
280
+ // tool.execute.after: tdd-observer mirror
281
+ //
282
+ // When a bash tool runs the project's test_cmd in an active workspace,
283
+ // record the exit code via impulso-hub record-test. Never throws.
284
+ //
285
+ // Exit code is extracted from output.metadata (opencode after-hook output
286
+ // shape: { title, output: string, metadata }). Candidate fields checked in
287
+ // order: exitCode, exit_code, code — consistent with tdd-observer.cjs.
288
+ // -----------------------------------------------------------------------
289
+ 'tool.execute.after': async (input, output) => {
290
+ try {
291
+ const tool = String(input?.tool ?? '').toLowerCase();
292
+ if (tool !== 'bash' && tool !== 'shell') return;
293
+
294
+ // Command comes from input.args in the after hook
295
+ const command =
296
+ input?.args && typeof input.args === 'object' && typeof input.args.command === 'string'
297
+ ? input.args.command
298
+ : '';
299
+ if (!command) return;
300
+
301
+ // Active workspace check
302
+ const cwd = process.cwd();
303
+ if (!existsSync(join(cwd, '.impulso', 'active'))) return;
304
+
305
+ // Read test_cmd from workspace config
306
+ let testCmd;
307
+ try {
308
+ const cfg = JSON.parse(readFileSync(join(cwd, '.impulso', 'config.json'), 'utf8'));
309
+ testCmd = cfg.test_cmd;
310
+ } catch (_e) {
311
+ return;
312
+ }
313
+ if (!testCmd || !command.includes(testCmd)) return;
314
+
315
+ // Extract exit code from metadata
316
+ const meta = output?.metadata || {};
317
+ let exitCode = null;
318
+ for (const field of ['exitCode', 'exit_code', 'code']) {
319
+ if (typeof meta[field] === 'number') {
320
+ exitCode = meta[field];
321
+ break;
322
+ }
323
+ }
324
+ if (exitCode === null) return;
325
+
326
+ spawnGate(gate, ['record-test', '--cmd', command, '--exit-code', String(exitCode)]);
327
+ } catch (_e) {
328
+ // Never block tool use
329
+ }
330
+ },
331
+
332
+ // -----------------------------------------------------------------------
333
+ // experimental.chat.system.transform: phase reminder
334
+ //
335
+ // Appends phaseReminderLine(phase) when an active workspace exists.
336
+ // Line is byte-stable per phase (phaseReminderLine is a pure function),
337
+ // which keeps the system prompt cache-friendly.
338
+ //
339
+ // Phase is read directly from .impulso/work/<slug>/state.json — no gate
340
+ // subprocess spawned per message. Reads are permitted (write monopoly only).
341
+ // Any read/parse error silently appends nothing (fail-open).
342
+ // -----------------------------------------------------------------------
343
+ 'experimental.chat.system.transform': async (_input, output) => {
344
+ if (!output || !Array.isArray(output.system)) return;
345
+
346
+ const cwd = process.cwd();
347
+ if (!existsSync(join(cwd, '.impulso', 'active'))) return;
348
+
349
+ try {
350
+ const slug = readFileSync(join(cwd, '.impulso', 'active'), 'utf8').trim();
351
+ const stateData = readFileSync(join(cwd, '.impulso', 'work', slug, 'state.json'), 'utf8');
352
+ const { phase } = JSON.parse(stateData);
353
+ if (!phase) return;
354
+ output.system.push(phaseReminderLine(phase));
355
+ } catch (_e) {
356
+ // Fail-open: any read/parse error → append nothing
357
+ }
358
+ },
359
+ };
360
+ };
361
+
362
+ export default ImpulsoOrchestrationPlugin;
@@ -0,0 +1,84 @@
1
+ // impulso — opencode plugin orchestrator
2
+ //
3
+ // Single plugin entry that composes DirectSpeech + bundled skills/commands.
4
+ // Use as the sole entry in opencode.jsonc:
5
+ //
6
+ // "plugin": ["impulso@git+https://github.com/alexandrekm/Impulso.git"]
7
+ //
8
+ // DirectSpeech plugin is imported directly (sibling file).
9
+ //
10
+ // Bun ESM module.
11
+
12
+ import path from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { composeHooks } from './compose.js';
16
+ import { registerCommands } from './commands-loader.js';
17
+ import { ImpulsoDirectSpeechPlugin } from './directspeech/plugin.js';
18
+ import { ImpulsoOrchestrationPlugin } from './orchestration/index.js';
19
+ import { ImpulsoSessionNamePlugin } from './session-name/plugin.js';
20
+
21
+ const here = path.dirname(fileURLToPath(import.meta.url));
22
+ const pkgRoot = path.resolve(here, '..', '..');
23
+
24
+ // Impulso is opinionated: rtk and tokensave are hard-required at runtime,
25
+ // not just at install time. Throw at plugin init so the failure is loud
26
+ // ([impulso] in logs — integration tests grep for it) instead of a silently
27
+ // degraded session.
28
+ const REQUIRED_BINARIES = [
29
+ { bin: 'rtk', hint: 'brew install rtk-ai/tap/rtk' },
30
+ { bin: 'tokensave', hint: 'brew install aovestdipaperino/tap/tokensave' },
31
+ { bin: 'plannotator', hint: 'curl -fsSL https://plannotator.ai/install.sh | bash' },
32
+ ];
33
+
34
+ function enforceRequiredBinaries() {
35
+ const missing = REQUIRED_BINARIES.filter(({ bin }) => {
36
+ const r = spawnSync('which', [bin], { stdio: ['ignore', 'pipe', 'ignore'] });
37
+ return r.error || r.status !== 0;
38
+ });
39
+ if (missing.length > 0) {
40
+ const msg =
41
+ '[impulso] hard-required binaries missing from PATH: ' +
42
+ missing.map(({ bin, hint }) => `${bin} (install: ${hint})`).join(', ');
43
+ console.error(msg);
44
+ throw new Error(msg);
45
+ }
46
+ }
47
+
48
+ export const ImpulsoPlugin = async (ctx) => {
49
+ enforceRequiredBinaries();
50
+
51
+ const sub = [];
52
+
53
+ // DirectSpeech plugin (flag tracking, reinforcement, chat.message parsing)
54
+ sub.push(await ImpulsoDirectSpeechPlugin(ctx));
55
+
56
+ // Orchestration plugin (agent roster, path-guard, tdd-observer, phase reminder)
57
+ sub.push(await ImpulsoOrchestrationPlugin(ctx));
58
+
59
+ // Session-name plugin (X-Session-Name header for proxy cache locality)
60
+ sub.push(await ImpulsoSessionNamePlugin(ctx));
61
+
62
+ const hooks = composeHooks(sub);
63
+
64
+ // Wrap the composed config hook with our own skills/commands registration.
65
+ // This appends bundled skills/ dir to config.skills.paths and inlines each
66
+ // bundled commands/*.md as a config.command[name] template.
67
+ const subConfig = hooks.config;
68
+ hooks.config = async (config) => {
69
+ config.skills = config.skills || {};
70
+ config.skills.paths = config.skills.paths || [];
71
+ const skillsDir = path.join(pkgRoot, 'skills');
72
+ if (!config.skills.paths.includes(skillsDir)) {
73
+ config.skills.paths.push(skillsDir);
74
+ }
75
+
76
+ registerCommands(config, path.join(pkgRoot, 'commands'));
77
+
78
+ if (subConfig) await subConfig(config);
79
+ };
80
+
81
+ return hooks;
82
+ };
83
+
84
+ export default ImpulsoPlugin;
@@ -0,0 +1,100 @@
1
+ # Impulso-RTK (opencode harness)
2
+
3
+ Vendored OpenCode plugin for [RTK](https://github.com/rtk-ai/rtk) — the Rust CLI
4
+ proxy that reduces LLM token consumption 60-90% on common shell commands (`git`,
5
+ `cargo`, `pytest`, `tsc`, `docker`, `kubectl`, `aws`, …).
6
+
7
+ ## Attribution
8
+
9
+ Vendored verbatim from
10
+ [`hooks/opencode/rtk.ts`](https://github.com/rtk-ai/rtk/blob/develop/hooks/opencode/rtk.ts)
11
+ in the upstream RTK repo (Apache-2.0). All rewrite logic lives in the `rtk`
12
+ binary; this file is a thin OpenCode plugin glue that shells out to
13
+ `rtk rewrite <command>`.
14
+
15
+ To check for upstream changes:
16
+
17
+ ```bash
18
+ diff harnesses/opencode/rtk/rtk.ts \
19
+ <(curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/develop/hooks/opencode/rtk.ts)
20
+ ```
21
+
22
+ Empty diff = up to date.
23
+
24
+ ## Architecture
25
+
26
+ Single hook on the opencode plugin system:
27
+
28
+ ```
29
+ user runs bash tool (e.g. "git status")
30
+ ───► tool.execute.before() fires
31
+ ───► plugin calls `rtk rewrite "git status"` as subprocess
32
+ ───► rtk returns "rtk git status"
33
+ ───► plugin mutates args.command in-place
34
+ ───► opencode executes "rtk git status"
35
+ ───► filtered output reaches LLM (~200 tokens instead of ~2,000)
36
+ ```
37
+
38
+ If the `rtk` binary is not on `$PATH` at plugin load time, the plugin logs a
39
+ warning and returns no hooks — opencode proceeds with raw commands. No crash, no
40
+ broken sessions.
41
+
42
+ ## Prerequisite — install the rtk binary
43
+
44
+ The plugin requires `rtk >= 0.23.0` on `$PATH`. Impulso does not install it for
45
+ you. Pick one:
46
+
47
+ ### Homebrew (recommended on macOS / Linuxbrew)
48
+
49
+ ```bash
50
+ brew install rtk
51
+ ```
52
+
53
+ ### Quick install (Linux / macOS)
54
+
55
+ ```bash
56
+ curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
57
+ ```
58
+
59
+ Installs to `~/.local/bin`. Add to PATH if needed:
60
+
61
+ ```bash
62
+ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc
63
+ ```
64
+
65
+ ### Cargo
66
+
67
+ ```bash
68
+ cargo install --git https://github.com/rtk-ai/rtk
69
+ ```
70
+
71
+ ### Verify
72
+
73
+ ```bash
74
+ rtk --version # should print "rtk 0.23.0" or higher
75
+ rtk gain # shows token savings stats
76
+ ```
77
+
78
+ > Name collision warning: another crates.io package named `rtk` (Rust Type Kit)
79
+ > exists. If `rtk gain` fails, you have the wrong package. Use
80
+ > `cargo install --git` above instead.
81
+
82
+ ## File overview
83
+
84
+ ```
85
+ harnesses/opencode/rtk/
86
+ ├── rtk.ts # opencode plugin (vendored verbatim from upstream)
87
+ └── README.md # this file
88
+ ```
89
+
90
+ ## Disabling
91
+
92
+ Three ways to opt out without uninstalling:
93
+
94
+ - Per-command: prefix with `RTK_DISABLED=1` (e.g. `RTK_DISABLED=1 git status`
95
+ runs raw `git status`).
96
+ - Permanent exclusions: list commands in `~/.config/rtk/config.toml` under
97
+ `[hooks] exclude_commands = ["curl", "playwright"]`.
98
+ - Remove the plugin: delete the `../harnesses/opencode/rtk/rtk.ts` entry from
99
+ the `plugin` array in `opencode/opencode.jsonc` (and from
100
+ `harnesses/opencode/install.sh` heredoc if using installed profile).
@@ -0,0 +1,41 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+
3
+ // RTK OpenCode plugin — rewrites commands to use rtk for token savings.
4
+ // Requires: rtk >= 0.23.0 in PATH.
5
+ //
6
+ // This is a thin delegating plugin: all rewrite logic lives in `rtk rewrite`,
7
+ // which is the single source of truth (src/discover/registry.rs).
8
+ // To add or change rewrite rules, edit the Rust registry — not this file.
9
+
10
+ export const RtkOpenCodePlugin: Plugin = async ({ $ }) => {
11
+ try {
12
+ await $`which rtk`.quiet()
13
+ } catch {
14
+ console.warn("[rtk] rtk binary not found in PATH — plugin disabled")
15
+ return {}
16
+ }
17
+
18
+ return {
19
+ "tool.execute.before": async (input, output) => {
20
+ const tool = String(input?.tool ?? "").toLowerCase()
21
+ if (tool !== "bash" && tool !== "shell") return
22
+ const args = output?.args
23
+ if (!args || typeof args !== "object") return
24
+
25
+ const command = (args as Record<string, unknown>).command
26
+ if (typeof command !== "string" || !command) return
27
+
28
+ try {
29
+ const result = await $`rtk rewrite ${command}`.quiet().nothrow()
30
+ // Non-zero exit: rtk failed — never trust partial stdout as a command.
31
+ if (result.exitCode !== 0) return
32
+ const rewritten = String(result.stdout).trim()
33
+ if (rewritten && rewritten !== command) {
34
+ ;(args as Record<string, unknown>).command = rewritten
35
+ }
36
+ } catch {
37
+ // rtk rewrite failed — pass through unchanged
38
+ }
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,27 @@
1
+ // impulso-session-name — opencode plugin
2
+ //
3
+ // Injects a stable X-Session-Name header (value = the opencode session ID)
4
+ // on every outbound chat request, for proxy cache locality (OpenRouter,
5
+ // Helicone, LiteLLM, etc.). Always on: no flags, no toggles, no modes.
6
+ //
7
+ // Issue #37 asked for a session.created/session.updated listener via a
8
+ // custom fetch wrapper; @opencode-ai/plugin@1.17.14 defines no such hooks
9
+ // (absent from compose.js INPUT_HOOKS and the SDK Hooks type), so this uses
10
+ // chat.headers instead — same mechanism directspeech uses, same net result:
11
+ // a stable per-session header on every request.
12
+ //
13
+ // Hook mapping (opencode >= 1.15.x):
14
+ // - chat.headers: set X-Session-Name from input.sessionID
15
+ //
16
+ // Bun ESM module.
17
+
18
+ export const ImpulsoSessionNamePlugin = async (_ctx) => {
19
+ return {
20
+ 'chat.headers': async (input, output) => {
21
+ const id = input?.sessionID;
22
+ if (id && output?.headers) output.headers['X-Session-Name'] = String(id);
23
+ },
24
+ };
25
+ };
26
+
27
+ export default ImpulsoSessionNamePlugin;