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.
- package/README.md +425 -0
- package/agents/ImpulsoOrchestrator.md +112 -0
- package/agents/debugger.md +38 -0
- package/agents/generalist.md +26 -0
- package/agents/implementer-frontend.md +41 -0
- package/agents/implementer-infra.md +41 -0
- package/agents/implementer.md +35 -0
- package/agents/planner.md +52 -0
- package/agents/reviewer.md +40 -0
- package/agents/scout-writer.md +24 -0
- package/agents/scout.md +22 -0
- package/agents/shipper.md +64 -0
- package/commands/impulso-address-pr-comments.md +162 -0
- package/commands/impulso-brainstorm.md +54 -0
- package/commands/impulso-context-pack.md +55 -0
- package/commands/impulso-debug.md +50 -0
- package/commands/impulso-directspeech-compress.md +137 -0
- package/commands/impulso-directspeech-help.md +33 -0
- package/commands/impulso-directspeech-stats.md +16 -0
- package/commands/impulso-implement.md +86 -0
- package/commands/impulso-plan.md +92 -0
- package/commands/impulso-ship.md +46 -0
- package/commands/impulso-spec.md +106 -0
- package/commands/impulso-verify.md +99 -0
- package/commands/plannotator-annotate.md +47 -0
- package/commands/plannotator-last.md +45 -0
- package/commands/plannotator-review.md +46 -0
- package/harnesses/claude/README.md +109 -0
- package/harnesses/claude/hooks/bash-state-guard.cjs +84 -0
- package/harnesses/claude/hooks/directspeech-context.cjs +29 -0
- package/harnesses/claude/hooks/path-guard.cjs +106 -0
- package/harnesses/claude/hooks/preflight.cjs +39 -0
- package/harnesses/claude/hooks/pretooluse-bash.cjs +69 -0
- package/harnesses/claude/hooks/rtk.cjs +87 -0
- package/harnesses/claude/hooks/subagent-start.cjs +83 -0
- package/harnesses/claude/hooks/task-exit.cjs +189 -0
- package/harnesses/claude/hooks/tdd-observer.cjs +118 -0
- package/harnesses/claude/hooks/tokensave-stale.cjs +63 -0
- package/harnesses/claude/hooks/userpromptsubmit.cjs +42 -0
- package/harnesses/claude/install.sh +120 -0
- package/harnesses/claude/merge-settings.cjs +71 -0
- package/harnesses/claude/uninstall.sh +89 -0
- package/harnesses/opencode/bin/darwin-amd64/impulso-hub +0 -0
- package/harnesses/opencode/bin/darwin-arm64/impulso-hub +0 -0
- package/harnesses/opencode/bin/impulso-hub +0 -0
- package/harnesses/opencode/bin/linux-amd64/impulso-hub +0 -0
- package/harnesses/opencode/bin/linux-arm64/impulso-hub +0 -0
- package/harnesses/opencode/commands-loader.js +101 -0
- package/harnesses/opencode/compose.js +111 -0
- package/harnesses/opencode/directspeech/README.md +43 -0
- package/harnesses/opencode/directspeech/plugin.js +47 -0
- package/harnesses/opencode/frontmatter.js +60 -0
- package/harnesses/opencode/install.sh +121 -0
- package/harnesses/opencode/orchestration/index.js +362 -0
- package/harnesses/opencode/plugin.js +84 -0
- package/harnesses/opencode/rtk/README.md +100 -0
- package/harnesses/opencode/rtk/rtk.ts +41 -0
- package/harnesses/opencode/session-name/plugin.js +27 -0
- package/harnesses/opencode/sync-commands.js +98 -0
- package/harnesses/opencode/uninstall.sh +36 -0
- package/opencode/opencode.jsonc +35 -0
- package/package.json +95 -0
- package/scripts/bump-plugin-version.cjs +28 -0
- package/scripts/install-hub.js +78 -0
- package/scripts/sync-agents.cjs +167 -0
- package/shared/directspeech-config.cjs +18 -0
- package/shared/orchestration-config.cjs +281 -0
- package/shared/orchestration-data.json +48 -0
- package/shared/state-guard.cjs +56 -0
- package/shared/tokensave-freshness.cjs +62 -0
- package/skills/impulso-address-pr-comments/SKILL.md +159 -0
- package/skills/impulso-brainstorm/SKILL.md +51 -0
- package/skills/impulso-context-pack/SKILL.md +52 -0
- package/skills/impulso-debug/SKILL.md +47 -0
- package/skills/impulso-directspeech-compress/SKILL.md +134 -0
- package/skills/impulso-directspeech-help/SKILL.md +30 -0
- package/skills/impulso-directspeech-stats/SKILL.md +13 -0
- package/skills/impulso-implement/SKILL.md +83 -0
- package/skills/impulso-plan/SKILL.md +89 -0
- package/skills/impulso-ship/SKILL.md +43 -0
- package/skills/impulso-spec/SKILL.md +103 -0
- package/skills/impulso-verify/SKILL.md +96 -0
- package/skills/plannotator-annotate/SKILL.md +45 -0
- package/skills/plannotator-last/SKILL.md +43 -0
- 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;
|