Imported from driangle/taskmd (
AGENTS.md). Install upstream withnpx skills add driangle/taskmd. Copyright stays with the author.
Development Guidelines for taskmd
This document provides guidelines and conventions for developing the taskmd project. These instructions are designed to help maintain code quality, consistency, and reliability across the codebase.
Project Scope Boundary
Before proposing or building a new feature, integration, or scanner, check it against
the core scope boundary: docs/adr/0001-core-scope-boundary.md.
In short:
- Core = reading, writing, and presenting markdown task files: parser, scanner,
validator, the read views (
list,get,next,board,stats,graph, …), mutations (add,set,rm,archive), config, and the MCP/web surfaces over that model. Test: would it still make sense if taskmd only ever touched the local filesystem? - Optional / adjacent = anything reaching outside that line — a foreign task system,
a network service, or a different source domain.
sync(Jira/Linear/Trello/GitHub) is being extracted to a separate module behind a stable task-source API. New foreign-system integrations belong there, not in core. (Still in-tree and supported until the extraction lands.)todos(source-code TODO/FIXME scanner) stays in core but is capped — an on-ramp for turning comments into tasks, not a growth area. New languages/markers/ analytics must clear the optional-feature bar.
Once a feature is in scope, a second question decides where it lives: sdk/go or
apps/cli? See docs/adr/0006-sdk-is-the-pure-task-model-layer.md.
In short, it goes in apps/cli if it produces output (formats, prints, colorizes),
knows how it was invoked (flags, config, cobra, MCP, HTTP), or reaches outside the
task files (git, network, registry, watching). Everything else — parsing, validating,
scanning, graphs, filtering, search, recommendation, task-file writing — is sdk/go.
This is a layering axis, not a scope axis: git metadata is core (ADR 0004) and still
lives in apps/cli/internal/gitmeta. Placement matters because sdk/go is a separate
module with its own semver line and immutable version tags.
Record non-obvious scope or architecture decisions as a new ADR under docs/adr/.
Prerequisites
Before developing, ensure you have the following tools installed:
- Go (1.25+): https://go.dev/dl/
- pnpm:
npm install -g pnpm(for web frontend) - golangci-lint: Required for
make lintandmake lint-fix- macOS:
brew install golangci-lint - go install:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - Other: See golangci-lint install docs
- macOS:
- Git hooks: Run
git config core.hooksPath .githooksto enable project git hooks (see Git Hooks Setup)
Testing Requirements
CLI Testing Policy
IMPORTANT: All new CLI features MUST include comprehensive tests.
When implementing new CLI commands or features:
-
Create test files in the same package with
_test.gosuffix- Example:
internal/cli/graph.go→internal/cli/graph_test.go
- Example:
-
Required test coverage includes:
- ✅ Happy path tests - Verify the feature works correctly with valid inputs
- ✅ Format tests - Test all output formats (JSON, YAML, ASCII, etc.)
- ✅ Flag tests - Test all command-line flags and their combinations
- ✅ Error handling - Test invalid inputs and edge cases
- ✅ Integration tests - Test with real temporary files when applicable
-
Test naming convention:
func TestCommandName_FeatureDescription(t *testing.T)Example:
TestGraphCommand_ExcludeStatus_BugFix -
Test structure:
func TestMyFeature(t *testing.T) { // Setup tmpDir := createTestFiles(t) // Reset flags to known state flagVar = defaultValue // Execute err := runCommand(cmd, args) // Verify if err != nil { t.Fatalf("unexpected error: %v", err) } // Add specific assertions } -
Use test helpers:
- Create helper functions for common setup (e.g.,
createTestTaskFiles) - Use
t.TempDir()for temporary directories - Use
t.Helper()in helper functions
- Create helper functions for common setup (e.g.,
-
Examples of good test coverage:
- See
internal/cli/graph_test.gofor a comprehensive example - See
internal/graph/graph_test.gofor package-level tests
- See
Running Tests
# Run unit/integration tests
cd apps/cli && go test ./...
# Run specific test
go test ./internal/cli -run TestGraphCommand
# Run with verbose output
go test -v ./internal/cli -run TestGraphCommand
# Run with coverage
go test -cover ./...
Running E2E Tests
E2E tests build the real binary and invoke it as a subprocess, testing the full CLI including config loading, argument parsing, and output formatting.
# Run all e2e tests
cd apps/cli && make e2e
# Run a specific e2e test group
go test -tags e2e -count=1 -run TestConfig ./internal/e2e/...
# Run with verbose output
go test -tags e2e -count=1 -v ./internal/e2e/...
Note: make test does not include e2e tests. Run make e2e separately.
Test Coverage Goals
- CLI commands: Minimum 80% coverage
- Core packages (graph, parser, validator): Minimum 90% coverage
- Critical paths: 100% coverage
Code Quality Standards
Linting
The project uses golangci-lint for code quality checks, following Go conventions:
# Run linter (from apps/cli directory)
make lint
# Or run directly
golangci-lint run
# Auto-fix issues where possible
make lint-fix
# Run go mod tidy
make tidy
# Run all checks (test, lint, vet)
make check
Key quality metrics enforced:
- Function length: Max 60 lines per function (excluding comments)
- Promotes single responsibility principle
- Improves testability and readability
- Cyclomatic complexity: Max 15 complexity per function
- Catches overly complex branching logic
- Cognitive complexity: Max 20 cognitive complexity
- Measures how hard code is to understand
- Code quality: errcheck, staticcheck, govet, unused code detection
- Formatting: gofmt, goimports
Note on file length: golangci-lint has no built-in file-length rule, so the primary focus is on keeping functions small and focused — if functions stay under 60 lines, files naturally remain manageable. As a soft guardrail, scripts/check-go-file-length.sh warns about non-test .go files over 300 lines. It runs as part of make check-lite (and thus the pre-commit hook) but is non-blocking — it prints warnings and always exits 0. Treat warnings as a nudge to split a file, not a hard gate. Run it directly with make check-file-length.
Common lint rules:
- Use
anyinstead ofinterface{} - No unused imports or variables
- Proper error handling (don't ignore errors)
- Consistent naming conventions
- No magic numbers - use named constants
Go Conventions
-
Error handling:
// Good if err != nil { return fmt.Errorf("operation failed: %w", err) } // Bad - don't ignore errors _ = someOperation() -
Use context for cleanup:
func TestSomething(t *testing.T) { tmpDir := t.TempDir() // Auto-cleanup // ... test code } -
Consistent formatting:
- Use
gofmt(automatically done by most editors) - Use
goimportsfor import organization
- Use
CLI Command Development
Adding a New Command
- Create the command file:
internal/cli/<command>.go - Define flags as package-level variables
- Register command in
init()function withrootCmd.AddCommand() - Create RunE function with signature
func(cmd *cobra.Command, args []string) error - Use GetGlobalFlags() for common flags (verbose, format, etc.)
- Add comprehensive tests in
internal/cli/<command>_test.go
Command Structure Template
package cli
import (
"github.com/spf13/cobra"
)
var (
// Command-specific flags
myFlag string
)
var myCmd = &cobra.Command{
Use: "mycommand [args]",
Short: "Brief description",
Long: `Detailed description with examples`,
Args: cobra.MaximumNArgs(1),
RunE: runMyCommand,
}
func init() {
rootCmd.AddCommand(myCmd)
myCmd.Flags().StringVar(&myFlag, "flag", "default", "description")
}
func runMyCommand(cmd *cobra.Command, args []string) error {
flags := GetGlobalFlags()
// Implementation
return nil
}
Task Management
When working on tasks, use the CLI to manage task status and dependencies.
During development: Use taskmd-dev to test your changes (see Building and Deployment section).
For stable operations: Use taskmd (the Homebrew-installed version).
Task File Conventions
When working on tasks:
-
Check task status before starting:
taskmd list tasks/cli # Or during development: taskmd-dev list tasks/cli -
Update task status as you work:
- Mark as
in-progresswhen starting - Mark as
completedwhen done - Check off subtasks
- [x]as you complete them
taskmd set 042 --status in-progress # Or during development: taskmd-dev set 042 --status in-progress - Mark as
-
Maintain a worklog as you work (this repo sets
worklogs: truein.taskmd.yaml):- Create/append to
tasks/<group>/.worklogs/<ID>.md - Add timestamped entries when starting, making decisions, hitting blockers, or finishing
- See the agent template (e.g.,
CLAUDE.md) for format details and examples
- Create/append to
-
Reference the task specification document:
- See
docs/taskmd_specification.mdfor task format conventions - Follow the defined frontmatter schema
- See
Task Dependencies
- Always check task dependencies before starting work
- Ensure dependent tasks are completed first
- Use the graph command to visualize dependencies:
taskmd graph --format ascii --exclude-status completed # Or during development: taskmd-dev graph --format ascii --exclude-status completed
Building and Deployment
Development Workflow
When working on the CLI, use the development build to test your changes while keeping the Homebrew-installed stable version available.
Install Development Binary
cd apps/cli
make install-dev
This installs the binary as taskmd-dev in ~/bin/, keeping your stable taskmd (from Homebrew) available.
Note: Ensure ~/bin is in your PATH. It should already be configured in ~/.zshrc.
Testing Your Changes
ALWAYS use taskmd-dev when testing changes you've made to the source code:
# After making code changes
cd apps/cli
make install-dev
# Test your changes
taskmd-dev list
taskmd-dev next
taskmd-dev set 042 --status completed
# Compare with stable version if needed
taskmd list # Uses Homebrew version
Quick Build for Testing
For rapid iteration without installing:
cd apps/cli
make build
# Run directly
./taskmd list
Build Options
| Command | Output | Use Case |
|---|---|---|
make build |
apps/cli/taskmd |
Quick local testing |
make install-dev |
~/bin/taskmd-dev |
Development (recommended) |
make install |
$GOPATH/bin/taskmd |
Replace system binary |
make build-full |
apps/cli/taskmd (with web) |
Full build with embedded web assets |
Production Builds
For release builds with version information:
cd apps/cli
go build -ldflags="-X 'main.Version=1.0.0' -X 'main.GitCommit=$(git rev-parse HEAD)' -X 'main.BuildDate=$(date)'" -o bin/taskmd ./cmd/taskmd
Documentation
When to Update Documentation
Update documentation when:
- Adding new CLI commands or flags
- Changing existing behavior
- Adding new task file conventions
- Implementing new output formats
Specification Sync
The taskmd specification lives in docs/taskmd_specification.md (the canonical source). Three other artifacts derive from it and must stay in sync:
apps/cli/internal/cli/templates/TASKMD_SPEC.md(embedded in the CLI binary) — byte-identical copyapps/docs/reference/specification.md(docs site) — byte-identical copyclaude-code-plugin-lite/SPEC_REFERENCE.md(embedded in the lite plugin) — condensed subset, not a verbatim copy
After editing docs/taskmd_specification.md, always run:
cd apps/cli && make sync-spec
make sync-spec copies the canonical spec to the two byte-identical locations, and TestSpecTemplate_MatchesCanonicalSpec fails if either drifts.
SPEC_REFERENCE.md is a hand-written condensed subset (it restructures and omits content), so it is not copied by sync-spec. Instead, it is drift-checked, not freely hand-edited: TestSpecReference_EnumsMatchCanonical and TestSpecReference_RequiredFieldsMatchCanonical fail if its enum value sets (status, priority, effort, type) or required fields (id, title) contradict the canonical spec. When you change those facts in the canonical spec, update SPEC_REFERENCE.md in the same commit.
Documentation Locations
- CLI commands: Help text in the command definition
- Task format:
docs/taskmd_specification.md(canonical spec — see Specification Sync above) - Development: This file (
CLAUDE.md) - Project overview:
README.md(monorepo: Go CLI inapps/cli, Vite + React 19 web app inapps/web, Go SDK insdk/go, docs inapps/docs)
Common Patterns
Scanner Usage
taskScanner := scanner.NewScanner(scanDir, flags.Verbose)
result, err := taskScanner.Scan()
if err != nil {
return fmt.Errorf("scan failed: %w", err)
}
tasks := result.Tasks
Output Formatting
Support multiple output formats consistently:
switch flags.Format {
case "json":
return outputJSON(data, outFile)
case "yaml":
return outputYAML(data, outFile)
case "table":
return outputTable(data, outFile)
default:
return fmt.Errorf("unsupported format: %s", flags.Format)
}
File Writing
var outFile *os.File
if outputPath != "" {
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer f.Close()
outFile = f
} else {
outFile = os.Stdout
}
Git Workflow
Git Hooks Setup
This project uses a .githooks/ directory for version-controlled git hooks. After cloning, configure Git to use them:
git config core.hooksPath .githooks
Active hooks:
- pre-commit: Runs
taskmd validateto check task files,make check-lite(compile + lint), andscripts/check-sdk-pin.sh(see below). The commit is blocked if any of them fail.
The sdk/go pin
apps/cli and sdk/go are separate Go modules. go.work makes in-repo builds
resolve sdk/go locally, so a stale sdk/go pin in apps/cli/go.mod is invisible
during development. External consumers have no workspace:
go install github.com/driangle/taskmd/apps/cli/cmd/taskmd@latest
reads apps/cli/go.mod, so if the CLI uses SDK symbols added after the pinned
commit, that install fails to compile. This shipped once already — see
issue #8 and
PR #9. There are no apps/cli/vX.Y.Z
tags, so @latest resolves to main HEAD: drift breaks users as soon as it lands
on main, not at release time.
Four guards keep the pin honest:
| Guard | When | Behavior |
|---|---|---|
scripts/check-sdk-pin.sh --staged |
pre-commit hook | Warns on the commit that introduces SDK changes (the pin can't reference an unpushed commit), then blocks any later commit that leaves the pin stale |
CI sdk-pin job |
pushes to main | Auto-heals: tags the next sdk/go version, repoints the pin, and pushes the bump back to main — then verifies with --strict and a GOWORK=off build |
make check-sdk-pin |
any time, locally | Any drift is an error (report only, no healing) |
scripts/release.sh |
every release | Tags and pushes sdk/go when it changed, repoints the pin at that version, and verifies the GOWORK=off build before tagging the CLI |
Auto-bump on main: what you need to know
Since CI heals drift, the normal path is do nothing — land your SDK change and the pin catches up on its own. Two things are still on you:
Breaking changes must be declared. The bump defaults to patch, which is right for additive or fix-only work. A machine cannot detect a breaking API change, so pre-1.0 you flag it in the commit that makes the break:
refactor(sdk): rename Heading.Text to Heading.Title
sdk-bump: minor
Any commit touching sdk/go since the last tag carrying that marker promotes the whole
batch to a minor bump. Without it, a breaking change ships under a patch version that
importers will pick up automatically — and module versions are immutable, so there is
no fixing it afterwards.
The bump commit does not get its own CI run. CI pushes with GITHUB_TOKEN, which
GitHub deliberately does not let trigger further workflow runs. The sdk-pin job runs its
verification after the bump, so the landed state is checked — but no other job re-runs
against it. That is fine because the bump only touches go.mod/go.sum.
Versioning: independent version lines
Nothing in this repo is in lockstep with the repo version except the repo itself. The CLI, the SDK module, and the three marketplace plugins each version on their own line, because each one's number has to mean something to a different audience.
The Go modules: two independent modules, two tags
apps/cli and sdk/go version independently, because the SDK's version numbers
have to mean something to people importing the library:
| Tag | Module | When |
|---|---|---|
vX.Y.Z |
the CLI / repo release | every release |
sdk/go/vX.Y.Z |
github.com/driangle/taskmd/sdk/go |
only when sdk/go changed |
The sdk/go/ prefix is not decoration — Go requires a module in a subdirectory to be
tagged with its directory path, or the tag is invisible to go get.
apps/cli/go.mod pins a released SDK version (v0.4.0), not a pseudo-version
(v0.0.0-20260811122305-775ccf445961). Both work, but a pseudo-version is an opaque
commit pointer that no reviewer can evaluate — which is how the pin went stale twice.
The SDK is pre-1.0, so under semver a breaking API change is a minor bump
(v0.4.0 → v0.5.0), not a major one. Going to v1.0.0 would promise stability;
going to v2.0.0 or beyond would additionally require the major version in the import
path (github.com/driangle/taskmd/sdk/go/v2) and a repo-wide import rewrite.
Module versions are immutable. Once a tag is pushed and the Go module proxy has fetched it, that version is fixed forever — you cannot retag it. Pick the next number instead.
The three plugins: independent lines, no tags
The marketplace plugins version independently too, and their versions are not derived
from the repo version — release.sh used to rewrite claude-code-plugin's manifest to
the repo version on every release, and that is exactly what was removed:
| Plugin | Directory | Line | Bump when |
|---|---|---|---|
taskmd |
claude-code-plugin/ |
0.x, pre-1.0 |
the directory changed |
taskmd-lite |
claude-code-plugin-lite/ |
0.x, pre-1.0 |
the directory changed |
taskmd-mcp |
claude-code-plugin-mcp/ |
1.x, stable |
the directory changed |
Plugins carry no tags of their own — they are served from the marketplace repo at
whatever commit it points to, so "the last release" is the last vX.Y.Z repo tag. The
version lives in exactly one place, <plugin>/.claude-plugin/plugin.json;
.claude-plugin/marketplace.json deliberately carries none, and release.sh fails the
release if a version key appears there.
taskmd-mcp is at 1.x while the CLI is pre-1.0 on purpose: its MCP tool surface is a
contract other clients code against, so a changed tool signature is a major bump
there, where a renamed skill in the pre-1.0 plugins is only a minor one. Full rules:
ADR 0003.
A changed plugin blocks the release until you version it. release.sh diffs each
plugin directory against the last repo tag and, for every one that moved, requires
--plugin-taskmd-version / --plugin-lite-version / --plugin-mcp-version:
./scripts/release.sh 0.4.2 --plugin-mcp-version 1.1.0 --notes-file notes.md
Unlike the sdk pin, there is no CI auto-heal — the bump size depends on what the change
means to consumers, which a script cannot infer. Preview what a release would demand with
./scripts/release.sh --dry-run <version>; the plugin check runs as a pre-flight, so a
dry run reports every missing bump at once without touching anything.
Workflow when you change sdk/go
During development, go.work means you change sdk/go and apps/cli together and
everything just builds — no pin bump needed per commit. The pre-commit hook will remind
you that the pin is behind, and block unrelated commits until it is resolved.
The pin is normally repointed at release time by scripts/release.sh:
./scripts/release.sh 0.3.1 --sdk-version 0.4.1 --notes-file notes.md
If sdk/go changed and you omit --sdk-version, the script stops and tells you.
If sdk/go did not change, omit it and no SDK tag is created.
CI bumps the pin for you once the change is on main (see Auto-bump on main above), so this is only needed when you want the bump before pushing, or when CI cannot push (for example a fork, or branch protection that rejects the bot). From a clean tree:
make bump-sdk-pin VERSION=0.4.1
./scripts/bump-sdk-pin.sh 0.4.1 --dry-run # to preview first
That tags sdk/go/v0.4.1, pushes the tag, repoints apps/cli/go.mod, verifies the
GOWORK=off build, and commits the bump. Then git push.
The ordering matters, and is why this is a script rather than a snippet: go get
resolves versions through the module proxy, so the tag has to be pushed before the
pin can reference it. Pushing a tag also pushes the commit objects it points at, so this
works from a branch you have not pushed yet — tag, push tag, then pin.
Pick the version by what changed: pre-1.0, a breaking API change is a minor bump
(v0.4.0 → v0.5.0), additive or fix-only is a patch bump (v0.4.0 → v0.4.1).
Commit Messages
Follow conventional commit format:
type(scope): brief description
Longer description if needed
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Types:
feat: New featurefix: Bug fixtest: Adding testsdocs: Documentation changesrefactor: Code refactoringchore: Maintenance tasks
Before Committing
- Run tests:
make testorgo test ./... - Run e2e tests:
make e2e - Run linter:
make lintorgolangci-lint run - Build successfully:
make buildorgo build ./... - Test with development binary: Install and test your changes
make install-dev taskmd-dev list # Test basic functionality taskmd-dev next # Test your specific changes - Update relevant documentation
All checks (test, lint, build, manual testing) should pass before committing.
Troubleshooting
Tests Failing
- Check if flags are properly reset between tests
- Verify temporary directories are being used
- Look for race conditions or shared state
Build Failures
- Check for missing dependencies:
go mod tidy - Verify Go version compatibility
- Check for syntax errors:
go vet ./...
Linting Issues
- Run auto-fix:
golangci-lint run --fix - Check for unused imports
- Verify error handling patterns
Performance Considerations
Large Repositories
When working with large task repositories:
- Use streaming where possible
- Avoid loading all tasks into memory at once
- Consider pagination for output
- Use efficient data structures (maps for lookups)
Testing Performance
For performance-critical code:
- Add benchmark tests:
func BenchmarkMyFunction(b *testing.B) - Run benchmarks:
go test -bench=. - Profile if needed:
go test -cpuprofile=cpu.prof
Security
Input Validation
Always validate:
- File paths (prevent directory traversal)
- Task IDs (prevent injection)
- User input in flags
Safe File Operations
// Good - validate paths
if !strings.HasPrefix(filepath.Clean(userPath), basePath) {
return fmt.Errorf("invalid path")
}
// Good - proper permissions
os.WriteFile(path, data, 0644)
Questions or Issues?
- Check existing tasks for similar implementations
- Review test files for usage examples
- Refer to
docs/taskmd_specification.mdfor task format questions - Use the graph command to understand dependencies
Last Updated: 2026-02-08 Maintained By: taskmd contributors
