commit 0afe018452143f49eba3e6253db1d21a001948c8 Author: L'électron rare <108685187+electron-rare@users.noreply.github.com> Date: Sat Mar 21 09:07:11 2026 +0100 feat: initialize VS Code Studio extension diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..068db39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +out/ +.DS_Store +.vscode-test/ +*.vsix diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c5083e1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Kill LIFE Studio", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ], + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "preLaunchTask": "npm: compile" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..f8aeba6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,22 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "compile", + "problemMatcher": "$tsc", + "group": { + "kind": "build", + "isDefault": true + }, + "label": "npm: compile" + }, + { + "type": "npm", + "script": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "label": "npm: watch" + } + ] +} diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..37ace7f --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,5 @@ +node_modules/** +.vscode/** +src/** +tsconfig.json +README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3307c84 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 electron-rare + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..17d60df --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Kill LIFE Studio for VS Code + +Extension VS Code orientee produit pour relier plusieurs projets locaux, reutiliser leurs prompts agents et transformer des idees en specs, cadrage UX et roadmap. + +## Positionnement + +`Kill LIFE Studio` est la version produit du trio : + +- `Studio` pour le cadrage produit, les specs, la narration, le scope et la clarte des livrables. +- `Mesh` pour l'orchestration multi-repo et les handoffs. +- `Operator` pour l'execution et l'operationnel. + +## Ce que fait cette extension + +- Scanne chaque projet ouvert dans le workspace, plus les racines ajoutees manuellement. +- Detecte par defaut `agents/*.md` et `.github/agents/*.md`. +- Affiche les agents trouves dans une sidebar dediee. +- Permet d'ouvrir un prompt agent, de le copier, ou de l'inserer dans l'editeur courant. +- Expose un participant de chat `@killstudio` avec des commandes `/agents`, `/pm`, `/architect`, `/firmware`, `/hw`, `/qa`, `/doc`. + +Par defaut, `@killstudio` repond avec un biais produit : besoins, priorisation, experience, spec-first et plan d'execution. + +## Demarrage rapide + +```bash +npm install +npm run compile +``` + +Ensuite dans VS Code : + +1. Ouvre ce repo. +2. Lance `F5` pour ouvrir une fenetre d'extension development host. +3. Dans la nouvelle fenetre, ouvre ton vrai workspace projet. +4. Si besoin, ajoute d'autres depots via la commande `Kill LIFE Studio: Add Linked Project Root`. + +## Utilisation + +### Sidebar + +La vue `Kill LIFE Studio` liste les projets detectes puis les agents trouves. + +- Clic sur un agent : ouvre son fichier Markdown. +- Menu contextuel : copie ou insere le prompt. + +### Chat + +Dans le chat Copilot / agent de VS Code : + +- `@killstudio /agents` : liste les projets et agents lies. +- `@killstudio /pm` : route la demande avec le prompt PM. +- `@killstudio /architect` : route la demande avec le prompt architecture. +- `@killstudio /doc` : route la demande avec le prompt documentation. +- `@killstudio` sans commande : agit comme studio produit relie aux agents du projet actif. + +## Reglages + +### `killLifeStudio.linkedProjectRoots` + +Liste de chemins absolus supplementaires a scanner, en plus des dossiers deja ouverts dans le workspace. + +### `killLifeStudio.agentSearchPatterns` + +Globs utilises pour decouvrir les prompts d'agents dans chaque racine projet. + +Valeurs par defaut : + +```json +[ + "agents/*.md", + ".github/agents/*.md" +] +``` diff --git a/media/kill-life.svg b/media/kill-life.svg new file mode 100644 index 0000000..6813a35 --- /dev/null +++ b/media/kill-life.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d9aca3e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,287 @@ +{ + "name": "kill-life-studio", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kill-life-studio", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "@types/vscode": "^1.110.0", + "typescript": "^5.9.3" + }, + "engines": { + "vscode": "^1.110.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f335cb4 --- /dev/null +++ b/package.json @@ -0,0 +1,184 @@ +{ + "name": "kill-life-studio", + "displayName": "Kill LIFE Studio", + "description": "Product-focused VS Code extension that turns linked local agent prompts into a studio for specs, UX, and roadmap work.", + "version": "0.1.0", + "publisher": "electron-rare", + "license": "MIT", + "engines": { + "vscode": "^1.110.0" + }, + "categories": [ + "AI", + "Other" + ], + "keywords": [ + "agents", + "copilot", + "chat", + "embedded", + "kill-life" + ], + "activationEvents": [ + "onChatParticipant:kill-life-studio.orchestrator" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "killLifeStudio.refreshAgents", + "title": "Kill LIFE Studio: Refresh Agents" + }, + { + "command": "killLifeStudio.addLinkedProjectRoot", + "title": "Kill LIFE Studio: Add Linked Project Root" + }, + { + "command": "killLifeStudio.removeLinkedProjectRoot", + "title": "Kill LIFE Studio: Remove Linked Project Root" + }, + { + "command": "killLifeStudio.openAgentFile", + "title": "Kill LIFE Studio: Open Agent File" + }, + { + "command": "killLifeStudio.copyAgentPrompt", + "title": "Kill LIFE Studio: Copy Agent Prompt" + }, + { + "command": "killLifeStudio.insertAgentPrompt", + "title": "Kill LIFE Studio: Insert Agent Prompt" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "killLifeStudioSidebar", + "title": "Kill LIFE Studio", + "icon": "media/kill-life.svg" + } + ] + }, + "views": { + "killLifeStudioSidebar": [ + { + "id": "killLifeStudio.agentsView", + "name": "Linked Agents" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "killLifeStudio.refreshAgents", + "when": "view == killLifeStudio.agentsView", + "group": "navigation@1" + }, + { + "command": "killLifeStudio.addLinkedProjectRoot", + "when": "view == killLifeStudio.agentsView", + "group": "navigation@2" + }, + { + "command": "killLifeStudio.removeLinkedProjectRoot", + "when": "view == killLifeStudio.agentsView", + "group": "navigation@3" + } + ], + "view/item/context": [ + { + "command": "killLifeStudio.openAgentFile", + "when": "view == killLifeStudio.agentsView && viewItem == agentItem", + "group": "inline" + }, + { + "command": "killLifeStudio.copyAgentPrompt", + "when": "view == killLifeStudio.agentsView && viewItem == agentItem", + "group": "navigation@1" + }, + { + "command": "killLifeStudio.insertAgentPrompt", + "when": "view == killLifeStudio.agentsView && viewItem == agentItem", + "group": "navigation@2" + } + ] + }, + "configuration": { + "title": "Kill LIFE Studio", + "properties": { + "killLifeStudio.linkedProjectRoots": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "Additional absolute project roots to scan for agent markdown files." + }, + "killLifeStudio.agentSearchPatterns": { + "type": "array", + "default": [ + "agents/*.md", + ".github/agents/*.md" + ], + "items": { + "type": "string" + }, + "description": "Glob patterns resolved relative to each project root." + } + } + }, + "chatParticipants": [ + { + "id": "kill-life-studio.orchestrator", + "name": "killstudio", + "fullName": "Kill LIFE Studio", + "description": "Shape product work from your linked local project agents.", + "isSticky": true, + "commands": [ + { + "name": "agents", + "description": "List linked projects and discovered agents." + }, + { + "name": "pm", + "description": "Use the project management agent prompt." + }, + { + "name": "architect", + "description": "Use the architecture agent prompt." + }, + { + "name": "firmware", + "description": "Use the firmware agent prompt." + }, + { + "name": "hw", + "description": "Use the hardware schematic agent prompt." + }, + { + "name": "qa", + "description": "Use the QA agent prompt." + }, + { + "name": "doc", + "description": "Use the documentation agent prompt." + } + ] + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "check": "tsc --noEmit -p ./" + }, + "dependencies": { + "fast-glob": "^3.3.3" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "@types/vscode": "^1.110.0", + "typescript": "^5.9.3" + } +} diff --git a/src/agentCatalog.ts b/src/agentCatalog.ts new file mode 100644 index 0000000..f666df9 --- /dev/null +++ b/src/agentCatalog.ts @@ -0,0 +1,232 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import fg from 'fast-glob'; +import * as vscode from 'vscode'; +import { getAllProjectRoots, getSearchPatterns } from './config'; + +const SEARCH_IGNORES = [ + '**/.git/**', + '**/node_modules/**', + '**/out/**', + '**/dist/**', + '**/coverage/**', + '**/target/**' +]; + +const ROLE_LABELS: Record = { + architect: 'Architect Agent', + doc: 'Documentation Agent', + firmware: 'Firmware Agent', + hw: 'Hardware Agent', + pm: 'PM Agent', + qa: 'QA Agent' +}; + +export interface AgentDefinition { + id: string; + role: string; + title: string; + description: string; + body: string; + file: vscode.Uri; + projectName: string; + projectRoot: string; + relativePath: string; +} + +export interface ProjectSummary { + name: string; + root: string; + agents: AgentDefinition[]; +} + +function inferRole(relativePath: string): string { + const base = path.basename(relativePath, '.md').toLowerCase().replace(/[_-]?agent$/, ''); + if (base === 'hw_schematic' || base === 'hw-schematic' || base === 'hwschematic') { + return 'hw'; + } + + return base; +} + +function toDisplayRole(role: string): string { + return ROLE_LABELS[role] ?? `${role.replace(/[_-]+/g, ' ')} agent`; +} + +function extractTitle(body: string, role: string): string { + for (const line of body.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (trimmed.startsWith('# ')) { + return trimmed.slice(2).trim(); + } + } + + return toDisplayRole(role); +} + +function extractDescription(body: string, role: string): string { + for (const line of body.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('>') || trimmed.startsWith('-')) { + continue; + } + + return trimmed; + } + + return `Prompt source for ${toDisplayRole(role)}.`; +} + +function findOwningProjectRoot(filePath: string, projectRoots: string[]): string | undefined { + return [...projectRoots] + .sort((left, right) => right.length - left.length) + .find(root => filePath === root || filePath.startsWith(`${root}${path.sep}`)); +} + +function compareAgents(left: AgentDefinition, right: AgentDefinition): number { + return ( + left.projectName.localeCompare(right.projectName) || + left.role.localeCompare(right.role) || + left.relativePath.localeCompare(right.relativePath) + ); +} + +export function groupAgentsByProject(agents: AgentDefinition[]): ProjectSummary[] { + const grouped = new Map(); + + for (const agent of agents) { + const existing = grouped.get(agent.projectRoot); + if (existing) { + existing.agents.push(agent); + continue; + } + + grouped.set(agent.projectRoot, { + name: agent.projectName, + root: agent.projectRoot, + agents: [agent] + }); + } + + return [...grouped.values()] + .map(project => ({ + ...project, + agents: [...project.agents].sort(compareAgents) + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function resolveProjectRootForActiveEditor(projectRoots: string[]): string | undefined { + const activeDocument = vscode.window.activeTextEditor?.document.uri; + if (!activeDocument || activeDocument.scheme !== 'file') { + return projectRoots[0]; + } + + return [...projectRoots] + .sort((left, right) => right.length - left.length) + .find(root => activeDocument.fsPath === root || activeDocument.fsPath.startsWith(`${root}${path.sep}`)) + ?? projectRoots[0]; +} + +export function selectAgentForRole( + agents: AgentDefinition[], + role: string, + preferredProjectRoot?: string +): AgentDefinition | undefined { + const candidates = agents.filter(agent => agent.role === role); + if (!candidates.length) { + return undefined; + } + + return candidates.find(agent => agent.projectRoot === preferredProjectRoot) ?? candidates[0]; +} + +export class AgentCatalog { + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + private agents: AgentDefinition[] = []; + private loaded = false; + private lastError: string | undefined; + + readonly onDidChange = this.onDidChangeEmitter.event; + + getAgents(): AgentDefinition[] { + return [...this.agents]; + } + + getProjects(): ProjectSummary[] { + return groupAgentsByProject(this.agents); + } + + getLastError(): string | undefined { + return this.lastError; + } + + async ensureLoaded(): Promise { + if (!this.loaded) { + await this.refresh(); + } + } + + async refresh(): Promise { + try { + const projectRoots = getAllProjectRoots(); + const patterns = getSearchPatterns(); + const discoveredFiles = new Set(); + + for (const projectRoot of projectRoots) { + const matches = await fg(patterns, { + cwd: projectRoot, + absolute: true, + onlyFiles: true, + unique: true, + followSymbolicLinks: false, + ignore: SEARCH_IGNORES + }); + + for (const match of matches) { + discoveredFiles.add(path.resolve(match)); + } + } + + const nextAgents: AgentDefinition[] = []; + const orderedFiles = [...discoveredFiles].sort((left, right) => left.localeCompare(right)); + + for (const filePath of orderedFiles) { + const projectRoot = findOwningProjectRoot(filePath, projectRoots); + if (!projectRoot) { + continue; + } + + const body = await fs.readFile(filePath, 'utf8'); + const relativePath = path.relative(projectRoot, filePath); + const role = inferRole(relativePath); + + nextAgents.push({ + id: `${projectRoot}:${relativePath}`, + role, + title: extractTitle(body, role), + description: extractDescription(body, role), + body, + file: vscode.Uri.file(filePath), + projectName: path.basename(projectRoot), + projectRoot, + relativePath + }); + } + + this.agents = nextAgents.sort(compareAgents); + this.lastError = undefined; + this.loaded = true; + } catch (error) { + this.agents = []; + this.lastError = error instanceof Error ? error.message : String(error); + this.loaded = true; + } finally { + this.onDidChangeEmitter.fire(); + } + } + + dispose(): void { + this.onDidChangeEmitter.dispose(); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..44a0934 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,83 @@ +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +const NAMESPACE = 'killLifeStudio'; +const LINKED_PROJECT_ROOTS_KEY = 'linkedProjectRoots'; +const SEARCH_PATTERNS_KEY = 'agentSearchPatterns'; + +const DEFAULT_SEARCH_PATTERNS = ['agents/*.md', '.github/agents/*.md']; + +function getConfiguration(): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration(NAMESPACE); +} + +function normalizeProjectRoot(input: string): string { + return path.resolve(input.trim()); +} + +function getConfigurationTarget(): vscode.ConfigurationTarget { + return vscode.workspace.workspaceFile || vscode.workspace.workspaceFolders?.length + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; +} + +export function getLinkedProjectRoots(): string[] { + const configured = getConfiguration().get(LINKED_PROJECT_ROOTS_KEY) ?? []; + return [...new Set(configured.map(normalizeProjectRoot).filter(Boolean))]; +} + +export function getSearchPatterns(): string[] { + const configured = getConfiguration().get(SEARCH_PATTERNS_KEY) ?? []; + const cleaned = configured.map(pattern => pattern.trim()).filter(Boolean); + return cleaned.length ? cleaned : DEFAULT_SEARCH_PATTERNS; +} + +export function getAllProjectRoots(): string[] { + const workspaceRoots = (vscode.workspace.workspaceFolders ?? []).map(folder => folder.uri.fsPath); + return [...new Set([...workspaceRoots, ...getLinkedProjectRoots()].map(normalizeProjectRoot))]; +} + +export async function addLinkedProjectRoot(): Promise { + const selection = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: 'Link project root' + }); + + const selected = selection?.[0]; + if (!selected) { + return; + } + + const nextRoot = normalizeProjectRoot(selected.fsPath); + const current = getLinkedProjectRoots(); + if (current.includes(nextRoot)) { + void vscode.window.showInformationMessage(`Project root already linked: ${nextRoot}`); + return; + } + + const updated = [...current, nextRoot].sort((left, right) => left.localeCompare(right)); + await getConfiguration().update(LINKED_PROJECT_ROOTS_KEY, updated, getConfigurationTarget()); + void vscode.window.showInformationMessage(`Linked project root added: ${nextRoot}`); +} + +export async function removeLinkedProjectRoot(): Promise { + const current = getLinkedProjectRoots(); + if (!current.length) { + void vscode.window.showInformationMessage('No linked project roots configured.'); + return; + } + + const picked = await vscode.window.showQuickPick(current, { + placeHolder: 'Select a linked project root to remove' + }); + + if (!picked) { + return; + } + + const updated = current.filter(root => root !== picked); + await getConfiguration().update(LINKED_PROJECT_ROOTS_KEY, updated, getConfigurationTarget()); + void vscode.window.showInformationMessage(`Linked project root removed: ${picked}`); +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..30251f3 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,265 @@ +import * as vscode from 'vscode'; +import { + AgentCatalog, + AgentDefinition, + resolveProjectRootForActiveEditor, + selectAgentForRole +} from './agentCatalog'; +import { addLinkedProjectRoot, getAllProjectRoots, removeLinkedProjectRoot } from './config'; +import { AgentsTreeProvider } from './tree'; + +const PARTICIPANT_ID = 'kill-life-studio.orchestrator'; + +const ROLE_COMMANDS = new Set(['pm', 'architect', 'firmware', 'hw', 'qa', 'doc']); + +function buildRosterMarkdown(agents: AgentDefinition[]): string { + if (!agents.length) { + return 'No agents discovered yet. Open a workspace or add linked project roots first.'; + } + + const grouped = new Map(); + for (const agent of agents) { + const bucket = grouped.get(agent.projectName) ?? []; + bucket.push(agent); + grouped.set(agent.projectName, bucket); + } + + const lines: string[] = ['# Linked agents']; + for (const [projectName, projectAgents] of [...grouped.entries()].sort((left, right) => left[0].localeCompare(right[0]))) { + lines.push(`## ${projectName}`); + for (const agent of projectAgents.sort((left, right) => left.role.localeCompare(right.role))) { + lines.push(`- \`${agent.role}\` - ${agent.title} (\`${agent.relativePath}\`)`); + } + } + + return lines.join('\n'); +} + +function buildGroundingPrompt( + agents: AgentDefinition[], + preferredProjectRoot: string | undefined, + selectedAgent: AgentDefinition | undefined +): string { + const projectAgents = preferredProjectRoot + ? agents.filter(agent => agent.projectRoot === preferredProjectRoot) + : agents; + + const roster = (projectAgents.length ? projectAgents : agents) + .map(agent => `- ${agent.role}: ${agent.title} (${agent.projectName} / ${agent.relativePath})`) + .join('\n'); + + const projectLine = preferredProjectRoot + ? `Active project root: ${preferredProjectRoot}` + : 'No active project root detected.'; + + const agentBlock = selectedAgent + ? `Selected agent prompt:\n${selectedAgent.body}` + : 'No single agent command was selected. Act as a product studio that coordinates PM, architecture, documentation, and implementation framing.'; + + return [ + 'You are Kill LIFE Studio inside VS Code.', + 'You focus on product thinking, scope, UX, specs, roadmap clarity, and turning ideas into executable work.', + 'Ground every answer in the linked local project agents listed below.', + 'Do not invent files, commands, standards, or project state that were not provided.', + 'If information is missing, say what is missing and ask for the smallest useful next input.', + projectLine, + 'Available agents:', + roster || '- none', + agentBlock + ].join('\n\n'); +} + +function extractHistory(context: vscode.ChatContext): vscode.LanguageModelChatMessage[] { + const previousMessages = context.history.filter( + historyTurn => historyTurn instanceof vscode.ChatResponseTurn + ); + + const messages: vscode.LanguageModelChatMessage[] = []; + for (const previousMessage of previousMessages) { + let fullMessage = ''; + previousMessage.response.forEach(part => { + const markdownPart = part as vscode.ChatResponseMarkdownPart; + fullMessage += markdownPart.value.value; + }); + + if (fullMessage.trim()) { + messages.push(vscode.LanguageModelChatMessage.Assistant(fullMessage)); + } + } + + return messages; +} + +async function pickAgent( + catalog: AgentCatalog, + placeHolder: string +): Promise { + await catalog.ensureLoaded(); + const agents = catalog.getAgents(); + if (!agents.length) { + void vscode.window.showWarningMessage('No agents discovered yet.'); + return undefined; + } + + const picked = await vscode.window.showQuickPick( + agents.map(agent => ({ + label: agent.title, + description: `${agent.projectName} • ${agent.role}`, + detail: agent.relativePath, + agent + })), + { placeHolder } + ); + + return picked?.agent; +} + +async function openAgentFile(agent: AgentDefinition): Promise { + await vscode.window.showTextDocument(agent.file, { + preview: false + }); +} + +async function copyAgentPrompt(agent: AgentDefinition): Promise { + await vscode.env.clipboard.writeText(agent.body); + void vscode.window.showInformationMessage(`Copied prompt: ${agent.title}`); +} + +async function insertAgentPrompt(agent: AgentDefinition): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + const document = await vscode.workspace.openTextDocument({ + content: agent.body, + language: 'markdown' + }); + await vscode.window.showTextDocument(document, { preview: false }); + return; + } + + await editor.edit(editBuilder => { + editBuilder.insert(editor.selection.active, agent.body); + }); +} + +export function activate(context: vscode.ExtensionContext): void { + const catalog = new AgentCatalog(); + const treeProvider = new AgentsTreeProvider(catalog); + + context.subscriptions.push(catalog, treeProvider); + context.subscriptions.push( + vscode.window.registerTreeDataProvider('killLifeStudio.agentsView', treeProvider) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('killLifeStudio.refreshAgents', async () => { + await catalog.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('killLifeStudio.addLinkedProjectRoot', async () => { + await addLinkedProjectRoot(); + await catalog.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('killLifeStudio.removeLinkedProjectRoot', async () => { + await removeLinkedProjectRoot(); + await catalog.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('killLifeStudio.openAgentFile', async (agent?: AgentDefinition) => { + const resolvedAgent = agent ?? (await pickAgent(catalog, 'Select an agent file to open')); + if (resolvedAgent) { + await openAgentFile(resolvedAgent); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('killLifeStudio.copyAgentPrompt', async (agent?: AgentDefinition) => { + const resolvedAgent = agent ?? (await pickAgent(catalog, 'Select an agent prompt to copy')); + if (resolvedAgent) { + await copyAgentPrompt(resolvedAgent); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('killLifeStudio.insertAgentPrompt', async (agent?: AgentDefinition) => { + const resolvedAgent = agent ?? (await pickAgent(catalog, 'Select an agent prompt to insert')); + if (resolvedAgent) { + await insertAgentPrompt(resolvedAgent); + } + }) + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('killLifeStudio')) { + void catalog.refresh(); + } + }) + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => { + void catalog.refresh(); + }) + ); + + const chatHandler: vscode.ChatRequestHandler = async ( + request, + chatContext, + stream, + token + ) => { + await catalog.ensureLoaded(); + + const agents = catalog.getAgents(); + if (request.command === 'agents') { + stream.markdown(buildRosterMarkdown(agents)); + return; + } + + if (!agents.length) { + stream.markdown( + 'No agents discovered yet. Open a project workspace or run `Kill LIFE Studio: Add Linked Project Root` first.' + ); + return; + } + + const projectRoots = getAllProjectRoots(); + const preferredProjectRoot = resolveProjectRootForActiveEditor(projectRoots); + const selectedRole = request.command && ROLE_COMMANDS.has(request.command) ? request.command : undefined; + const selectedAgent = selectedRole + ? selectAgentForRole(agents, selectedRole, preferredProjectRoot) + : undefined; + + const messages: vscode.LanguageModelChatMessage[] = [ + vscode.LanguageModelChatMessage.User( + buildGroundingPrompt(agents, preferredProjectRoot, selectedAgent) + ), + ...extractHistory(chatContext), + vscode.LanguageModelChatMessage.User( + request.prompt.trim() || 'Provide a concise product next-step plan for the selected agent.' + ) + ]; + + const response = await request.model.sendRequest(messages, {}, token); + for await (const fragment of response.text) { + stream.markdown(fragment); + } + }; + + const participant = vscode.chat.createChatParticipant(PARTICIPANT_ID, chatHandler); + participant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'kill-life.svg'); + context.subscriptions.push(participant); + + void catalog.refresh(); +} + +export function deactivate(): void {} diff --git a/src/tree.ts b/src/tree.ts new file mode 100644 index 0000000..596163c --- /dev/null +++ b/src/tree.ts @@ -0,0 +1,99 @@ +import * as vscode from 'vscode'; +import { AgentCatalog, AgentDefinition, ProjectSummary } from './agentCatalog'; + +class ProjectNode { + readonly kind = 'project'; + + constructor(readonly project: ProjectSummary) {} +} + +class AgentNode { + readonly kind = 'agent'; + + constructor(readonly agent: AgentDefinition) {} +} + +class InfoNode { + readonly kind = 'info'; + + constructor(readonly message: string) {} +} + +export type AgentsTreeNode = ProjectNode | AgentNode | InfoNode; + +export class AgentsTreeProvider implements vscode.TreeDataProvider { + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + + readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + + constructor(private readonly catalog: AgentCatalog) { + this.catalog.onDidChange(() => this.refresh()); + } + + refresh(): void { + this.onDidChangeTreeDataEmitter.fire(undefined); + } + + getTreeItem(element: AgentsTreeNode): vscode.TreeItem { + if (element.kind === 'info') { + const infoItem = new vscode.TreeItem(element.message, vscode.TreeItemCollapsibleState.None); + infoItem.contextValue = 'infoItem'; + infoItem.iconPath = new vscode.ThemeIcon('info'); + return infoItem; + } + + if (element.kind === 'project') { + const projectItem = new vscode.TreeItem( + element.project.name, + vscode.TreeItemCollapsibleState.Expanded + ); + projectItem.description = `${element.project.agents.length} agent${element.project.agents.length > 1 ? 's' : ''}`; + projectItem.tooltip = element.project.root; + projectItem.contextValue = 'projectItem'; + projectItem.iconPath = new vscode.ThemeIcon('repo'); + return projectItem; + } + + const agent = element.agent; + const agentItem = new vscode.TreeItem(agent.title, vscode.TreeItemCollapsibleState.None); + agentItem.description = agent.role; + agentItem.tooltip = `${agent.description}\n${agent.file.fsPath}`; + agentItem.resourceUri = agent.file; + agentItem.contextValue = 'agentItem'; + agentItem.iconPath = new vscode.ThemeIcon('hubot'); + agentItem.command = { + command: 'killLifeStudio.openAgentFile', + title: 'Open Agent File', + arguments: [agent] + }; + return agentItem; + } + + getChildren(element?: AgentsTreeNode): vscode.ProviderResult { + if (!element) { + const projects = this.catalog.getProjects(); + if (!projects.length) { + const error = this.catalog.getLastError(); + if (error) { + return [new InfoNode(`Unable to load agents: ${error}`)]; + } + + return [ + new InfoNode('Open a project workspace or add linked project roots to discover agents.') + ]; + } + + return projects.map(project => new ProjectNode(project)); + } + + if (element.kind === 'project') { + return element.project.agents.map(agent => new AgentNode(agent)); + } + + return []; + } + + dispose(): void { + this.onDidChangeTreeDataEmitter.dispose(); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cad090f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": [ + "ES2022" + ], + "outDir": "out", + "rootDir": "src", + "strict": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": [ + "node", + "vscode" + ] + }, + "include": [ + "src/**/*.ts" + ] +}