diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ebe829..a157097 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,6 +99,7 @@ jobs: cp "$GITHUB_WORKSPACE/packages/cli/templates/marketplace/stop-guard.sh" "$PLUGIN_DIR/scripts/" cp "$GITHUB_WORKSPACE/packages/cli/templates/marketplace/auto-lint.sh" "$PLUGIN_DIR/scripts/" cp "$GITHUB_WORKSPACE/packages/cli/templates/marketplace/checkpoint.sh" "$PLUGIN_DIR/scripts/" + cp "$GITHUB_WORKSPACE/packages/cli/templates/marketplace/version-check.sh" "$PLUGIN_DIR/scripts/" chmod +x "$PLUGIN_DIR/scripts/"*.sh # Update plugin.json version (count skills and agents dynamically) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c16c8..f313c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.1] - 2026-08-20 + +### Added +- **The session tells you when the project is behind.** A `SessionStart` hook runs + `version-check.sh`, which compares the version recorded in `.ai-template/manifest.json` + against the installed CLI and prints one line when they differ — naming `devtronic update` or + `npm i -g devtronic@latest` depending on which side is older. Everything it needs has existed + since the manifest did; nothing ran the comparison unless you typed `devtronic info`, so this + repository sat on 1.3.0 through two minor releases without a word. Silent when the two agree, + local (no registry call), and always exits 0 — a session never fails to start because of it. + It reports and stops there: `update` retires files and asks about the ones you made yours, so + applying it unattended is how work gets lost. + +### Fixed +- **`devtronic update` announced ~50 files it was never going to write.** The command walks the + template tree twice — once to report, once to apply — and only the apply loop asked whether + the install was in plugin or marketplace mode. So every marketplace user was shown the whole + skill and agent set as "New Files in This Version", confirmed adding them, and saw none + appear. The list was never empty either, so `All files are up to date!` could not fire for a + marketplace install however current it was. Both loops now share `isPluginManagedPath()`. +- **Migrating to the plugin left devtronic's old inline hooks in `.claude/settings.json`.** The + plugin supplies the same hooks, so both ran: the SessionStart prompt fired twice per session, + and the unfiltered `npx eslint --fix` linted every markdown write alongside the plugin's + filtered `auto-lint.sh`. `registerGitHubPlugin()` now strips them and reports which events it + cleaned. Matching is by signature and deliberately narrow — a hook devtronic did not write is + the user's and is never touched. + +### Internal +- 46 tests over the three changes: `isPluginManagedPath()` (20), `stripDevtronicHooks()` (15, + over half of them asserting a user's hook survives), and `version-check.sh` (11, run as a real + script against a temporary project and a fake CLI on `PATH`). All mutation-checked — removing + the detection guard brings the phantom files back, treating every hook as devtronic's fails + the four tests that protect the user's, and swapping `sort -V` for `sort` fails the one that + pins numeric version ordering. +- The generated and bundled `version-check.sh` are asserted identical, the same guard that now + covers `hooks.json` after the two copies drifted apart in 1.5.0. + +### Documentation +- `docs/plugins.md` described the `PostToolUse` filter as per-handler `if:` conditions + (`Edit(**/*.ts)`, …). That implementation was written and then reverted during the 1.5.0 + review — the filter lives in `auto-lint.sh`, which reads the real `tool_input.file_path` — + but the documentation kept the version that never shipped. +- The `SessionStart` section documents the version check, and both READMEs name it. + +--- + ## [1.5.0] - 2026-08-20 Skills were pre-approving tools they had no business holding, three of them answered to names diff --git a/README.md b/README.md index b477cce..ba93e89 100644 --- a/README.md +++ b/README.md @@ -133,23 +133,24 @@ export strips. ``` ┌─────────────────────────────────────────────────────────────────┐ -│ AI ARCHITECTURE LAYERS │ +│ AI ARCHITECTURE LAYERS │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ AGENTS.md Universal context for all AI agents │ │ │ │ -│ ├── Skills Reusable workflows (/spec, etc.) │ +│ ├── Skills Reusable workflows (/spec, etc.) │ │ │ 21 core + 12 design phase + 8 addon │ │ │ │ -│ ├── Agents Specialized subagents (quality, review) │ +│ ├── Agents Specialized subagents (quality, review) │ │ │ 15 core + 4 addon agents │ │ │ │ -│ ├── Rules Quality standards (IDE-specific format) │ +│ ├── Rules Quality standards (IDE-specific format) │ │ │ │ -│ └── Hooks Automated workflow (lint, checkpoint, etc.) │ +│ └── Hooks Automated workflow (lint, checkpoint, │ +│ version drift) │ │ 6 hooks included (Claude Code) │ │ │ -│ thoughts/ Persistent documents (specs, plans, etc.) │ +│ thoughts/ Persistent documents (specs, plans, etc.) │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` diff --git a/docs/plugins.md b/docs/plugins.md index 8d15f56..5ac988e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -89,10 +89,11 @@ The marketplace repo (`r-bart/devtronic-plugin`) contains: │ │ └── ... │ ├── agents/ # 15 agents │ ├── hooks/ -│ │ └── hooks.json # 5 workflow hooks +│ │ └── hooks.json # 6 hook events │ └── scripts/ │ ├── stop-guard.sh │ ├── auto-lint.sh +│ ├── version-check.sh │ └── checkpoint.sh ├── LICENSE └── README.md @@ -122,9 +123,22 @@ Event: startup Type: prompt (haiku) ``` -Quick project orientation — checks git status, recent commits, and in-progress work. A -second, silent `command` step sweeps a stale convergence-loop ownership sentinel (from a -crashed loop) so a returning human is never stuck behind a `Stop` gate that never guards. +Quick project orientation — checks git status, recent commits, and in-progress work. + +Two silent `command` steps run alongside it. The first sweeps a stale convergence-loop +ownership sentinel (from a crashed loop) so a returning human is never stuck behind a `Stop` +gate that never guards. The second runs `version-check.sh`, which compares the version in +`.ai-template/manifest.json` against the installed CLI and prints one line when they differ: + +``` +devtronic: this project was last written by 1.3.0, the CLI is 1.5.1. +Run `devtronic update` to bring the project files in step. +``` + +It names `npm i -g devtronic@latest` instead when the project is the newer of the two. It is +silent when they agree, makes no network call, and always exits 0 — a session never fails to +start because of it. It reports only: `devtronic update` retires files and asks about the ones +you made yours, so it needs a human. **Cost**: ~$0.002/session @@ -135,9 +149,10 @@ Event: Write | Edit Type: command ``` -Auto-runs lint-fix after a source file changes. Each handler carries an `if:` condition -(`Edit(**/*.ts)`, `Edit(**/*.tsx)`, …) so the linter does not spawn on markdown or JSON -writes. Auto-detects your package manager. Errors suppressed so they never block Claude. +Auto-runs lint-fix after a source file changes. The filter lives in `auto-lint.sh`, which +reads the real `tool_input.file_path` and exits early on anything that is not lintable source, +so editing a README does not spawn a lint pass. Auto-detects your package manager. Errors +suppressed so they never block Claude. ### Stop diff --git a/packages/cli/README.md b/packages/cli/README.md index 9f362e1..5d72dcb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -58,7 +58,8 @@ See the [full docs](https://github.com/r-bart/devtronic/blob/main/docs/cli-refer - **Architecture rules** — IDE-specific format (`.claude/rules/`, `.cursor/rules/`, etc.) - **Skills** (21 core + 12 design + 8 addon) — Reusable workflows (`/brief`, `/spec`, `/create-plan`, `/converge`, `/summary`, `/audit`, `/devtronic-help`, etc.) - **Agents** (15 + 4 addon) — Specialized subagents (code-reviewer, quality-runner, etc.) -- **Hooks** (6) — Automated workflow (lint-on-save, checkpoint, loop gates, etc.) +- **Hooks** (6) — Automated workflow (lint-on-save, checkpoint, loop gates, and a session-start + notice when the project's files are older than the CLI) - **Portable skills** — the core skill set at `.agents/skills/` for every IDE except Claude Code - **thoughts/** — Structured directory for AI working documents diff --git a/packages/cli/package-lock.json b/packages/cli/package-lock.json index b35bdd0..0495317 100644 --- a/packages/cli/package-lock.json +++ b/packages/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "devtronic", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devtronic", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "@clack/prompts": "^1.0.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 966acfa..007f8e9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "devtronic", - "version": "1.5.0", + "version": "1.5.1", "description": "AI-assisted development toolkit — skills, agents, quality gates, and rules for Claude Code, Cursor, Copilot, and Antigravity", "type": "module", "bin": { diff --git a/packages/cli/src/commands/__tests__/plugin-managed-paths.test.ts b/packages/cli/src/commands/__tests__/plugin-managed-paths.test.ts new file mode 100644 index 0000000..4f139a7 --- /dev/null +++ b/packages/cli/src/commands/__tests__/plugin-managed-paths.test.ts @@ -0,0 +1,97 @@ +/** + * `isPluginManagedPath` decides which template files belong to the plugin + * rather than to the project. + * + * `update.ts` walks the template tree twice — once to report what it will do, + * once to do it. Only the second loop asked whether the install was in plugin + * mode. So a marketplace install was told ~50 skill and agent files were about + * to be added, confirmed it, and then saw none of them appear. Worse, the list + * was never empty, so "All files are up to date!" could not fire for a + * marketplace install no matter how current it was. + * + * Both loops now share this function, so they cannot disagree again. + */ +import { describe, it, expect } from 'vitest'; +import { isPluginManagedPath } from '../update.js'; +import type { IDE, InstallMode } from '../../types.js'; + +const PLUGIN_MODES: InstallMode[] = ['plugin', 'marketplace']; +const SKILL = '.claude/skills/converge/SKILL.md'; +const AGENT = '.claude/agents/code-reviewer.md'; + +// ─── Plugin modes hand skills and agents to the plugin ──────────────────────── + +describe('isPluginManagedPath — plugin and marketplace mode', () => { + for (const mode of PLUGIN_MODES) { + it(`${mode}: a skill belongs to the plugin`, () => { + expect(isPluginManagedPath('claude-code', mode, SKILL)).toBe(true); + }); + + it(`${mode}: an agent belongs to the plugin`, () => { + expect(isPluginManagedPath('claude-code', mode, AGENT)).toBe(true); + }); + + it(`${mode}: rules stay in the project`, () => { + expect(isPluginManagedPath('claude-code', mode, '.claude/rules/architecture.md')).toBe(false); + }); + + it(`${mode}: settings.json stays in the project`, () => { + expect(isPluginManagedPath('claude-code', mode, '.claude/settings.json')).toBe(false); + }); + + it(`${mode}: a skill supporting file goes with its skill`, () => { + expect( + isPluginManagedPath('claude-code', mode, '.claude/skills/scaffold/structures.md') + ).toBe(true); + }); + } +}); + +// ─── Standalone keeps everything ────────────────────────────────────────────── + +describe('isPluginManagedPath — standalone', () => { + it('a standalone install holds its own skills', () => { + expect(isPluginManagedPath('claude-code', undefined, SKILL)).toBe(false); + }); + + it('a standalone install holds its own agents', () => { + expect(isPluginManagedPath('claude-code', 'standalone' as InstallMode, AGENT)).toBe(false); + }); +}); + +// ─── The rule is Claude Code's alone ────────────────────────────────────────── + +describe('isPluginManagedPath — other IDEs', () => { + const others: IDE[] = ['cursor', 'antigravity', 'github-copilot', 'opencode', 'codex']; + + for (const ide of others) { + it(`${ide} has no plugin, so nothing is plugin-managed`, () => { + // Only Claude Code has the plugin. A `.claude/` path reached while + // walking another IDE's template tree is that IDE's own file. + expect(isPluginManagedPath(ide, 'marketplace', SKILL)).toBe(false); + }); + } + + it('the portable skill export is never plugin-managed', () => { + // It is generated for the non-Claude runtimes and must always be written. + expect(isPluginManagedPath('cursor', 'marketplace', '.agents/skills/spec/SKILL.md')).toBe( + false + ); + }); +}); + +// ─── Prefix matching is anchored ────────────────────────────────────────────── + +describe('isPluginManagedPath — path matching', () => { + it('does not match a lookalike outside .claude/', () => { + expect( + isPluginManagedPath('claude-code', 'marketplace', 'docs/.claude/skills/spec/SKILL.md') + ).toBe(false); + }); + + it('does not match a sibling directory that starts the same way', () => { + expect(isPluginManagedPath('claude-code', 'marketplace', '.claude/skills-archive/x.md')).toBe( + false + ); + }); +}); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 281e26c..1bc05e0 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -2,7 +2,7 @@ import { resolve, join, dirname } from 'node:path'; import { existsSync, unlinkSync, lstatSync, readdirSync, rmdirSync, rmSync, chmodSync } from 'node:fs'; import * as p from '@clack/prompts'; import chalk from 'chalk'; -import type { UpdateOptions, Manifest, ProjectConfig, IDE } from '../types.js'; +import type { UpdateOptions, Manifest, ProjectConfig, IDE, InstallMode } from '../types.js'; import { analyzeProject } from '../analyzers/index.js'; import { readManifest, @@ -48,6 +48,28 @@ import { syncAddonFiles } from '../generators/addonFiles.js'; */ const GENERATED_ROOT_FILES = ['AGENTS.md', 'CLAUDE.md', 'loop.manifest.yaml']; +/** + * In plugin and marketplace mode the skills and agents come from the plugin, + * not from the project, so `update` must neither copy them nor announce them. + * + * The apply loop has always skipped them. The detection loop did not, so every + * marketplace install was told ~50 files were about to be added and then saw + * none of them appear — and, because the list was never empty, never once saw + * "All files are up to date!". + */ +export function isPluginManagedPath( + ide: IDE, + installMode: InstallMode | undefined, + relativePath: string +): boolean { + const viaPlugin = + ide === 'claude-code' && (installMode === 'plugin' || installMode === 'marketplace'); + return ( + viaPlugin && + (relativePath.startsWith('.claude/skills/') || relativePath.startsWith('.claude/agents/')) + ); +} + export interface RemovedFile { path: string; info?: RemovalInfo; @@ -249,6 +271,9 @@ export async function updateCommand(options: UpdateOptions): Promise { const files = getAllFilesRecursive(templateDir); for (const file of files) { + // The plugin ships these; the project never holds a copy. + if (isPluginManagedPath(ide, manifest.installMode, file)) continue; + const templatePath = join(templateDir, file); const templateContent = readFile(templatePath); const templateChecksum = calculateChecksum(templateContent); @@ -436,8 +461,6 @@ export async function updateCommand(options: UpdateOptions): Promise { const templateDir = join(TEMPLATES_DIR, IDE_TEMPLATE_MAP[ide]); if (!existsSync(templateDir)) continue; - const isPluginMode = ide === 'claude-code' && (manifest.installMode === 'plugin' || manifest.installMode === 'marketplace'); - const files = getAllFilesRecursive(templateDir); for (const file of files) { // Skip modified files @@ -445,8 +468,9 @@ export async function updateCommand(options: UpdateOptions): Promise { continue; } - // Skip skills and agents if plugin mode — they're in the plugin - if (isPluginMode && (file.startsWith('.claude/skills/') || file.startsWith('.claude/agents/'))) { + // Skip skills and agents in plugin mode — they're in the plugin. Same + // predicate the detection loop uses, so the two cannot disagree again. + if (isPluginManagedPath(ide, manifest.installMode, file)) { continue; } @@ -479,7 +503,13 @@ export async function updateCommand(options: UpdateOptions): Promise { // Re-register GitHub marketplace if in marketplace mode (idempotent) if (manifest.installMode === 'marketplace') { - registerGitHubPlugin(targetDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); + const strippedHooks = registerGitHubPlugin(targetDir, PLUGIN_NAME, GITHUB_MARKETPLACE_NAME, GITHUB_MARKETPLACE_REPO); + if (strippedHooks.length > 0) { + // Left behind by the standalone era; the plugin supplies them now. + p.log.info( + `Removed devtronic's duplicate inline hooks from .claude/settings.json (${strippedHooks.join(', ')}). Hooks you added yourself were left alone.` + ); + } } // Update plugin files if in local plugin mode (not marketplace — marketplace updates via /plugin update) @@ -506,7 +536,7 @@ export async function updateCommand(options: UpdateOptions): Promise { ); // Make scripts executable - for (const script of ['checkpoint.sh', 'stop-guard.sh', 'auto-lint.sh']) { + for (const script of ['checkpoint.sh', 'stop-guard.sh', 'auto-lint.sh', 'version-check.sh']) { const scriptPath = join(targetDir, pluginResult.pluginPath, 'scripts', script); if (existsSync(scriptPath)) { chmodSync(scriptPath, 0o755); diff --git a/packages/cli/src/generators/__tests__/plugin.test.ts b/packages/cli/src/generators/__tests__/plugin.test.ts index cfa6522..3a67cf7 100644 --- a/packages/cli/src/generators/__tests__/plugin.test.ts +++ b/packages/cli/src/generators/__tests__/plugin.test.ts @@ -249,8 +249,9 @@ describe('generatePlugin', () => { const result = generatePlugin(targetDir, templatesDir, '1.8.0', createConfig(), 'npm'); // marketplace.json + plugin.json + 3 skills (brief/SKILL.md, audit/SKILL.md, audit/report-template.md) - // + 2 agents + hooks.json + checkpoint.sh + stop-guard.sh + auto-lint.sh = 11 files - expect(Object.keys(result.files)).toHaveLength(11); + // + 2 agents + hooks.json + checkpoint.sh + stop-guard.sh + auto-lint.sh + // + version-check.sh = 12 files + expect(Object.keys(result.files)).toHaveLength(12); // Every entry should have checksum and originalChecksum for (const entry of Object.values(result.files)) { diff --git a/packages/cli/src/generators/__tests__/version-check.test.ts b/packages/cli/src/generators/__tests__/version-check.test.ts new file mode 100644 index 0000000..c8e2226 --- /dev/null +++ b/packages/cli/src/generators/__tests__/version-check.test.ts @@ -0,0 +1,159 @@ +/** + * The SessionStart version check tells you when a project's devtronic files + * and the installed CLI have drifted apart. + * + * The plumbing to know this has existed since the manifest did — the manifest + * records the version that wrote the files, and the CLI knows its own — but + * nothing ran the comparison unless you typed `devtronic info`. So a project + * sat on 1.3.0 for two minor releases without anything saying so. This one did. + * + * The script reports and stops there. `devtronic update` retires files and asks + * about the ones you made yours, and applying that unattended is how work gets + * lost; detection is the automatable half. + */ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { generateHooks, generateVersionCheckScript } from '../hooks.js'; + +const BUNDLED = resolve(__dirname, '../../../templates/marketplace/version-check.sh'); + +// ─── The two copies are one text ────────────────────────────────────────────── + +describe('the generated and bundled scripts do not drift', () => { + it('are identical', () => { + // The same guard as hooks.json, for the same reason: the CLI writes one + // copy and the marketplace ships the other, and they diverged once before. + expect(generateVersionCheckScript()).toBe(readFileSync(BUNDLED, 'utf-8')); + }); +}); + +// ─── The hook is wired in ───────────────────────────────────────────────────── + +describe('SessionStart runs the check', () => { + const hooks = JSON.parse(generateHooks()).hooks; + + it('registers it on startup', () => { + const commands = hooks.SessionStart.flatMap((m: { hooks: { command?: string }[] }) => + m.hooks.map((h) => h.command ?? '') + ); + expect(commands.some((c: string) => c.includes('version-check.sh'))).toBe(true); + }); + + it('never lets it fail the session', () => { + const entry = hooks.SessionStart.flatMap((m: { hooks: { command?: string }[] }) => m.hooks).find( + (h: { command?: string }) => h.command?.includes('version-check.sh') + ); + expect(entry.command).toContain('|| true'); + expect(entry.timeout).toBeLessThanOrEqual(15); + }); +}); + +// ─── What the script actually does ──────────────────────────────────────────── + +function runIn(manifestVersion: string | null, cliVersion: string | null): string { + const dir = mkdtempSync(join(tmpdir(), 'devtronic-vc-')); + + if (manifestVersion !== null) { + mkdirSync(join(dir, '.ai-template'), { recursive: true }); + writeFileSync( + join(dir, '.ai-template', 'manifest.json'), + JSON.stringify( + { version: manifestVersion, installMode: 'marketplace', files: { 'CLAUDE.md': { checksum: 'x' } } }, + null, + 2 + ) + ); + } + + // A fake `devtronic` on PATH, so the test never depends on a global install. + const bin = join(dir, 'bin'); + mkdirSync(bin, { recursive: true }); + if (cliVersion !== null) { + const fake = join(bin, 'devtronic'); + writeFileSync(fake, `#!/bin/bash\necho "${cliVersion}"\n`); + chmodSync(fake, 0o755); + } + + const script = join(dir, 'version-check.sh'); + writeFileSync(script, generateVersionCheckScript()); + chmodSync(script, 0o755); + + return execFileSync('bash', [script], { + cwd: dir, + // An empty PATH would break `grep`; prepend the fake bin to a minimal one. + env: { PATH: `${bin}:/usr/bin:/bin`, HOME: dir }, + encoding: 'utf-8', + }).trim(); +} + +describe('version-check.sh', () => { + it('says nothing when the two agree', () => { + expect(runIn('1.5.1', '1.5.1')).toBe(''); + }); + + it('tells you to update the project when the CLI is ahead', () => { + const out = runIn('1.3.0', '1.5.1'); + expect(out).toContain('1.3.0'); + expect(out).toContain('1.5.1'); + expect(out).toContain('devtronic update'); + }); + + it('tells you to update the CLI when the project is ahead', () => { + // The case that bit this repo in reverse: a fresh plugin, a stale CLI. + const out = runIn('1.5.1', '1.4.4'); + expect(out).toContain('npm i -g devtronic@latest'); + expect(out).not.toContain('Run `devtronic update`'); + }); + + it('orders versions numerically, not as text', () => { + // "1.10.0" sorts before "1.9.0" as a string and after it as a version. + expect(runIn('1.9.0', '1.10.0')).toContain('devtronic update'); + expect(runIn('1.10.0', '1.9.0')).toContain('npm i -g devtronic@latest'); + }); + + it('says nothing in a project devtronic never touched', () => { + expect(runIn(null, '1.5.1')).toBe(''); + }); + + it('says nothing when the CLI is not installed', () => { + // Skills work without the CLI; a missing CLI is not a problem to announce. + expect(runIn('1.3.0', null)).toBe(''); + }); + + it('reads the top-level version, not a per-file entry', () => { + const dir = mkdtempSync(join(tmpdir(), 'devtronic-vc-')); + mkdirSync(join(dir, '.ai-template'), { recursive: true }); + writeFileSync( + join(dir, '.ai-template', 'manifest.json'), + '{\n "version": "1.3.0",\n "files": {\n "a.md": { "version": "9.9.9" }\n }\n}' + ); + const bin = join(dir, 'bin'); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, 'devtronic'), '#!/bin/bash\necho "1.5.1"\n'); + chmodSync(join(bin, 'devtronic'), 0o755); + const script = join(dir, 'version-check.sh'); + writeFileSync(script, generateVersionCheckScript()); + + const out = execFileSync('bash', [script], { + cwd: dir, + env: { PATH: `${bin}:/usr/bin:/bin`, HOME: dir }, + encoding: 'utf-8', + }); + expect(out).toContain('1.3.0'); + expect(out).not.toContain('9.9.9'); + }); + + it('always exits 0', () => { + // Whatever it finds, a session must still start. + for (const [m, c] of [ + ['1.3.0', '1.5.1'], + ['1.5.1', '1.5.1'], + [null, null], + ] as [string | null, string | null][]) { + expect(() => runIn(m, c)).not.toThrow(); + } + }); +}); diff --git a/packages/cli/src/generators/hooks.ts b/packages/cli/src/generators/hooks.ts index 87bbe01..2f7684a 100644 --- a/packages/cli/src/generators/hooks.ts +++ b/packages/cli/src/generators/hooks.ts @@ -98,6 +98,60 @@ ${lintFixCmd} 2>/dev/null || true `; } +/** + * Generates the `version-check.sh` the SessionStart hook runs. + * + * Identical to `templates/marketplace/version-check.sh` — the script takes no + * project configuration, so the two copies are the same text and a test holds + * them that way. The generated and bundled `hooks.json` drifted apart once + * already; this is the same guard applied before it can happen again. + */ +export function generateVersionCheckScript(): string { + return `#!/bin/bash +# Tells you when the project's devtronic files and the installed CLI have +# drifted apart. Generated by devtronic. +# +# Read-only and local: it reads two version strings and prints at most one +# line. It never fetches, never writes, and always exits 0 — a session must +# never fail to start because of this check. +# +# Detection only, on purpose. \`devtronic update\` deletes files the templates +# retired and asks about the ones you made yours, so it needs a human. + +MANIFEST=".ai-template/manifest.json" +[ -f "$MANIFEST" ] || exit 0 + +# The version that last wrote this project's files. It is the first "version" +# key in the manifest; the per-file entries carry checksums, not versions. +PROJECT=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$MANIFEST" 2>/dev/null \\ + | head -1 | sed 's/.*"\\([^"]*\\)"$/\\1/') +[ -n "$PROJECT" ] || exit 0 + +if command -v devtronic >/dev/null 2>&1; then + CLI=$(devtronic --version 2>/dev/null) +elif command -v npx >/dev/null 2>&1; then + CLI=$(npx --no-install devtronic --version 2>/dev/null) +fi +[ -n "$CLI" ] || exit 0 + +[ "$PROJECT" = "$CLI" ] && exit 0 + +# Which side is behind decides which command fixes it. If \`sort -V\` cannot +# order them, say what we know and let the human choose. +OLDEST=$(printf '%s\\n%s\\n' "$PROJECT" "$CLI" | sort -V 2>/dev/null | head -1) + +if [ "$OLDEST" = "$PROJECT" ]; then + echo "devtronic: this project was last written by $PROJECT, the CLI is $CLI. Run \\\`devtronic update\\\` to bring the project files in step." +elif [ "$OLDEST" = "$CLI" ]; then + echo "devtronic: the CLI is $CLI but this project was written by $PROJECT. Run \\\`npm i -g devtronic@latest\\\` before updating anything." +else + echo "devtronic: project $PROJECT, CLI $CLI — the two have drifted." +fi + +exit 0 +`; +} + /** * Generates a hooks.json configuration personalized by the project's * package manager and quality command. @@ -139,6 +193,19 @@ export function generateHooks(): string { }, ], }, + { + // Says so when the project's files and the CLI have drifted. Silent + // when they agree, and it never applies anything: `update` retires + // files and asks about the ones you made yours, so it needs a human. + matcher: 'startup', + hooks: [ + { + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/scripts/version-check.sh 2>/dev/null || true', + timeout: 15, + }, + ], + }, ], // Synchronous lint-fix: must complete before Claude reads the file again. // The script filters non-source edits (see generateAutoLintScript). diff --git a/packages/cli/src/generators/plugin.ts b/packages/cli/src/generators/plugin.ts index 64cf57d..8f2d7c4 100644 --- a/packages/cli/src/generators/plugin.ts +++ b/packages/cli/src/generators/plugin.ts @@ -14,6 +14,7 @@ import { generateCheckpointScript, generateStopGuardScript, generateAutoLintScript, + generateVersionCheckScript, } from './hooks.js'; import { CORE_SKILLS } from './rules.js'; @@ -184,6 +185,11 @@ export function generatePlugin( const autoLintRelPath = join(pluginRoot, 'scripts', 'auto-lint.sh'); writeGeneratedFile(targetDir, autoLintRelPath, autoLintContent, files); + // 9. Generate version-check script (SessionStart); reports project ↔ CLI drift + const versionCheckContent = generateVersionCheckScript(); + const versionCheckRelPath = join(pluginRoot, 'scripts', 'version-check.sh'); + writeGeneratedFile(targetDir, versionCheckRelPath, versionCheckContent, files); + return { files, pluginPath: pluginRoot }; } diff --git a/packages/cli/src/utils/__tests__/strip-devtronic-hooks.test.ts b/packages/cli/src/utils/__tests__/strip-devtronic-hooks.test.ts new file mode 100644 index 0000000..821b6e7 --- /dev/null +++ b/packages/cli/src/utils/__tests__/strip-devtronic-hooks.test.ts @@ -0,0 +1,173 @@ +/** + * `stripDevtronicHooks` removes devtronic's own inline hooks from + * `.claude/settings.json` once the project gets them from the plugin instead. + * + * A project installed before the plugin migration kept its standalone hooks in + * settings.json. The migration registered the plugin and left them there, so + * both ran: the same SessionStart prompt fired twice on every session, and the + * unfiltered `npx eslint --fix` linted every markdown write alongside the + * plugin's filtered `auto-lint.sh`. + * + * The dangerous half is the other direction. A hook devtronic did not write is + * the user's, and deleting it takes work they cannot get back — so matching is + * by signature and anything unrecognised survives. + */ +import { describe, it, expect } from 'vitest'; +import { stripDevtronicHooks } from '../settings.js'; +import type { ClaudeSettings } from '../settings.js'; + +function withHooks(hooks: unknown): ClaudeSettings { + return { hooks, enabledPlugins: { 'devtronic@devtronic': true } } as ClaudeSettings; +} + +function hooksOf(settings: ClaudeSettings): Record { + return (settings.hooks ?? {}) as Record; +} + +/** The exact shape a pre-plugin devtronic install left behind. */ +const LEGACY = { + SessionStart: [ + { + matcher: 'startup', + hooks: [{ type: 'prompt', prompt: 'Quick project orientation: First check...', model: 'haiku' }], + }, + ], + PostToolUse: [ + { + matcher: 'Write|Edit', + hooks: [{ type: 'command', command: 'npx eslint --fix --quiet 2>/dev/null || true' }], + }, + ], + SubagentStop: [{ hooks: [{ type: 'prompt', prompt: 'A subagent has finished. Based on...' }] }], +}; + +// ─── Devtronic's own hooks go ───────────────────────────────────────────────── + +describe('stripDevtronicHooks — removes what devtronic wrote', () => { + it('removes the whole legacy set', () => { + const { settings, removed } = stripDevtronicHooks(withHooks(LEGACY)); + expect(settings.hooks).toBeUndefined(); + expect(removed.sort()).toEqual(['PostToolUse', 'SessionStart', 'SubagentStop']); + }); + + it('removes the plugin-root script hooks', () => { + const { settings } = stripDevtronicHooks( + withHooks({ + Stop: [{ hooks: [{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/scripts/stop-guard.sh' }] }], + }) + ); + expect(settings.hooks).toBeUndefined(); + }); + + it('removes the standalone script paths', () => { + const { settings } = stripDevtronicHooks( + withHooks({ + PreCompact: [{ hooks: [{ type: 'command', command: '.claude/scripts/checkpoint.sh' }] }], + }) + ); + expect(settings.hooks).toBeUndefined(); + }); + + it('leaves the rest of the settings untouched', () => { + const { settings } = stripDevtronicHooks(withHooks(LEGACY)); + expect(settings.enabledPlugins).toEqual({ 'devtronic@devtronic': true }); + }); + + it('is idempotent', () => { + const once = stripDevtronicHooks(withHooks(LEGACY)).settings; + const twice = stripDevtronicHooks(once); + expect(twice.settings).toEqual(once); + expect(twice.removed).toEqual([]); + }); +}); + +// ─── The user's hooks stay ──────────────────────────────────────────────────── + +describe('stripDevtronicHooks — never touches the user', () => { + it('keeps a hook devtronic did not write', () => { + const mine = { PostToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'make fmt' }] }] }; + const { settings, removed } = stripDevtronicHooks(withHooks(mine)); + expect(hooksOf(settings)).toEqual(mine); + expect(removed).toEqual([]); + }); + + it('keeps the user hook and drops devtronic\'s from the same matcher', () => { + const { settings, removed } = stripDevtronicHooks( + withHooks({ + PostToolUse: [ + { + matcher: 'Write|Edit', + hooks: [ + { type: 'command', command: 'npx eslint --fix --quiet 2>/dev/null || true' }, + { type: 'command', command: './scripts/notify.sh' }, + ], + }, + ], + }) + ); + expect(hooksOf(settings).PostToolUse).toEqual([ + { matcher: 'Write|Edit', hooks: [{ type: 'command', command: './scripts/notify.sh' }] }, + ]); + expect(removed).toEqual(['PostToolUse']); + }); + + it('keeps a user prompt hook that merely mentions the same words', () => { + const mine = { + SessionStart: [{ hooks: [{ type: 'prompt', prompt: 'Remind me about quick project orientation' }] }], + }; + const { settings, removed } = stripDevtronicHooks(withHooks(mine)); + expect(hooksOf(settings)).toEqual(mine); + expect(removed).toEqual([]); + }); + + it('keeps a user hook on an event devtronic also used', () => { + const { settings } = stripDevtronicHooks( + withHooks({ + SessionStart: [ + { matcher: 'startup', hooks: [{ type: 'prompt', prompt: 'Quick project orientation: ...' }] }, + { matcher: 'resume', hooks: [{ type: 'command', command: 'git fetch' }] }, + ], + }) + ); + expect(hooksOf(settings).SessionStart).toEqual([ + { matcher: 'resume', hooks: [{ type: 'command', command: 'git fetch' }] }, + ]); + }); +}); + +// ─── Shapes that must not throw ─────────────────────────────────────────────── + +describe('stripDevtronicHooks — odd input', () => { + it('handles settings with no hooks', () => { + const settings = { enabledPlugins: {} } as ClaudeSettings; + expect(stripDevtronicHooks(settings)).toEqual({ settings, removed: [] }); + }); + + it('handles an empty hooks object', () => { + const { settings, removed } = stripDevtronicHooks(withHooks({})); + expect(settings.hooks).toBeUndefined(); + expect(removed).toEqual([]); + }); + + it('keeps an empty matcher array the user left behind', () => { + // `Stop: []` is not devtronic's to remove, and removing it changes nothing. + const { settings } = stripDevtronicHooks(withHooks({ Stop: [] })); + expect(settings.hooks).toBeUndefined(); + }); + + it('keeps a matcher whose hooks list is empty', () => { + const { settings } = stripDevtronicHooks(withHooks({ Stop: [{ hooks: [] }] })); + expect(hooksOf(settings).Stop).toEqual([{ hooks: [] }]); + }); + + it('leaves a non-array event value alone', () => { + const { settings } = stripDevtronicHooks(withHooks({ Stop: 'nonsense' })); + expect(hooksOf(settings).Stop).toBe('nonsense'); + }); + + it('ignores a hook entry with neither command nor prompt', () => { + const mine = { Stop: [{ hooks: [{ type: 'agent', agent: 'reviewer' }] }] }; + const { settings } = stripDevtronicHooks(withHooks(mine)); + expect(hooksOf(settings)).toEqual(mine); + }); +}); diff --git a/packages/cli/src/utils/settings.ts b/packages/cli/src/utils/settings.ts index 27df497..70df94b 100644 --- a/packages/cli/src/utils/settings.ts +++ b/packages/cli/src/utils/settings.ts @@ -40,6 +40,99 @@ export function writeClaudeSettings(targetDir: string, settings: ClaudeSettings) writeFile(settingsPath, JSON.stringify(settings, null, 2)); } +/** + * Hook entries devtronic itself wrote into `.claude/settings.json` back when a + * standalone install carried its own hooks. + * + * Once a project moves to the plugin, the plugin supplies these same hooks, and + * the leftovers in settings.json run *as well*: the same SessionStart prompt + * fires twice, and the unfiltered `npx eslint --fix` lints every markdown write + * alongside the plugin's filtered `auto-lint.sh`. + * + * Matching is by signature, and deliberately narrow. A hook devtronic did not + * write is never touched — an unrecognised entry is a hook the user added, and + * removing it would take work they cannot get back. + */ +const DEVTRONIC_HOOK_SIGNATURES: RegExp[] = [ + // Standalone-era command hooks. + /^npx eslint --fix --quiet/, + /^\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\//, + /^\.claude\/scripts\/(checkpoint|stop-guard|auto-lint)\.sh/, + /^bash \.claude\/scripts\//, + // Standalone-era prompt hooks, matched on their opening words. + /^Quick project orientation:/, + /^A subagent has finished\./, + /^If thoughts\/plans\/ contains a recent plan/, +]; + +interface SettingsHookEntry { + type?: string; + command?: string; + prompt?: string; + [key: string]: unknown; +} + +interface SettingsHookMatcher { + matcher?: string; + hooks?: SettingsHookEntry[]; + [key: string]: unknown; +} + +function isDevtronicHook(entry: SettingsHookEntry): boolean { + const body = entry.command ?? entry.prompt; + if (typeof body !== 'string') return false; + return DEVTRONIC_HOOK_SIGNATURES.some((re) => re.test(body.trim())); +} + +/** + * Removes devtronic's own inline hooks from a settings object, leaving every + * other hook untouched. Returns the event names it emptied, for reporting. + * + * Pure: takes and returns a plain object so the rule is testable on its own. + */ +export function stripDevtronicHooks(settings: ClaudeSettings): { + settings: ClaudeSettings; + removed: string[]; +} { + const hooks = settings.hooks as Record | undefined; + if (!hooks || typeof hooks !== 'object') return { settings, removed: [] }; + + const removed: string[] = []; + const kept: Record = {}; + + for (const [event, matchers] of Object.entries(hooks)) { + if (!Array.isArray(matchers)) { + kept[event] = matchers; + continue; + } + + const survivors: SettingsHookMatcher[] = []; + let droppedHere = false; + + for (const matcher of matchers) { + const entries = Array.isArray(matcher.hooks) ? matcher.hooks : []; + const keptEntries = entries.filter((e) => !isDevtronicHook(e)); + if (keptEntries.length !== entries.length) droppedHere = true; + // A matcher whose every hook was devtronic's goes with them. One that had + // none to begin with is the user's empty placeholder, and stays. + if (keptEntries.length > 0 || entries.length === 0) { + survivors.push(entries.length === keptEntries.length ? matcher : { ...matcher, hooks: keptEntries }); + } + } + + if (droppedHere) removed.push(event); + if (survivors.length > 0) kept[event] = survivors; + } + + const next: ClaudeSettings = { ...settings }; + if (Object.keys(kept).length > 0) { + next.hooks = kept; + } else { + delete next.hooks; + } + return { settings: next, removed }; +} + /** Legacy names from before the project was renamed to devtronic */ const LEGACY_PLUGIN_NAMES = ['dev-ai', 'ai-agentic']; const LEGACY_MARKETPLACE_NAMES = ['dev-ai-local', 'ai-agentic-local', 'devtronic-local']; @@ -103,8 +196,10 @@ export function registerGitHubPlugin( pluginName: string, marketplaceName: string, githubRepo: string -): void { - const settings = readClaudeSettings(targetDir); +): string[] { + // The plugin now supplies the hooks, so devtronic's own inline copies are + // duplicates. Anything the user added stays. + const { settings, removed } = stripDevtronicHooks(readClaudeSettings(targetDir)); // Clean up legacy marketplaces and plugins (includes old local marketplace) if (settings.extraKnownMarketplaces) { @@ -142,6 +237,7 @@ export function registerGitHubPlugin( } writeClaudeSettings(targetDir, settings); + return removed; } /** diff --git a/packages/cli/templates/marketplace/hooks.json b/packages/cli/templates/marketplace/hooks.json index 82acc89..a00ac45 100644 --- a/packages/cli/templates/marketplace/hooks.json +++ b/packages/cli/templates/marketplace/hooks.json @@ -22,29 +22,27 @@ "timeout": 10 } ] - } - ], - "PostToolUse": [ + }, { - "matcher": "Write|Edit", + "matcher": "startup", "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/auto-lint.sh 2>/dev/null || true", - "timeout": 30, - "statusMessage": "Auto-linting..." + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/version-check.sh 2>/dev/null || true", + "timeout": 15 } ] } ], - "StopFailure": [ + "PostToolUse": [ { - "matcher": "rate_limit|overloaded|authentication_failed", + "matcher": "Write|Edit", "hooks": [ { "type": "command", - "command": "if [ -f .claude/.loop-owner ] && grep -q '\"owner\":\"machine\"' .claude/.loop-owner; then rm -f .claude/.loop-owner; fi; exit 0", - "timeout": 10 + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/auto-lint.sh 2>/dev/null || true", + "timeout": 30, + "statusMessage": "Auto-linting..." } ] } @@ -68,6 +66,18 @@ ] } ], + "StopFailure": [ + { + "matcher": "rate_limit|overloaded|authentication_failed", + "hooks": [ + { + "type": "command", + "command": "if [ -f .claude/.loop-owner ] && grep -q '\"owner\":\"machine\"' .claude/.loop-owner; then rm -f .claude/.loop-owner; fi; exit 0", + "timeout": 10 + } + ] + } + ], "SubagentStop": [ { "hooks": [ @@ -94,4 +104,4 @@ } ] } -} +} \ No newline at end of file diff --git a/packages/cli/templates/marketplace/version-check.sh b/packages/cli/templates/marketplace/version-check.sh new file mode 100755 index 0000000..69b470a --- /dev/null +++ b/packages/cli/templates/marketplace/version-check.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Tells you when the project's devtronic files and the installed CLI have +# drifted apart. Generated by devtronic. +# +# Read-only and local: it reads two version strings and prints at most one +# line. It never fetches, never writes, and always exits 0 — a session must +# never fail to start because of this check. +# +# Detection only, on purpose. `devtronic update` deletes files the templates +# retired and asks about the ones you made yours, so it needs a human. + +MANIFEST=".ai-template/manifest.json" +[ -f "$MANIFEST" ] || exit 0 + +# The version that last wrote this project's files. It is the first "version" +# key in the manifest; the per-file entries carry checksums, not versions. +PROJECT=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$MANIFEST" 2>/dev/null \ + | head -1 | sed 's/.*"\([^"]*\)"$/\1/') +[ -n "$PROJECT" ] || exit 0 + +if command -v devtronic >/dev/null 2>&1; then + CLI=$(devtronic --version 2>/dev/null) +elif command -v npx >/dev/null 2>&1; then + CLI=$(npx --no-install devtronic --version 2>/dev/null) +fi +[ -n "$CLI" ] || exit 0 + +[ "$PROJECT" = "$CLI" ] && exit 0 + +# Which side is behind decides which command fixes it. If `sort -V` cannot +# order them, say what we know and let the human choose. +OLDEST=$(printf '%s\n%s\n' "$PROJECT" "$CLI" | sort -V 2>/dev/null | head -1) + +if [ "$OLDEST" = "$PROJECT" ]; then + echo "devtronic: this project was last written by $PROJECT, the CLI is $CLI. Run \`devtronic update\` to bring the project files in step." +elif [ "$OLDEST" = "$CLI" ]; then + echo "devtronic: the CLI is $CLI but this project was written by $PROJECT. Run \`npm i -g devtronic@latest\` before updating anything." +else + echo "devtronic: project $PROJECT, CLI $CLI — the two have drifted." +fi + +exit 0