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,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.