From a132c4174702b826315b2fef9f8d73497d87b1da Mon Sep 17 00:00:00 2001 From: raw-brt Date: Thu, 20 Aug 2026 07:11:47 +0200 Subject: [PATCH 1/3] fix(update): stop announcing files plugin mode never writes, and clear the duplicate hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `update` walks the template tree twice — once to report what it will do, once to do it — and only the apply loop asked whether the install was in plugin or marketplace mode. Every marketplace user was shown the whole skill and agent set as "New Files in This Version", confirmed adding them, and then saw none appear. Because the list was never empty, `All files are up to date!` could not fire for a marketplace install however current it was. Both loops now share `isPluginManagedPath()`. The second one has been running silently since the plugin migration. Moving a project to the plugin registered it and left devtronic's old inline hooks in `.claude/settings.json`, so both sets ran: the same 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 narrow on purpose. An unrecognised hook is one the user added, and removing it takes work they cannot get back — over half of `stripDevtronicHooks`'s tests assert exactly that. Found by running `devtronic update --dry-run` against this repository, which proposed 50 files it would not have written. --- CHANGELOG.md | 24 +++ .../__tests__/plugin-managed-paths.test.ts | 97 ++++++++++ packages/cli/src/commands/update.ts | 42 ++++- .../__tests__/strip-devtronic-hooks.test.ts | 173 ++++++++++++++++++ packages/cli/src/utils/settings.ts | 100 +++++++++- 5 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/commands/__tests__/plugin-managed-paths.test.ts create mode 100644 packages/cli/src/utils/__tests__/strip-devtronic-hooks.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c16c8..321a317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ 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). +## [Unreleased] + +### 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 +- 35 tests over the two rules above: `isPluginManagedPath()` (20) and `stripDevtronicHooks()` + (15, over half of them asserting a user's hook survives). Both mutation-checked — removing the + detection guard brings the phantom files back, and treating every hook as devtronic's fails + the four tests that protect the user's. + +--- + ## [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/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..ad0ee03 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) 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; } /** From c1f5f538f896820428a34f0f26ecf5376db17ec2 Mon Sep 17 00:00:00 2001 From: raw-brt Date: Thu, 20 Aug 2026 07:23:18 +0200 Subject: [PATCH 2/3] feat(hooks): tell the session when the project and the CLI have drifted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything needed to know this has existed since the manifest did: the manifest records the version that last wrote the project's files, and the CLI knows its own. Nothing ran the comparison unless you typed `devtronic info`, so this repository sat on 1.3.0 through two minor releases without a word. A `SessionStart` hook now runs `version-check.sh`. It reads two version strings, orders them with `sort -V`, and prints one line naming the command that fixes the gap — `devtronic update` when the CLI is ahead, `npm i -g devtronic@latest` when the project is. Silent when they agree, local, and always exits 0: a session must never fail to start because of this check. Detection only. `devtronic update` retires files and asks about the ones you made yours, so applying it unattended is how work gets lost. The automatable half is knowing you need to. The script is one text in two places, so a test asserts the generated and bundled copies are identical — the guard that hooks.json earned the hard way. --- .github/workflows/release.yml | 1 + CHANGELOG.md | 25 ++- docs/plugins.md | 3 +- packages/cli/package-lock.json | 4 +- packages/cli/package.json | 2 +- packages/cli/src/commands/update.ts | 2 +- .../src/generators/__tests__/plugin.test.ts | 5 +- .../__tests__/version-check.test.ts | 159 ++++++++++++++++++ packages/cli/src/generators/hooks.ts | 67 ++++++++ packages/cli/src/generators/plugin.ts | 6 + packages/cli/templates/marketplace/hooks.json | 34 ++-- .../templates/marketplace/version-check.sh | 42 +++++ 12 files changed, 326 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/generators/__tests__/version-check.test.ts create mode 100755 packages/cli/templates/marketplace/version-check.sh 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 321a317..d00fe94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,18 @@ 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). -## [Unreleased] +## [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 @@ -22,10 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the user's and is never touched. ### Internal -- 35 tests over the two rules above: `isPluginManagedPath()` (20) and `stripDevtronicHooks()` - (15, over half of them asserting a user's hook survives). Both mutation-checked — removing the - detection guard brings the phantom files back, and treating every hook as devtronic's fails - the four tests that protect the user's. +- 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. --- diff --git a/docs/plugins.md b/docs/plugins.md index 8d15f56..667659d 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 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/update.ts b/packages/cli/src/commands/update.ts index ad0ee03..1bc05e0 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -536,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/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 From 32659328edbd0034bd73b90ed1e56bd9a7e2935b Mon Sep 17 00:00:00 2001 From: raw-brt Date: Thu, 20 Aug 2026 07:25:43 +0200 Subject: [PATCH 3/3] docs: describe the hooks that actually ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/plugins.md` said the `PostToolUse` lint filter was a set of 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 now covers the version check, with the line it prints and why it reports rather than applies. Both READMEs name it, and the overview box in the root README is aligned to its own border again. --- CHANGELOG.md | 7 +++++++ README.md | 13 +++++++------ docs/plugins.md | 26 ++++++++++++++++++++------ packages/cli/README.md | 3 ++- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d00fe94..f313c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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 667659d..5ac988e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -123,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 @@ -136,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