Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
│ │
└─────────────────────────────────────────────────────────────────┘
```
Expand Down
29 changes: 22 additions & 7 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
});
});
44 changes: 37 additions & 7 deletions packages/cli/src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -249,6 +271,9 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {

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);
Expand Down Expand Up @@ -436,17 +461,16 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
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
if (modifiedFiles.includes(file)) {
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;
}

Expand Down Expand Up @@ -479,7 +503,13 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {

// 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)
Expand All @@ -506,7 +536,7 @@ export async function updateCommand(options: UpdateOptions): Promise<void> {
);

// 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);
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/generators/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Loading