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,89 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Impulso claude uninstall — reverses install.sh. Targeted removals only:
5
+ # strips impulso hooks from settings.json, removes the ruleset file and its
6
+ # CLAUDE.md import line, removes only repo-owned symlinks, deregisters the
7
+ # tokensave MCP. Foreign content is never touched. Never rm -rf's ~/.claude.
8
+ #
9
+ # Default profile dir: ~/.config/impulso-profile/claude
10
+ # Override with: IMPULSO_CLAUDE_PROFILE=/some/dir ./uninstall.sh
11
+
12
+ REPO="$(cd "$(dirname "$0")/../.." && pwd)"
13
+ DEFAULT_PROFILE="${HOME}/.config/impulso-profile/claude"
14
+ PROFILE_DIR="${IMPULSO_CLAUDE_PROFILE:-${DEFAULT_PROFILE}}"
15
+
16
+ echo "==> Impulso uninstall (claude)"
17
+ echo " Profile: ${PROFILE_DIR}"
18
+
19
+ if [[ ! -d "${PROFILE_DIR}" ]]; then
20
+ echo " Nothing to do — profile dir does not exist."
21
+ exit 0
22
+ fi
23
+
24
+ # Deregister tokensave MCP (user scope in this profile).
25
+ if command -v claude >/dev/null 2>&1 \
26
+ && CLAUDE_CONFIG_DIR="${PROFILE_DIR}" claude mcp get tokensave >/dev/null 2>&1; then
27
+ CLAUDE_CONFIG_DIR="${PROFILE_DIR}" claude mcp remove --scope user tokensave >/dev/null 2>&1 || true
28
+ echo " Deregistered tokensave MCP"
29
+ fi
30
+
31
+ # Strip impulso hooks from settings.json (foreign hooks/keys survive).
32
+ if [[ -f "${PROFILE_DIR}/settings.json" ]]; then
33
+ node "${REPO}/harnesses/claude/merge-settings.cjs" --remove "${PROFILE_DIR}/settings.json"
34
+ echo " Removed impulso hooks from settings.json"
35
+ fi
36
+
37
+ # Ruleset + CLAUDE.md import line.
38
+ rm -f "${PROFILE_DIR}/IMPULSO-AGENTS.md" "${PROFILE_DIR}/AGENTS.md"
39
+ if [[ -f "${PROFILE_DIR}/CLAUDE.md" ]]; then
40
+ grep -vF '@IMPULSO-AGENTS.md' "${PROFILE_DIR}/CLAUDE.md" | grep -vFx '@AGENTS.md' > "${PROFILE_DIR}/CLAUDE.md.tmp" || true
41
+ if [[ -s "${PROFILE_DIR}/CLAUDE.md.tmp" ]] && grep -q '[^[:space:]]' "${PROFILE_DIR}/CLAUDE.md.tmp"; then
42
+ mv "${PROFILE_DIR}/CLAUDE.md.tmp" "${PROFILE_DIR}/CLAUDE.md"
43
+ echo " Removed import line from CLAUDE.md"
44
+ else
45
+ rm -f "${PROFILE_DIR}/CLAUDE.md.tmp" "${PROFILE_DIR}/CLAUDE.md"
46
+ echo " Removed CLAUDE.md (contained only the impulso import)"
47
+ fi
48
+ fi
49
+
50
+ # skills/, commands/, and agents/: remove only symlinks pointing into this repo.
51
+ for sub in skills commands agents; do
52
+ p="${PROFILE_DIR}/${sub}"
53
+ if [[ -L "$p" && "$(readlink "$p")" == "${REPO}/"* ]]; then
54
+ rm "$p"
55
+ echo " Removed ${sub} symlink"
56
+ elif [[ -d "$p" ]]; then
57
+ for entry in "$p"/*; do
58
+ [[ -L "$entry" && "$(readlink "$entry")" == "${REPO}/"* ]] && rm "$entry"
59
+ done
60
+ echo " Removed repo-owned entries from ${sub}/"
61
+ fi
62
+ done
63
+
64
+ # impulso-hub binary: remove if it exists.
65
+ GATE_DST="${HOME}/.local/bin/impulso-hub"
66
+ if [[ -f "${GATE_DST}" ]]; then
67
+ rm -f "${GATE_DST}"
68
+ echo " Removed impulso-hub binary"
69
+
70
+ # Remove old impulso-gate binary if present (renamed to impulso-hub)
71
+ OLD_GATE="${HOME}/.local/bin/impulso-gate"
72
+ if [ -f "${OLD_GATE}" ] || [ -L "${OLD_GATE}" ]; then
73
+ rm -f "${OLD_GATE}" && echo " Removed old impulso-gate binary"
74
+ fi
75
+ fi
76
+ # Also clean up stale symlink from old CJS installer
77
+ if [[ -L "${GATE_DST}" ]]; then
78
+ rm -f "${GATE_DST}"
79
+ echo " Removed stale impulso-hub symlink"
80
+ fi
81
+
82
+ # Only the default isolated profile dir is removed wholesale — and never
83
+ # ~/.claude, even if someone points IMPULSO_CLAUDE_PROFILE at it.
84
+ if [[ "${PROFILE_DIR}" == "${DEFAULT_PROFILE}" && "${PROFILE_DIR}" != "${HOME}/.claude" ]]; then
85
+ rm -rf "${PROFILE_DIR}"
86
+ echo " Removed isolated profile dir"
87
+ fi
88
+
89
+ echo "==> Done."
@@ -0,0 +1,101 @@
1
+ // impulso — runtime commands loader
2
+ //
3
+ // Reads bundled commands/*.md files at plugin load time and registers each
4
+ // as a config.command[name] inline template. This avoids requiring users to
5
+ // symlink command files manually.
6
+ //
7
+ // Each command .md file has frontmatter (--- delimiters) with at minimum a
8
+ // description: field, followed by the body. The existing trailer
9
+ // ---
10
+ // **User arguments:** $ARGUMENTS
11
+ // is stripped from the body and re-appended, matching the format produced by
12
+ // sync-commands.js.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ /**
18
+ * Parse a command .md file. Command frontmatter only has a description: field;
19
+ * the command name is derived from the filename (minus .md extension).
20
+ * @returns {{ description: string, body: string } | null}
21
+ */
22
+ function parseCommandFile(content) {
23
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(content);
24
+ if (!m) return null;
25
+
26
+ const raw = m[1];
27
+ const body = m[2];
28
+
29
+ const lines = raw.split(/\r?\n/);
30
+ let description = null;
31
+
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const line = lines[i];
34
+ const descMatch = /^description:\s*(.*)$/.exec(line);
35
+ if (descMatch) {
36
+ const rest = descMatch[1].trim();
37
+ if (rest === '>' || rest === '|' || rest === '') {
38
+ const parts = [];
39
+ for (let j = i + 1; j < lines.length; j++) {
40
+ if (/^\s/.test(lines[j])) {
41
+ parts.push(lines[j].trim());
42
+ } else if (lines[j] === '') {
43
+ continue;
44
+ } else {
45
+ break;
46
+ }
47
+ }
48
+ description = parts.filter(Boolean).join(' ');
49
+ } else {
50
+ // Strip quotes if prettier added them
51
+ description = rest.replace(/^['"]|['"]$/g, '');
52
+ }
53
+ break;
54
+ }
55
+ }
56
+
57
+ if (!description) return null;
58
+ return { description, body };
59
+ }
60
+
61
+ /**
62
+ * Read commands/*.md from the given directory and register each as an
63
+ * inline config.command entry.
64
+ * @param {object} config — the opencode Config object (mutated in place)
65
+ * @param {string} commandsDir — absolute path to the commands/ directory
66
+ */
67
+ export function registerCommands(config, commandsDir) {
68
+ let files;
69
+ try {
70
+ files = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.md'));
71
+ } catch (e) {
72
+ return;
73
+ }
74
+
75
+ config.command = config.command || {};
76
+
77
+ for (const filename of files) {
78
+ const name = filename.replace(/\.md$/, '');
79
+ if (config.command[name]) continue;
80
+
81
+ try {
82
+ const raw = fs.readFileSync(path.join(commandsDir, filename), 'utf8');
83
+ const parsed = parseCommandFile(raw);
84
+ if (!parsed) {
85
+ console.warn(`[impulso] failed to parse command ${filename}: missing description`);
86
+ continue;
87
+ }
88
+
89
+ const { description, body } = parsed;
90
+ const cleanedBody = body
91
+ .replace(/^---\r?\n\*\*User arguments:\*\* \$ARGUMENTS\s*$/m, '')
92
+ .replace(/\s+$/, '');
93
+
94
+ const template = cleanedBody + '\n\n---\n\n**User arguments:** $ARGUMENTS';
95
+
96
+ config.command[name] = { template, description };
97
+ } catch (e) {
98
+ console.warn(`[impulso] failed to register command ${filename}:`, e.message);
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,111 @@
1
+ // impulso — plugin hook composer
2
+ //
3
+ // Merges an array of Hooks objects into one. For hook keys that accept
4
+ // (input, output) — e.g. chat.message, experimental.chat.system.transform,
5
+ // command.execute.before, tool.definition, experimental.chat.messages.transform —
6
+ // each sub-plugin's handler is called in sequence with the same input/output pair.
7
+ //
8
+ // For `event`, handlers are called sequentially with the event.
9
+ // For `config`, handlers are called sequentially mutating config in place.
10
+ // For `tool`, definitions are shallow-merged (last-write-wins per name).
11
+
12
+ /**
13
+ * Hook keys that receive (input, output) pairs. Names match
14
+ * @opencode-ai/plugin Hooks interface.
15
+ */
16
+ const OUTPUT_HOOKS = [
17
+ 'chat.message',
18
+ 'chat.params',
19
+ 'chat.headers',
20
+ 'experimental.chat.messages.transform',
21
+ 'experimental.chat.system.transform',
22
+ 'experimental.session.compacting',
23
+ 'experimental.compaction.autocontinue',
24
+ 'experimental.text.complete',
25
+ 'experimental.provider.small_model',
26
+ 'tool.definition',
27
+ 'tool.execute.before',
28
+ 'tool.execute.after',
29
+ 'command.execute.before',
30
+ 'shell.env',
31
+ 'permission.ask',
32
+ ];
33
+
34
+ /**
35
+ * Hook keys that receive a single config object.
36
+ */
37
+ const CONFIG_HOOKS = ['config'];
38
+
39
+ /**
40
+ * Hook keys that receive a single input object.
41
+ */
42
+ const INPUT_HOOKS = ['event'];
43
+
44
+ /**
45
+ * Hook keys whose values are objects (merged via shallow assign rather than
46
+ * called in sequence).
47
+ */
48
+ const MERGE_HOOKS = ['tool'];
49
+
50
+ /**
51
+ * @param {Array<import('@opencode-ai/plugin').Hooks>} hooksList
52
+ * @returns {import('@opencode-ai/plugin').Hooks}
53
+ */
54
+ export function composeHooks(hooksList) {
55
+ const merged = {};
56
+
57
+ // Collect handlers per strategy
58
+ const outputMap = {};
59
+ const inputList = [];
60
+ const configList = [];
61
+ const mergeMap = {};
62
+
63
+ for (const hooks of hooksList) {
64
+ for (const key of OUTPUT_HOOKS) {
65
+ if (hooks[key]) {
66
+ outputMap[key] = outputMap[key] || [];
67
+ outputMap[key].push(hooks[key]);
68
+ }
69
+ }
70
+ for (const key of INPUT_HOOKS) {
71
+ if (hooks[key]) inputList.push(hooks[key]);
72
+ }
73
+ for (const key of CONFIG_HOOKS) {
74
+ if (hooks[key]) configList.push(hooks[key]);
75
+ }
76
+ for (const key of MERGE_HOOKS) {
77
+ if (hooks[key]) {
78
+ mergeMap[key] = mergeMap[key] || {};
79
+ Object.assign(mergeMap[key], hooks[key]);
80
+ }
81
+ }
82
+ }
83
+
84
+ // Build callers for (input, output) hooks
85
+ for (const [key, handlers] of Object.entries(outputMap)) {
86
+ merged[key] = async (input, output) => {
87
+ for (const fn of handlers) await fn(input, output);
88
+ };
89
+ }
90
+
91
+ // Build caller for single-input hooks (event)
92
+ if (inputList.length > 0) {
93
+ merged.event = async (input) => {
94
+ for (const fn of inputList) await fn(input);
95
+ };
96
+ }
97
+
98
+ // Build caller for single-config hooks
99
+ if (configList.length > 0) {
100
+ merged.config = async (config) => {
101
+ for (const fn of configList) await fn(config);
102
+ };
103
+ }
104
+
105
+ // Merge object hooks (tool) into the result
106
+ for (const [key, obj] of Object.entries(mergeMap)) {
107
+ merged[key] = obj;
108
+ }
109
+
110
+ return merged;
111
+ }
@@ -0,0 +1,43 @@
1
+ # Impulso-DirectSpeech (opencode harness)
2
+
3
+ DirectSpeech is **always on**. No modes, no toggles, no flag files.
4
+
5
+ Two layers:
6
+
7
+ 1. **Ruleset** — the Impulso mode block in `shared/AGENTS.md`, loaded into every
8
+ session via the profile `AGENTS.md`.
9
+ 2. **Per-turn reinforcement** — this plugin pushes one line into the system
10
+ prompt each turn (`experimental.chat.system.transform` hook) so the style
11
+ survives long conversations and compaction:
12
+
13
+ ```
14
+ IMPULSO DIRECT SPEECH MODE ACTIVE. Terse, technical, no filler. Fragments OK.
15
+ Code/commits/security: write normal.
16
+ ```
17
+
18
+ The line lives in `shared/directspeech-config.cjs` — shared with the Claude Code
19
+ harness, which injects it via a UserPromptSubmit hook.
20
+
21
+ Escape hatches are model-level, not machinery: Auto-Clarity drops terseness for
22
+ security warnings and irreversible actions; code/commits/PRs are always written
23
+ normal.
24
+
25
+ ## File overview
26
+
27
+ ```
28
+ harnesses/opencode/directspeech/
29
+ └── plugin.js # opencode plugin (ESM, loaded via profile plugins array)
30
+ shared/
31
+ └── directspeech-config.cjs # reinforcement line (CJS, shared across harnesses)
32
+ ```
33
+
34
+ `directspeech-config.cjs` is loaded by hand (not via module loader) because the
35
+ opencode Bun binary rejects `require()` of on-disk files and `await import()` of
36
+ CJS yields an empty namespace. `plugin.js` evaluates it through `new Function()`
37
+ — a deliberate choice for this sibling file only, not a general mechanism.
38
+
39
+ ## Skills relationship
40
+
41
+ The independent skills (`/impulso-directspeech-compress`, `-stats`, `-help`) are
42
+ regular bundled skills with their own slash commands — they don't interact with
43
+ the reinforcement line.
@@ -0,0 +1,47 @@
1
+ // impulso-directspeech — opencode plugin
2
+ //
3
+ // DirectSpeech is always on: inject the per-turn reinforcement line into the
4
+ // system prompt, unconditionally. No flags, no toggles, no modes. The full
5
+ // ruleset comes from the profile AGENTS.md; this is the drift guard.
6
+ //
7
+ // Bun ESM module; loads the shared reinforcement line from
8
+ // shared/directspeech-config.cjs via a Function-constructor CJS shim
9
+ // (the Bun binary rejects require() of on-disk CJS, and await import()
10
+ // of CJS yields an empty namespace).
11
+ //
12
+ // Hook mapping (opencode >= 1.15.x):
13
+ // - experimental.chat.system.transform: inject reinforcement
14
+
15
+ import { createRequire } from 'node:module';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { dirname, join } from 'node:path';
18
+ import { readFileSync } from 'node:fs';
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+
22
+ function loadConfig() {
23
+ const target = join(here, '../../../shared/directspeech-config.cjs');
24
+ const code = readFileSync(target, 'utf8').replace(/^#![^\n]*\n/, '');
25
+ const mod = { exports: {} };
26
+ new Function('module', 'exports', 'require', '__dirname', '__filename', code)(
27
+ mod,
28
+ mod.exports,
29
+ createRequire(import.meta.url),
30
+ dirname(target),
31
+ target,
32
+ );
33
+ return mod.exports;
34
+ }
35
+
36
+ const { reinforcementLine } = loadConfig();
37
+
38
+ export const ImpulsoDirectSpeechPlugin = async (_ctx) => {
39
+ return {
40
+ 'experimental.chat.system.transform': async (_input, output) => {
41
+ if (!output || !Array.isArray(output.system)) return;
42
+ output.system.push(reinforcementLine());
43
+ },
44
+ };
45
+ };
46
+
47
+ export default ImpulsoDirectSpeechPlugin;
@@ -0,0 +1,60 @@
1
+ // Shared frontmatter parser — used by sync-commands.js (build-time) and
2
+ // commands-loader.js (runtime plugin).
3
+
4
+ /**
5
+ * Parse YAML frontmatter from a SKILL.md or command .md file.
6
+ * Returns { name, description, body }.
7
+ * Throws on missing/malformed frontmatter.
8
+ */
9
+ export function parseFrontmatter(content) {
10
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(content);
11
+ if (!m) {
12
+ throw new Error('missing or malformed frontmatter (--- delimiters)');
13
+ }
14
+ const raw = m[1];
15
+ const body = m[2];
16
+
17
+ const lines = raw.split(/\r?\n/);
18
+ let name = null;
19
+ let description = null;
20
+
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const line = lines[i];
23
+ const nameMatch = /^name:\s*(.*)$/.exec(line);
24
+ if (nameMatch) {
25
+ name = nameMatch[1].trim();
26
+ continue;
27
+ }
28
+ const descMatch = /^description:\s*(.*)$/.exec(line);
29
+ if (descMatch) {
30
+ const rest = descMatch[1].trim();
31
+ if (rest === '>' || rest === '|' || rest === '') {
32
+ // Folded/literal block (`>` / `|`) OR multi-line plain scalar with
33
+ // value on continuation lines (prettier sometimes rewrote `>` blocks
34
+ // into this form). Either way, collect indented following lines.
35
+ const parts = [];
36
+ for (let j = i + 1; j < lines.length; j++) {
37
+ if (/^\s/.test(lines[j])) {
38
+ parts.push(lines[j].trim());
39
+ } else if (lines[j] === '') {
40
+ continue;
41
+ } else {
42
+ break;
43
+ }
44
+ }
45
+ description = parts.filter(Boolean).join(' ');
46
+ } else {
47
+ description = rest;
48
+ }
49
+ continue;
50
+ }
51
+ }
52
+
53
+ if (!name) {
54
+ throw new Error('frontmatter missing `name:`');
55
+ }
56
+ if (!description) {
57
+ throw new Error('frontmatter missing `description:`');
58
+ }
59
+ return { name, description, body };
60
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Impulso opencode install — creates the isolated profile dir, symlinks
5
+ # skills/commands from this repo, writes opencode.jsonc + AGENTS.md.
6
+ # Idempotent — safe to rerun.
7
+
8
+ REPO="$(cd "$(dirname "$0")/../.." && pwd)"
9
+ PROFILE_DIR="${HOME}/.config/impulso-profile/opencode"
10
+
11
+ echo "==> Impulso install (opencode)"
12
+ echo " Repo: ${REPO}"
13
+ echo " Profile: ${PROFILE_DIR}"
14
+
15
+ # --- Preflight: hard-required binaries -------------------------------------
16
+ # Impulso is opinionated: tokensave (MCP server) and rtk (CLI token-filter
17
+ # proxy) must be on PATH. Missing either aborts install before touching
18
+ # the filesystem.
19
+
20
+ require_bin() {
21
+ local bin="$1"
22
+ local hint="$2"
23
+ if ! command -v "$bin" >/dev/null 2>&1; then
24
+ echo "ERROR: '$bin' not found on PATH. Impulso requires it." >&2
25
+ echo " $hint" >&2
26
+ exit 1
27
+ fi
28
+ }
29
+
30
+ require_bin rtk "Install: brew install rtk-ai/tap/rtk (see https://www.rtk-ai.app)"
31
+ require_bin tokensave "Install: brew install aovestdipaperino/tap/tokensave (or: cargo install tokensave)"
32
+
33
+ # --- Global gitignore for tool artifacts ------------------------------------
34
+ # .tokensave/ (per-project SQLite code graph) and .rtk/ (cache) must never
35
+ # land in a target repo. Write to a global git excludesfile so any repo
36
+ # the user touches with Impulso is protected. Prompted; default yes.
37
+
38
+ ensure_global_gitignore() {
39
+ local ignore_file="${HOME}/.config/git/ignore"
40
+ local entries=(".tokensave/" ".rtk/")
41
+ local answer="y"
42
+
43
+ echo
44
+ echo "Impulso can add .tokensave/ and .rtk/ to a global git excludesfile"
45
+ echo "so they are never committed to any target repo."
46
+ printf "Write to %s? [Y/n] " "${ignore_file}"
47
+ read -r answer || answer=""
48
+
49
+ # ${answer,,} is bash 4+; macOS ships bash 3.2 — use case instead.
50
+ case "${answer}" in
51
+ '' | [Yy]) ;;
52
+ *)
53
+ echo " Skipped. Add these lines manually if you change your mind:"
54
+ printf " %s\n" "${entries[@]}"
55
+ echo " then: git config --global core.excludesfile <path>"
56
+ return 0
57
+ ;;
58
+ esac
59
+
60
+ mkdir -p "$(dirname "${ignore_file}")"
61
+ touch "${ignore_file}"
62
+
63
+ local entry
64
+ for entry in "${entries[@]}"; do
65
+ if ! grep -Fxq -- "${entry}" "${ignore_file}" 2>/dev/null; then
66
+ printf '%s\n' "${entry}" >> "${ignore_file}"
67
+ echo " Added: ${entry}"
68
+ else
69
+ echo " Already present: ${entry}"
70
+ fi
71
+ done
72
+
73
+ git config --global core.excludesfile "${ignore_file}"
74
+ echo " core.excludesfile = ${ignore_file}"
75
+ }
76
+
77
+ ensure_global_gitignore
78
+
79
+ # --- Profile setup ----------------------------------------------------------
80
+
81
+ mkdir -p "${PROFILE_DIR}"
82
+
83
+ # Symlink skills/ and commands/ into the profile. Use -sfn (force, no-deref
84
+ # target) so we don't follow into the target when replacing.
85
+ for sub in skills commands; do
86
+ link_path="${PROFILE_DIR}/${sub}"
87
+ target="${REPO}/${sub}"
88
+ if [[ -L "${link_path}" ]] || [[ -e "${link_path}" ]]; then
89
+ rm -rf "${link_path}"
90
+ fi
91
+ ln -sfn "${target}" "${link_path}"
92
+ echo " Linked ${sub} -> ${target}"
93
+ done
94
+
95
+ # Copy AGENTS.md from the canonical shared/ source (copy, not symlink —
96
+ # profile self-contained).
97
+ cp -f "${REPO}/shared/AGENTS.md" "${PROFILE_DIR}/AGENTS.md"
98
+ echo " Copied AGENTS.md"
99
+
100
+ # Generate opencode.jsonc from the self-boot config (single source of truth):
101
+ # the self-boot file resolves plugins relative to the config dir ("../...");
102
+ # the profile pins them to this repo's absolute path instead.
103
+ sed "s|\"\.\./|\"${REPO}/|g" "${REPO}/opencode/opencode.jsonc" > "${PROFILE_DIR}/opencode.jsonc"
104
+ echo " Wrote opencode.jsonc (plugins -> ${REPO}/harnesses/opencode)"
105
+
106
+ # --- impulso-hub binary — copy matching bundled package binary -------------
107
+ node "${REPO}/scripts/install-hub.js"
108
+
109
+ if ! command -v impulso-hub >/dev/null 2>&1; then
110
+ echo " NOTE: ~/.local/bin may not be on PATH. Add to your shell profile:"
111
+ echo ' export PATH="$HOME/.local/bin:$PATH"'
112
+ fi
113
+
114
+ # Remove old impulso-gate binary if present (renamed to impulso-hub)
115
+ OLD_GATE="${HOME}/.local/bin/impulso-gate"
116
+ if [ -f "${OLD_GATE}" ] || [ -L "${OLD_GATE}" ]; then
117
+ rm -f "${OLD_GATE}" && echo " Removed old impulso-gate binary"
118
+ fi
119
+
120
+ echo "==> Done. Launch with:"
121
+ echo " XDG_CONFIG_HOME=${HOME}/.config/impulso-profile opencode"