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,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// impulso-orchestration — shared roster, models, phases, and routes
|
|
3
|
+
//
|
|
4
|
+
// Single source of truth for agent orchestration config. Consumed by
|
|
5
|
+
// impulso-hub, both harnesses, and any skill that routes work to
|
|
6
|
+
// specialist agents.
|
|
7
|
+
//
|
|
8
|
+
// resolveModels/resolveEffort take a REQUIRED `harness` (claude|opencode) and
|
|
9
|
+
// resolve that harness's block. Ladder (highest to lowest):
|
|
10
|
+
// projectDir/.impulso/models.json[harness] >
|
|
11
|
+
// homeDir/.impulso/models.json[harness] > DEFAULT_MODELS[harness].
|
|
12
|
+
// Hard-fails on bad JSON, bad harness, or unknown keys — no silent fallback.
|
|
13
|
+
// Values are either a model string ("sonnet") or an object
|
|
14
|
+
// ({ "model": "sonnet", "effort": "high" }); fields merge per-key down the
|
|
15
|
+
// ladder, so an effort-only override keeps the lower-rung model.
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
|
|
21
|
+
// -----------------------------------------------------------------------
|
|
22
|
+
// ROSTER
|
|
23
|
+
// Ordered: name -> { description, tools, permissionClass, variant?, routingBlurb }
|
|
24
|
+
// -----------------------------------------------------------------------
|
|
25
|
+
const ROSTER = {
|
|
26
|
+
ImpulsoOrchestrator: {
|
|
27
|
+
description: 'Routes tasks to specialist agents.',
|
|
28
|
+
permissionClass: 'dispatch-only',
|
|
29
|
+
routingBlurb: 'Dispatch hub; no direct file/tool access.',
|
|
30
|
+
},
|
|
31
|
+
scout: {
|
|
32
|
+
description: 'Read-only codebase explorer.',
|
|
33
|
+
permissionClass: 'read-only',
|
|
34
|
+
routingBlurb: 'Explore, locate, report — no writes.',
|
|
35
|
+
},
|
|
36
|
+
'scout-writer': {
|
|
37
|
+
description: 'Read-only explorer that writes the shared context pack.',
|
|
38
|
+
permissionClass: 'read-only',
|
|
39
|
+
routingBlurb: 'Explore + write context.md; workspace writes only.',
|
|
40
|
+
},
|
|
41
|
+
planner: {
|
|
42
|
+
description: 'Produces specs and implementation plans.',
|
|
43
|
+
permissionClass: 'read-only',
|
|
44
|
+
routingBlurb: 'Plan artifacts only; scout handles exploration.',
|
|
45
|
+
},
|
|
46
|
+
implementer: {
|
|
47
|
+
description: 'General-purpose implementation agent.',
|
|
48
|
+
permissionClass: 'full',
|
|
49
|
+
routingBlurb: 'Code, tests, refactors — no frontend/infra specialization.',
|
|
50
|
+
},
|
|
51
|
+
'implementer-frontend': {
|
|
52
|
+
description: 'Frontend-focused implementation agent.',
|
|
53
|
+
permissionClass: 'full',
|
|
54
|
+
variant: 'frontend',
|
|
55
|
+
routingBlurb: 'UI, CSS, component trees, browser APIs.',
|
|
56
|
+
},
|
|
57
|
+
'implementer-infra': {
|
|
58
|
+
description: 'Infrastructure and ops implementation agent.',
|
|
59
|
+
permissionClass: 'full',
|
|
60
|
+
variant: 'infra',
|
|
61
|
+
routingBlurb: 'Terraform, K8s, CI/CD, cloud config.',
|
|
62
|
+
},
|
|
63
|
+
reviewer: {
|
|
64
|
+
description: 'Reviews code changes (task-level and branch-level).',
|
|
65
|
+
permissionClass: 'read-only',
|
|
66
|
+
routingBlurb: 'Code review; read-only.',
|
|
67
|
+
},
|
|
68
|
+
debugger: {
|
|
69
|
+
description: 'Root-cause analysis and bug fixes.',
|
|
70
|
+
permissionClass: 'full',
|
|
71
|
+
routingBlurb: 'Reproduce, isolate, fix — systematic debugging.',
|
|
72
|
+
},
|
|
73
|
+
shipper: {
|
|
74
|
+
description: 'Commits, pushes, opens PRs.',
|
|
75
|
+
permissionClass: 'git-only',
|
|
76
|
+
routingBlurb: 'Git operations only; no arbitrary file writes.',
|
|
77
|
+
},
|
|
78
|
+
generalist: {
|
|
79
|
+
description: 'Catch-all for tasks without a specialist fit.',
|
|
80
|
+
permissionClass: 'full',
|
|
81
|
+
routingBlurb: 'Broad-capability; prefer specialists when type is known.',
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// -----------------------------------------------------------------------
|
|
86
|
+
// DISABLED_BUILTINS
|
|
87
|
+
// opencode built-in subagents impulso disables at config-write time to prevent
|
|
88
|
+
// the LLM orchestrator from dispatching a built-in (no impulso rules, no rtk,
|
|
89
|
+
// no path-guard) instead of an impulso-owned roster agent. `scout` is NOT
|
|
90
|
+
// listed: impulso's own `scout` registration overrides the built-in by name.
|
|
91
|
+
// -----------------------------------------------------------------------
|
|
92
|
+
const DISABLED_BUILTINS = ['explore', 'general'];
|
|
93
|
+
|
|
94
|
+
// -----------------------------------------------------------------------
|
|
95
|
+
// DEFAULT_MODELS, ROUTES, PHASES
|
|
96
|
+
// Load from orchestration-data.json
|
|
97
|
+
// -----------------------------------------------------------------------
|
|
98
|
+
const {
|
|
99
|
+
routes: ROUTES,
|
|
100
|
+
phases: PHASES,
|
|
101
|
+
defaultModels: DEFAULT_MODELS,
|
|
102
|
+
} = require('./orchestration-data.json');
|
|
103
|
+
|
|
104
|
+
// -----------------------------------------------------------------------
|
|
105
|
+
// EFFORT_LEVELS
|
|
106
|
+
// Claude Code's per-subagent effort enum. opencode receives the value as a
|
|
107
|
+
// reasoningEffort provider-passthrough; interpretation is provider-specific.
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
110
|
+
|
|
111
|
+
// -----------------------------------------------------------------------
|
|
112
|
+
// _resolveTuning
|
|
113
|
+
// (projectDir, harness, homeDir) -> { models, effort } merged maps
|
|
114
|
+
// `harness` is REQUIRED — the valid set is Object.keys(DEFAULT_MODELS).
|
|
115
|
+
// Ladder: project > home > DEFAULT_MODELS[harness]. Values are a model string
|
|
116
|
+
// or a { model?, effort? } object; fields merge per-key down the ladder.
|
|
117
|
+
// Only the `harness` block of each models.json contributes overrides, but the
|
|
118
|
+
// whole-file top-level key check runs regardless (catches the flat/legacy
|
|
119
|
+
// shape). `effort` inside a claude block is a hard error (Claude Code effort
|
|
120
|
+
// comes from agent frontmatter). Unknown keys/fields, invalid effort, bad
|
|
121
|
+
// harness, or malformed JSON = throw.
|
|
122
|
+
// -----------------------------------------------------------------------
|
|
123
|
+
function _resolveTuning(projectDir, harness, homeDir) {
|
|
124
|
+
// Invalid harness arg → throw before reading any files.
|
|
125
|
+
if (!(harness in DEFAULT_MODELS)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`orchestration-config: harness must be one of: ${Object.keys(DEFAULT_MODELS).join(', ')} (got ${JSON.stringify(harness)})`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const block = DEFAULT_MODELS[harness];
|
|
132
|
+
|
|
133
|
+
// Apply lowest-priority first so higher-priority overwrites
|
|
134
|
+
const ladder = [path.join(homeDir, '.impulso', 'models.json')];
|
|
135
|
+
|
|
136
|
+
const models = Object.assign({}, block);
|
|
137
|
+
const effort = {};
|
|
138
|
+
for (const key of Object.keys(block)) effort[key] = null;
|
|
139
|
+
|
|
140
|
+
for (const filePath of ladder) {
|
|
141
|
+
if (!fs.existsSync(filePath)) continue;
|
|
142
|
+
|
|
143
|
+
let raw;
|
|
144
|
+
try {
|
|
145
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
146
|
+
} catch (e) {
|
|
147
|
+
throw new Error(`orchestration-config: cannot read ${filePath}: ${e.message}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let overrides;
|
|
151
|
+
try {
|
|
152
|
+
overrides = JSON.parse(raw);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
throw new Error(`orchestration-config: invalid JSON in ${filePath}: ${e.message}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Whole-file top-level key check — every top-level key must be a known
|
|
158
|
+
// harness. Catches the flat/legacy shape (e.g. { "planner": "opus" })
|
|
159
|
+
// regardless of which harness is being resolved.
|
|
160
|
+
for (const key of Object.keys(overrides)) {
|
|
161
|
+
if (!(key in DEFAULT_MODELS)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`orchestration-config: unknown top-level key "${key}" in ${filePath} — models.json is now harness-partitioned: wrap keys under a "claude" or "opencode" block`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Descend ONLY the resolved harness's block; absent block → no overrides.
|
|
169
|
+
const blockOverrides = overrides[harness];
|
|
170
|
+
if (blockOverrides === null || typeof blockOverrides !== 'object') continue;
|
|
171
|
+
|
|
172
|
+
for (const key of Object.keys(blockOverrides)) {
|
|
173
|
+
if (!(key in block)) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`orchestration-config: unknown model key "${key}" in the "${harness}" block of ${filePath}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const value = blockOverrides[key];
|
|
180
|
+
if (typeof value === 'string' || value === null) {
|
|
181
|
+
models[key] = value;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (typeof value !== 'object') {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`orchestration-config: value for "${key}" in the "${harness}" block of ${filePath} must be a string or { model?, effort? } object`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
for (const field of Object.keys(value)) {
|
|
190
|
+
if (field !== 'model' && field !== 'effort') {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`orchestration-config: unknown field "${field}" for "${key}" in the "${harness}" block of ${filePath}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if ('model' in value) {
|
|
197
|
+
if (typeof value.model !== 'string' || value.model === '') {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`orchestration-config: model for "${key}" in the "${harness}" block of ${filePath} must be a non-empty string`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
models[key] = value.model;
|
|
203
|
+
}
|
|
204
|
+
if ('effort' in value) {
|
|
205
|
+
if (harness === 'claude') {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`orchestration-config: "effort" is not supported in the "claude" block (key "${key}" in ${filePath}) — Claude Code effort comes from agents/<name>.md frontmatter, not models.json`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (value.effort !== null && !EFFORT_LEVELS.includes(value.effort)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`orchestration-config: invalid effort "${value.effort}" for "${key}" in the "${harness}" block of ${filePath} — must be one of: ${EFFORT_LEVELS.join(', ')}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
effort[key] = value.effort;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { models, effort };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// -----------------------------------------------------------------------
|
|
224
|
+
// resolveModels
|
|
225
|
+
// (projectDir, harness, homeDir?) -> merged models map (model strings only)
|
|
226
|
+
// -----------------------------------------------------------------------
|
|
227
|
+
function resolveModels(projectDir, harness, homeDir) {
|
|
228
|
+
if (homeDir === undefined) homeDir = process.env.IMPULSO_HOME ?? os.homedir();
|
|
229
|
+
return _resolveTuning(projectDir, harness, homeDir).models;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// -----------------------------------------------------------------------
|
|
233
|
+
// resolveEffort
|
|
234
|
+
// (projectDir, harness, homeDir?) -> merged effort map (EFFORT_LEVELS value or
|
|
235
|
+
// null; null = inherit the session/dispatch default)
|
|
236
|
+
// -----------------------------------------------------------------------
|
|
237
|
+
function resolveEffort(projectDir, harness, homeDir) {
|
|
238
|
+
if (homeDir === undefined) homeDir = process.env.IMPULSO_HOME ?? os.homedir();
|
|
239
|
+
return _resolveTuning(projectDir, harness, homeDir).effort;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// -----------------------------------------------------------------------
|
|
243
|
+
// PERMISSION_CLASSES
|
|
244
|
+
// Class names are the single source of truth consumed by sync-agents (membership
|
|
245
|
+
// validation) and orchestration/index.js (SDK permission mapping).
|
|
246
|
+
// The allowed/denied tool lists were removed — they were only consumed by tests
|
|
247
|
+
// and nothing runtime uses the values; opencode uses the SDK permission schema
|
|
248
|
+
// (edit, bash) mapped in buildPermission().
|
|
249
|
+
// -----------------------------------------------------------------------
|
|
250
|
+
const PERMISSION_CLASSES = {
|
|
251
|
+
'read-only': {},
|
|
252
|
+
full: {},
|
|
253
|
+
'git-only': {},
|
|
254
|
+
'dispatch-only': {},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// -----------------------------------------------------------------------
|
|
258
|
+
// phaseReminderLine
|
|
259
|
+
// Pure function of inputs — byte-stable for prompt caching.
|
|
260
|
+
// -----------------------------------------------------------------------
|
|
261
|
+
function phaseReminderLine(phase, taskInfo) {
|
|
262
|
+
let line = `IMPULSO PIPELINE: phase ${phase}`;
|
|
263
|
+
if (taskInfo !== undefined && taskInfo !== null) {
|
|
264
|
+
line += `, task ${taskInfo}`;
|
|
265
|
+
}
|
|
266
|
+
line += '. Gates enforced by impulso-hub.';
|
|
267
|
+
return line;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
ROSTER,
|
|
272
|
+
DISABLED_BUILTINS,
|
|
273
|
+
DEFAULT_MODELS,
|
|
274
|
+
EFFORT_LEVELS,
|
|
275
|
+
resolveModels,
|
|
276
|
+
resolveEffort,
|
|
277
|
+
PERMISSION_CLASSES,
|
|
278
|
+
ROUTES,
|
|
279
|
+
PHASES,
|
|
280
|
+
phaseReminderLine,
|
|
281
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"routes": {
|
|
3
|
+
"trivial": [],
|
|
4
|
+
"small": ["plan", "implement", "verify"],
|
|
5
|
+
"feature": ["brainstorm", "spec", "plan", "implement", "verify", "ship"],
|
|
6
|
+
"debug": ["debug"],
|
|
7
|
+
"question": []
|
|
8
|
+
},
|
|
9
|
+
"phases": {
|
|
10
|
+
"brainstorm": { "requires": ["artifact:brainstorm.md"] },
|
|
11
|
+
"spec": { "requires": ["artifact:spec.md", "spec-confirmed"] },
|
|
12
|
+
"plan": { "requires": ["artifact:plan.md", "plannotator:plan.md"] },
|
|
13
|
+
"implement": { "requires": ["tasks-complete"] },
|
|
14
|
+
"verify": {
|
|
15
|
+
"requires": ["suite-green", "coverage-pass", "review-clean", "plannotator-review"]
|
|
16
|
+
},
|
|
17
|
+
"ship": { "requires": ["pr-recorded"] },
|
|
18
|
+
"debug": { "requires": [] }
|
|
19
|
+
},
|
|
20
|
+
"defaultModels": {
|
|
21
|
+
"claude": {
|
|
22
|
+
"scout": "haiku",
|
|
23
|
+
"scout-writer": "haiku",
|
|
24
|
+
"planner": "opus",
|
|
25
|
+
"implementer": "sonnet",
|
|
26
|
+
"implementer-frontend": "sonnet",
|
|
27
|
+
"implementer-infra": "sonnet",
|
|
28
|
+
"reviewer": "opus",
|
|
29
|
+
"debugger": "sonnet",
|
|
30
|
+
"shipper": "haiku",
|
|
31
|
+
"generalist": "sonnet",
|
|
32
|
+
"ImpulsoOrchestrator": null
|
|
33
|
+
},
|
|
34
|
+
"opencode": {
|
|
35
|
+
"scout": "llm/scout",
|
|
36
|
+
"scout-writer": "llm/scout-writer",
|
|
37
|
+
"planner": "llm/planner",
|
|
38
|
+
"implementer": "llm/implementer",
|
|
39
|
+
"implementer-frontend": "llm/implementer-frontend",
|
|
40
|
+
"implementer-infra": "llm/implementer-infra",
|
|
41
|
+
"reviewer": "llm/reviewer",
|
|
42
|
+
"debugger": "llm/debugger",
|
|
43
|
+
"shipper": "llm/shipper",
|
|
44
|
+
"generalist": "llm/generalist",
|
|
45
|
+
"ImpulsoOrchestrator": null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// impulso shared state-guard — detects bash commands that touch gate-owned
|
|
3
|
+
// state paths (.impulso/work/*/state.json or .impulso/active).
|
|
4
|
+
//
|
|
5
|
+
// Shared between the Claude Code bash-state-guard hook and the opencode
|
|
6
|
+
// orchestration before-hook so the pattern cannot drift between harnesses.
|
|
7
|
+
|
|
8
|
+
// Primary regex: word-boundary-adjacent reference to gate-owned state paths.
|
|
9
|
+
// Applied to the lowercase copy of the command string.
|
|
10
|
+
// Trailing terminator class extended to cover shell separators that were
|
|
11
|
+
// previously missing: ; ) & |
|
|
12
|
+
const STATE_FILE_RE = /(^|[\s'"=/])\.impulso\/(work\/[^\s'"\/]+\/state\.json|active)([\s'";)&|]|$)/;
|
|
13
|
+
|
|
14
|
+
// isStateGuardHit — returns true if the command string references a
|
|
15
|
+
// gate-owned path. Two checks:
|
|
16
|
+
// 1. Primary regex — covers direct references, shell redirections, pipes.
|
|
17
|
+
// 2. Co-occurrence — catches dot-segment bypasses like
|
|
18
|
+
// `.impulso/work/x/./state.json` where the primary regex can't follow /./
|
|
19
|
+
// and `.impulso/active` bypasses where the trailing char (;, ), &, |)
|
|
20
|
+
// was not in the original class.
|
|
21
|
+
function isStateGuardHit(command) {
|
|
22
|
+
const lower = command.toLowerCase();
|
|
23
|
+
if (STATE_FILE_RE.test(lower)) return true;
|
|
24
|
+
// Dot-segment bypass: both tokens appear but the path separator sequence
|
|
25
|
+
// breaks the primary regex
|
|
26
|
+
if (lower.includes('.impulso') && lower.includes('state.json')) return true;
|
|
27
|
+
// Active-file co-occurrence: .impulso + /active with word boundary
|
|
28
|
+
// (avoids false positives on .impulso/activesomething where the next char
|
|
29
|
+
// is a word character, since \b won't fire there)
|
|
30
|
+
if (lower.includes('.impulso') && /\/active\b/.test(lower)) return true;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// isGateInvocation — returns true when every segment of the command that
|
|
35
|
+
// references a gate-owned state path is itself an impulso-hub invocation.
|
|
36
|
+
//
|
|
37
|
+
// The command is split on shell separators (&&, ||, ;, |, newline).
|
|
38
|
+
// Each segment that touches a gate-owned path must match the gate-executable
|
|
39
|
+
// pattern; segments that do not reference state are ignored.
|
|
40
|
+
// For commands with no state reference at all this is trivially true.
|
|
41
|
+
function isGateInvocation(command) {
|
|
42
|
+
// Split on shell separators — simple split, no full shell parsing.
|
|
43
|
+
const segments = command.split(/&&|\|\||[;|\n]/);
|
|
44
|
+
// Gate-executable pattern: segment trimmed, starts with impulso-hub or
|
|
45
|
+
// node ...scripts/impulso-hub.cjs as the executable.
|
|
46
|
+
const GATE_RE = /(^|\s)(impulso-hub)\b/;
|
|
47
|
+
for (const seg of segments) {
|
|
48
|
+
// Only scrutinise segments that reference a gate-owned state path.
|
|
49
|
+
if (!isStateGuardHit(seg)) continue;
|
|
50
|
+
// This segment references state — it must itself be a gate invocation.
|
|
51
|
+
if (!GATE_RE.test(seg.trim())) return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { isStateGuardHit, isGateInvocation, STATE_FILE_RE };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// impulso tokensave-freshness — freshness reconciliation for the tokensave index
|
|
2
|
+
//
|
|
3
|
+
// SPIKE findings: SQLite .tokensave/tokensave.db mtime is unreliable as a
|
|
4
|
+
// reindex signal — WAL-mode in-place page updates may not bump the db file
|
|
5
|
+
// mtime. Decision: use max(mtime of all files in .tokensave/ excluding the
|
|
6
|
+
// stale marker itself) as the reference time. Any file written by a reindex
|
|
7
|
+
// (WAL, SHM, journal, or the db itself) advances this high-water mark.
|
|
8
|
+
//
|
|
9
|
+
// Interface:
|
|
10
|
+
// module.exports.reconcile(tokensaveDir) -> { stale: boolean, since?: string }
|
|
11
|
+
// Deletes the stale marker as a side effect when the index is newer.
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
function reconcile(tokensaveDir) {
|
|
19
|
+
const markerPath = path.join(tokensaveDir, 'stale');
|
|
20
|
+
|
|
21
|
+
// No marker → not stale (nothing to reconcile)
|
|
22
|
+
let markerStat;
|
|
23
|
+
try {
|
|
24
|
+
markerStat = fs.statSync(markerPath);
|
|
25
|
+
} catch (_) {
|
|
26
|
+
return { stale: false };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Reference mtime: newest file in tokensaveDir, excluding the stale marker
|
|
30
|
+
let refMtime = 0;
|
|
31
|
+
try {
|
|
32
|
+
const files = fs.readdirSync(tokensaveDir);
|
|
33
|
+
for (const f of files) {
|
|
34
|
+
if (f === 'stale') continue;
|
|
35
|
+
try {
|
|
36
|
+
const m = fs.statSync(path.join(tokensaveDir, f)).mtimeMs;
|
|
37
|
+
if (m > refMtime) refMtime = m;
|
|
38
|
+
} catch (_) {}
|
|
39
|
+
}
|
|
40
|
+
} catch (_) {}
|
|
41
|
+
|
|
42
|
+
const markerMtime = markerStat.mtimeMs;
|
|
43
|
+
|
|
44
|
+
if (refMtime >= markerMtime) {
|
|
45
|
+
// Index is at least as fresh as the marker → reconciled; delete marker
|
|
46
|
+
try {
|
|
47
|
+
fs.unlinkSync(markerPath);
|
|
48
|
+
} catch (_) {}
|
|
49
|
+
return { stale: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Marker is newer than any index file → stale
|
|
53
|
+
let since;
|
|
54
|
+
try {
|
|
55
|
+
since = fs.readFileSync(markerPath, 'utf8').split('\n')[0].trim();
|
|
56
|
+
} catch (_) {
|
|
57
|
+
since = new Date(markerMtime).toISOString();
|
|
58
|
+
}
|
|
59
|
+
return { stale: true, since };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { reconcile };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: impulso-address-pr-comments
|
|
3
|
+
description:
|
|
4
|
+
Fetch PR review comments from bots and humans, triage with per-comment
|
|
5
|
+
approval, apply fixes, commit, reply, and resolve threads
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Impulso Address PR Comments
|
|
9
|
+
|
|
10
|
+
Detect PR → fetch all comment sources → present bot/human tables → per-comment
|
|
11
|
+
approval → apply fixes → commit → reply + resolve threads → report. Zero-arg;
|
|
12
|
+
`gh` CLI only, no MCP.
|
|
13
|
+
|
|
14
|
+
## MUST
|
|
15
|
+
|
|
16
|
+
1. **Detect PR** — resolve the PR for the current branch:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gh pr view --json number,url,title,headRefName,baseRefName
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
No PR on the branch → stop: "No open PR found for this branch." Derive the
|
|
23
|
+
repo:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
gh repo view --json nameWithOwner --jq '.nameWithOwner'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Call the result `REPO` (`owner/name`) and the PR number `PR` in the steps
|
|
30
|
+
below.
|
|
31
|
+
|
|
32
|
+
2. **Fetch all comment sources**, inline (no subagent dispatch):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Reviews
|
|
36
|
+
gh api repos/<REPO>/pulls/<PR>/reviews --paginate --jq \
|
|
37
|
+
'.[] | {id, state, body, reviewer: .user.login, is_bot: (.user.type=="Bot")}'
|
|
38
|
+
|
|
39
|
+
# Inline (file/line) comments
|
|
40
|
+
gh api repos/<REPO>/pulls/<PR>/comments --paginate --jq \
|
|
41
|
+
'.[] | {id, path, line, body, diff_hunk, reviewer: .user.login, is_bot: (.user.type=="Bot"), in_reply_to_id}'
|
|
42
|
+
|
|
43
|
+
# Issue-level (PR conversation) comments — catches bot walkthroughs posted
|
|
44
|
+
# outside a formal review
|
|
45
|
+
gh api repos/<REPO>/issues/<PR>/comments --paginate --jq \
|
|
46
|
+
'.[] | {id, body, reviewer: .user.login, is_bot: (.user.type=="Bot")}'
|
|
47
|
+
|
|
48
|
+
# Review threads, for resolving later (OWNER/REPO_NAME split from REPO)
|
|
49
|
+
OWNER="${REPO%%/*}"; REPO_NAME="${REPO#*/}"
|
|
50
|
+
gh api graphql -f query='
|
|
51
|
+
query($owner: String!, $repo: String!, $pr: Int!) {
|
|
52
|
+
repository(owner: $owner, name: $repo) {
|
|
53
|
+
pullRequest(number: $pr) {
|
|
54
|
+
reviewThreads(first: 100) {
|
|
55
|
+
nodes {
|
|
56
|
+
id
|
|
57
|
+
isResolved
|
|
58
|
+
comments(first: 1) { nodes { databaseId } }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}' -f owner=<OWNER> -f repo=<REPO_NAME> -F pr=<PR> --jq \
|
|
64
|
+
'.data.repository.pullRequest.reviewThreads.nodes[] | {thread_id: .id, is_resolved: .isResolved, root_comment_id: .comments.nodes[0].databaseId}'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Classify every comment by `user.type=="Bot"` — bot or human, grouped/labeled
|
|
68
|
+
by literal login (no hardcoded bot allowlist). Group human inline replies by
|
|
69
|
+
`in_reply_to_id`; address the root thread, not each reply individually. No
|
|
70
|
+
comments found anywhere → report and stop.
|
|
71
|
+
|
|
72
|
+
3. **Present** — group bot vs human. Every comment gets a uniform severity:
|
|
73
|
+
**blocker / high / medium / low**. Human comments add an **Intent** column
|
|
74
|
+
(orthogonal to severity): bug catch / style nit / design question /
|
|
75
|
+
clarification needed / blocking.
|
|
76
|
+
|
|
77
|
+
| # | Source | Reviewer | File | Line | Severity | Intent | Suggested fix |
|
|
78
|
+
| --- | ------ | -------- | ---- | ---- | -------- | ------ | ------------- |
|
|
79
|
+
|
|
80
|
+
(Intent column applies to human rows only.)
|
|
81
|
+
|
|
82
|
+
4. **Iterate + approve**, one comment at a time. Assess each technically before
|
|
83
|
+
proposing a fix — if a suggestion is wrong, say so. Show file, line, diff
|
|
84
|
+
hunk, and the full comment text. No file edits during this step — collect
|
|
85
|
+
every decision first. Nothing is applied without explicit per-comment
|
|
86
|
+
approval.
|
|
87
|
+
|
|
88
|
+
- Bot comment options: **Accept** / **Deny** / **Custom**.
|
|
89
|
+
- Human comment options: **Fix it** / **Reply only** / **Fix and reply** /
|
|
90
|
+
**Skip** / **Discuss**.
|
|
91
|
+
|
|
92
|
+
5. **Apply** — once every comment has a decision: read each affected file, apply
|
|
93
|
+
the approved fixes with Edit, and show each change made.
|
|
94
|
+
|
|
95
|
+
6. **Commit** — run `git log --oneline -5` first to confirm the branch's actual
|
|
96
|
+
commit convention. Branch name matches `[A-Z]+-\d+`:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
type(JIRA-KEY): address PR review feedback
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Otherwise:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
type: address PR review feedback
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
One commit for all applied fixes, then push.
|
|
109
|
+
|
|
110
|
+
7. **Reply + resolve** — reply to every comment, bot and human alike (replies ≤2
|
|
111
|
+
sentences):
|
|
112
|
+
|
|
113
|
+
| Action | Reply | Resolve thread |
|
|
114
|
+
| ------------------- | ---------------------------------- | -------------- |
|
|
115
|
+
| Fixed | `Fixed: <what changed>` | Yes |
|
|
116
|
+
| Fix and reply | `Fixed: <what changed>. <context>` | Yes |
|
|
117
|
+
| Reply only | `<why no change needed>` | No |
|
|
118
|
+
| Skipped (invalid) | `Not applicable: <reason>` | Yes |
|
|
119
|
+
| Skipped (by design) | `By design: <reason>` | Yes |
|
|
120
|
+
|
|
121
|
+
Inline reply:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
gh api repos/<REPO>/pulls/<PR>/comments -f in_reply_to=<ID> -f body='<reply>'
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Issue-level (PR conversation) reply:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
gh api repos/<REPO>/issues/<PR>/comments -f body='<reply>'
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Resolve a thread:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<thread_id>"}) { thread { id isResolved } } }'
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
8. **Report** — summary counts (bot / human, each: fixed / replied / skipped)
|
|
140
|
+
plus a per-comment action table:
|
|
141
|
+
|
|
142
|
+
| # | Source | File | Line | Severity | Action | Summary |
|
|
143
|
+
| --- | ------ | ---- | ---- | -------- | ------ | ------- |
|
|
144
|
+
|
|
145
|
+
Offer to re-request review:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
gh pr edit <PR> --add-reviewer <reviewer>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Error handling
|
|
152
|
+
|
|
153
|
+
| Scenario | Action |
|
|
154
|
+
| ---------------------- | ----------------------------------------- |
|
|
155
|
+
| No PR on branch | Stop: "No open PR found for this branch." |
|
|
156
|
+
| No comments found | Report and stop. |
|
|
157
|
+
| `gh` not authenticated | Tell user to run `gh auth login`. |
|
|
158
|
+
| Bot login varies | Filter on `user.type=="Bot"`. |
|
|
159
|
+
| API rate limiting | Report, suggest waiting. |
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: impulso-brainstorm
|
|
3
|
+
description: >
|
|
4
|
+
Invoke at feature-route start: init the workspace, grill the user (batch
|
|
5
|
+
independent questions; ask dependent decisions one at a time), record
|
|
6
|
+
decisions to brainstorm.md, and advance to the spec phase.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Impulso Brainstorm
|
|
10
|
+
|
|
11
|
+
Deterministic state/precondition validation and status `--json` schema v1 are
|
|
12
|
+
owned by `impulso-hub`; this skill retains human interview, dispatch, and
|
|
13
|
+
decision logging policy.
|
|
14
|
+
|
|
15
|
+
Feature-route entry. Init workspace → grill user → write decision log → advance.
|
|
16
|
+
|
|
17
|
+
## MUST
|
|
18
|
+
|
|
19
|
+
1. Derive `<slug>` (kebab-case, ≤40 chars) from the feature name. Run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
impulso-hub init <slug> --route feature
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Exit 2 with "test_cmd required": run
|
|
26
|
+
`impulso-hub config set test_cmd "<project test command>"` then retry.
|
|
27
|
+
|
|
28
|
+
2. Run the brainstorm protocol. Batch independent questions into one message;
|
|
29
|
+
ask dependent decisions one at a time, waiting for each answer before
|
|
30
|
+
proceeding. Say verbatim first:
|
|
31
|
+
|
|
32
|
+
> Interview me relentlessly about every aspect of this plan until we reach a
|
|
33
|
+
> shared understanding. For each question, provide your recommended answer.
|
|
34
|
+
|
|
35
|
+
Ask each question via the interactive question tool (`AskUserQuestion` on
|
|
36
|
+
Claude Code, `question` on opencode) — recommended answer as the first
|
|
37
|
+
option; plain text otherwise.
|
|
38
|
+
|
|
39
|
+
Facts from the codebase via scout. Decisions go to the human. Do not enact
|
|
40
|
+
the plan until user confirms shared understanding reached.
|
|
41
|
+
|
|
42
|
+
3. Write a terse decision log:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
BRAINSTORM_PATH=$(impulso-hub workspace-path --brainstorm)
|
|
46
|
+
# Write brainstorm.md at $BRAINSTORM_PATH
|
|
47
|
+
# Format: one bullet per decision: - <decision>: <rationale ≤10 words>
|
|
48
|
+
# No filler, no prose preamble.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
4. Run `impulso-hub advance`. Exit 2 = read stderr, satisfy precondition, retry.
|