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
25 changes: 25 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /src
schedule:
interval: weekly
groups:
go-deps:
patterns: ["*"]

- package-ecosystem: gomod
directory: /mcpb/launcher
schedule:
interval: weekly
groups:
go-deps:
patterns: ["*"]

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,57 @@ jobs:
exit 1
fi

# Windows gets binaries at release time (engine cross-compile + the
# mcpb launcher), but nothing validated Windows builds on PR — breakage
# only surfaced during the release pipeline. This job fails fast.
build-and-test-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: src/go.mod
cache-dependency-path: src/go.sum

- name: Build engine
working-directory: src
run: go build ./...

- name: Test engine
working-directory: src
run: go test ./... -count=1

- name: Build MCPB launcher (native Windows)
working-directory: mcpb/launcher
run: go build ./...

- name: Test MCPB launcher
working-directory: mcpb/launcher
run: go test ./... -count=1

# The fresh-install job shellchecks the shipped wrappers; this covers
# the hook layer, which runs on every tool call in every session.
lint-shell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: shellcheck hooks and wrappers
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
shellcheck -S warning hooks/*.sh bin/devkit bin/mcpb-build mcpb/server/devkit

lint-actions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Run the tool author's image directly rather than a third-party
# wrapper action — one less link in the supply chain.
- name: actionlint
run: docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:latest -color

hook-smoke-tests:
runs-on: ubuntu-latest
steps:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ jobs:
TAG_PATCH=$(echo "$LATEST_TAG" | cut -d. -f3)

if [ "$P_MAJOR" -gt "$TAG_MAJOR" ] 2>/dev/null || \
([ "$P_MAJOR" -eq "$TAG_MAJOR" ] && [ "$P_MINOR" -gt "$TAG_MINOR" ]) 2>/dev/null || \
([ "$P_MAJOR" -eq "$TAG_MAJOR" ] && [ "$P_MINOR" -eq "$TAG_MINOR" ] && [ "$P_PATCH" -gt "$TAG_PATCH" ]) 2>/dev/null; then
{ [ "$P_MAJOR" -eq "$TAG_MAJOR" ] && [ "$P_MINOR" -gt "$TAG_MINOR" ]; } 2>/dev/null || \
{ [ "$P_MAJOR" -eq "$TAG_MAJOR" ] && [ "$P_MINOR" -eq "$TAG_MINOR" ] && [ "$P_PATCH" -gt "$TAG_PATCH" ]; } 2>/dev/null; then
echo "Manual version bump detected: $LATEST_TAG → $PLUGIN_VERSION"
echo "version=$PLUGIN_VERSION" >> "$GITHUB_OUTPUT"
echo "bumped=manual" >> "$GITHUB_OUTPUT"
Expand Down
27 changes: 17 additions & 10 deletions hooks/audit-trail.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
# Log location: .devkit/audit.log (gitignored)
# Format: ISO-8601 timestamp | working directory | command

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
set -euo pipefail

# Observational hook — jq parse failures degrade to "nothing to log",
# never to a hook error (fail-open contract, see rtk-rewrite.sh).
INPUT=$(cat || true)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)

# Skip if no command
[ -z "$COMMAND" ] && exit 0
Expand All @@ -32,32 +36,35 @@ LOG_FILE="${LOG_DIR}/audit.log"
# the submodule's own .gitignore is the right file to update.
INIT_MARKER="${LOG_DIR}/.gitignore-installed"
if [[ ! -f "$INIT_MARKER" ]]; then
mkdir -p "$LOG_DIR"
mkdir -p "$LOG_DIR" 2>/dev/null || true
if ( set -C; : > "$INIT_MARKER" ) 2>/dev/null; then
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [[ -n "$REPO_ROOT" ]]; then
GITIGNORE="${REPO_ROOT}/.gitignore"
if [[ ! -f "$GITIGNORE" ]] || ! grep -qE '^\.devkit($|/)' "$GITIGNORE" 2>/dev/null; then
if [[ -f "$GITIGNORE" ]] && [[ -n "$(tail -c 1 "$GITIGNORE" 2>/dev/null)" ]]; then
printf '\n' >> "$GITIGNORE"
printf '\n' >> "$GITIGNORE" 2>/dev/null || true
fi
printf '.devkit/\n' >> "$GITIGNORE"
printf '.devkit/\n' >> "$GITIGNORE" 2>/dev/null || true
fi
fi
fi
fi

mkdir -p "$LOG_DIR"
# If the log directory can't be created there is nothing to log into —
# exit clean rather than erroring on every Bash call.
mkdir -p "$LOG_DIR" 2>/dev/null || exit 0

# Truncate command for log (first line only, max 500 chars)
CMD_SHORT=$(echo "$COMMAND" | head -1 | cut -c1-500)

# Append timestamped entry
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $(pwd) | ${CMD_SHORT}" >> "$LOG_FILE"
# Append timestamped entry. Tolerate write failures (read-only fs,
# permissions) — losing one audit line must not error every Bash call.
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $(pwd) | ${CMD_SHORT}" >> "$LOG_FILE" 2>/dev/null || true

# Rotate if log exceeds 10k lines
if [ -f "$LOG_FILE" ] && [ "$(wc -l < "$LOG_FILE")" -gt 10000 ]; then
tail -5000 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
if [ -f "$LOG_FILE" ] && [ "$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0)" -gt 10000 ]; then
{ tail -5000 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"; } 2>/dev/null || true
fi

# Always allow — this hook is observational only
Expand Down
14 changes: 8 additions & 6 deletions hooks/post-validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@

set -euo pipefail

INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
TOOL_OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
# jq failures on malformed stdin must not abort the hook under set -e —
# fail open (skip validation) rather than surface a hook error.
INPUT=$(cat || true)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)
TOOL_OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // empty' 2>/dev/null || true)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null || true)

# --- Bash: check for suppressed errors ---
if [ "$TOOL_NAME" = "Bash" ]; then
Expand All @@ -35,7 +37,7 @@ fi
if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then
CHECK_CONTENT="$CONTENT"
if [ -z "$CHECK_CONTENT" ]; then
CHECK_CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty')
CHECK_CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty' 2>/dev/null || true)
fi

if [ -n "$CHECK_CONTENT" ]; then
Expand Down
13 changes: 9 additions & 4 deletions hooks/safety-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
# devkit safety hook — blocks or prompts on dangerous operations
# Runs on PreToolUse for Bash, Edit, and Write tools

INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
set -euo pipefail

# jq failures on malformed stdin must not abort the hook — the contract
# is "fail open" (never wedge Claude Code on parse errors), matching the
# guard pattern in rtk-rewrite.sh / security-patterns.sh.
INPUT=$(cat || true)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)

# --- Bash: dangerous commands ---
if [ "$TOOL_NAME" = "Bash" ]; then
Expand Down
32 changes: 19 additions & 13 deletions hooks/slop-detect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
# PostToolUse hook schema:
# { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": "string" } }

INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
set -euo pipefail

# Fail open on malformed stdin — advisory hook, never a blocker.
INPUT=$(cat || true)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)

[ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ] || exit 0
[ -z "$FILE_PATH" ] && exit 0
Expand All @@ -24,10 +27,13 @@ WARNINGS=""
# For JS/TS/Python files, check if documentation blocks outweigh code
if echo "$FILE_PATH" | grep -qE '\.(js|jsx|ts|tsx|mjs|cjs)$'; then
# Count JSDoc/block comment lines vs code lines
DOC_LINES=$(grep -cE '^\s*(\*|/\*\*|///|\s*\*)' "$FILE_PATH" 2>/dev/null || echo 0)
# NB: grep -c prints "0" itself on no match (exiting 1), so the guard
# must be `|| true` — `|| echo 0` would yield "0\n0" and break the
# integer comparisons below.
DOC_LINES=$(grep -cE '^\s*(\*|/\*\*|///|\s*\*)' "$FILE_PATH" 2>/dev/null || true)
TOTAL_LINES=$(wc -l < "$FILE_PATH" 2>/dev/null | tr -d ' ' || echo 1)
BLANK_LINES=$(grep -cE '^\s*$' "$FILE_PATH" 2>/dev/null || echo 0)
CODE_LINES=$(( TOTAL_LINES - DOC_LINES - BLANK_LINES ))
BLANK_LINES=$(grep -cE '^\s*$' "$FILE_PATH" 2>/dev/null || true)
CODE_LINES=$(( ${TOTAL_LINES:-1} - ${DOC_LINES:-0} - ${BLANK_LINES:-0} ))
[ "$CODE_LINES" -lt 1 ] && CODE_LINES=1

if [ "$DOC_LINES" -gt 10 ] && [ "$DOC_LINES" -gt "$CODE_LINES" ]; then
Expand All @@ -36,10 +42,10 @@ if echo "$FILE_PATH" | grep -qE '\.(js|jsx|ts|tsx|mjs|cjs)$'; then
fi

if echo "$FILE_PATH" | grep -qE '\.py$'; then
DOC_LINES=$(grep -cE '^\s*("""|'\'''\'''\''|#)' "$FILE_PATH" 2>/dev/null || echo 0)
DOC_LINES=$(grep -cE '^\s*("""|'\'''\'''\''|#)' "$FILE_PATH" 2>/dev/null || true)
TOTAL_LINES=$(wc -l < "$FILE_PATH" 2>/dev/null | tr -d ' ' || echo 1)
BLANK_LINES=$(grep -cE '^\s*$' "$FILE_PATH" 2>/dev/null || echo 0)
CODE_LINES=$(( TOTAL_LINES - DOC_LINES - BLANK_LINES ))
BLANK_LINES=$(grep -cE '^\s*$' "$FILE_PATH" 2>/dev/null || true)
CODE_LINES=$(( ${TOTAL_LINES:-1} - ${DOC_LINES:-0} - ${BLANK_LINES:-0} ))
[ "$CODE_LINES" -lt 1 ] && CODE_LINES=1

if [ "$DOC_LINES" -gt 10 ] && [ "$DOC_LINES" -gt "$CODE_LINES" ]; then
Expand Down Expand Up @@ -68,10 +74,10 @@ fi

# --- Excessive type annotations in JS (not TS) ---
if echo "$FILE_PATH" | grep -qE '\.(js|jsx|mjs|cjs)$'; then
JSDOC_TYPES=$(grep -cE '@(param|returns|type|typedef)\s' "$FILE_PATH" 2>/dev/null || echo 0)
FUNCTIONS=$(grep -cE '(function\s|=>|async\s)' "$FILE_PATH" 2>/dev/null || echo 1)
[ "$FUNCTIONS" -lt 1 ] && FUNCTIONS=1
RATIO=$(( JSDOC_TYPES / FUNCTIONS ))
JSDOC_TYPES=$(grep -cE '@(param|returns|type|typedef)\s' "$FILE_PATH" 2>/dev/null || true)
FUNCTIONS=$(grep -cE '(function\s|=>|async\s)' "$FILE_PATH" 2>/dev/null || true)
[ "${FUNCTIONS:-0}" -lt 1 ] 2>/dev/null && FUNCTIONS=1
RATIO=$(( ${JSDOC_TYPES:-0} / ${FUNCTIONS:-1} ))
if [ "$RATIO" -gt 4 ]; then
WARNINGS="${WARNINGS}Excessive JSDoc type annotations in .js file (${JSDOC_TYPES} annotations for ${FUNCTIONS} functions) — consider using TypeScript instead. "
fi
Expand Down
3 changes: 1 addition & 2 deletions hooks/stop-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ HAS_GO=false
HAS_TS=false
HAS_RUST=false
HAS_PYTHON=false
HAS_CONFIG=false
HAS_SQL=false

while IFS= read -r file; do
Expand All @@ -66,7 +65,7 @@ while IFS= read -r file; do
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) HAS_TS=true ;;
*.rs) HAS_RUST=true ;;
*.py) HAS_PYTHON=true ;;
*.yml|*.yaml|*.json|*.toml|*.ini|*.env*) HAS_CONFIG=true ;;
*.yml|*.yaml|*.json|*.toml|*.ini|*.env*) ;; # config — excluded from domain count, needs no test evidence
*.sql|*/migrations/*|*/migrate/*) HAS_SQL=true ;;
esac
done <<< "$CHANGED_FILES"
Expand Down
7 changes: 5 additions & 2 deletions hooks/subagent-stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@

set -euo pipefail

INPUT=$(cat)
AGENT_OUTPUT=$(echo "$INPUT" | jq -r '.agent_output // empty')
# SubagentStop must always emit a JSON verdict — a jq parse failure under
# set -e would kill the hook with no output. Degrade to empty (which the
# short-output check below converts into a block with a clear reason).
INPUT=$(cat || true)
AGENT_OUTPUT=$(echo "$INPUT" | jq -r '.agent_output // empty' 2>/dev/null || true)

# If agent output is empty or very short, block — something went wrong
if [ ${#AGENT_OUTPUT} -lt 20 ]; then
Expand Down
12 changes: 9 additions & 3 deletions src/cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ func showSessionDetail(id string) error {
return fmt.Errorf("get steps: %w", err)
}

totalCost, _ := db.SessionTotalCost(id)
// Degrade to "unknown" on error, matching showAllSessions — a cost
// query failure should not make session detail unviewable.
totalCost, costErr := db.SessionTotalCost(id)
costStr := fmt.Sprintf("$%.4f", totalCost)
if costErr != nil {
costStr = "unknown"
}

fmt.Printf("Session: %s\n", session.ID)
fmt.Printf("Workflow: %s\n", session.Workflow)
Expand All @@ -81,9 +87,9 @@ func showSessionDetail(id string) error {
fmt.Printf("Iterations: %d/%d\n", len(steps), session.MaxIterations)
}
if session.BudgetUSD > 0 {
fmt.Printf("Budget: $%.2f ($%.4f spent)\n", session.BudgetUSD, totalCost)
fmt.Printf("Budget: $%.2f (%s spent)\n", session.BudgetUSD, costStr)
}
fmt.Printf("Total cost: $%.4f\n", totalCost)
fmt.Printf("Total cost: %s\n", costStr)

if len(steps) > 0 {
fmt.Printf("\n%-5s %-10s %-10s %-8s %-8s %s\n", "ITER", "AGENT", "STATUS", "EXIT", "COST", "SUMMARY")
Expand Down
10 changes: 8 additions & 2 deletions src/cmd/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,19 @@ var workflowCmd = &cobra.Command{
return fmt.Errorf("parse workflow: %w", err)
}

agentName, _ := cmd.Flags().GetString("agent")
agentName, err := cmd.Flags().GetString("agent")
if err != nil {
return fmt.Errorf("agent flag: %w", err)
}
runner, err := resolveRunner(agentName)
if err != nil {
return err
}

budget, _ := cmd.Flags().GetFloat64("budget")
budget, err := cmd.Flags().GetFloat64("budget")
if err != nil {
return fmt.Errorf("budget flag: %w", err)
}
// CLI flag overrides YAML budget; fall back to YAML if flag not set
if budget == 0 && wf.Budget.Limit > 0 {
// Convert token budget to rough USD estimate ($0.01 per 1K tokens)
Expand Down
8 changes: 7 additions & 1 deletion src/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,13 @@ func (e *Engine) RunWorkflow(ctx context.Context, wf *Workflow, cfg RunConfig) (
e.db.UpdateSessionStatus(session.ID, "cancelled")
}

allSteps, _ := e.db.GetSteps(session.ID)
// A failed read here still writes the report (with whatever steps we
// got) but must not be silent — surface it unless a step error is
// already being returned, which takes precedence.
allSteps, stepsErr := e.db.GetSteps(session.ID)
if stepsErr != nil && stepErr == nil {
stepErr = fmt.Errorf("loading steps for final report: %w", stepsErr)
}
stopReason := "completed"
if failed {
stopReason = "failed"
Expand Down
4 changes: 4 additions & 0 deletions src/lib/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package lib
import (
"os"
"path/filepath"
"runtime"
"testing"
)

Expand Down Expand Up @@ -314,6 +315,9 @@ func TestEnsureGitignore_EntryMidFile(t *testing.T) {
}

func TestDBDirectoryPermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix permission bits are not enforced on Windows — Stat reports 0777")
}
dir := t.TempDir()
dbDir := filepath.Join(dir, ".devkit")
db, err := OpenDB(filepath.Join(dbDir, "devkit.db"))
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ func setupTestServer(t *testing.T, workflowYAML string, principlesYAML string) (
if err != nil {
t.Fatalf("NewServer: %v", err)
}
// Close the DB before TempDir cleanup (LIFO: this runs first) —
// Windows cannot delete a directory containing an open file.
t.Cleanup(func() { srv.Close() })
return srv, dir
}

Expand Down
3 changes: 3 additions & 0 deletions src/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ func TestNewServer(t *testing.T) {
if srv == nil {
t.Fatal("server is nil")
}
// Close the DB before TempDir cleanup — Windows cannot delete a
// directory containing an open file.
t.Cleanup(func() { srv.Close() })
}
Loading
Loading