odd-studio 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +19 -0
- package/README.md +229 -0
- package/bin/odd-studio.js +212 -0
- package/hooks/odd-destructive-guard.sh +98 -0
- package/hooks/odd-git-safety.sh +138 -0
- package/hooks/odd-outcome-quality.sh +84 -0
- package/hooks/odd-pre-build.sh +57 -0
- package/hooks/odd-session-save.sh +38 -0
- package/hooks/odd-ui-check.sh +69 -0
- package/package.json +43 -0
- package/scripts/install-skill.js +30 -0
- package/scripts/postinstall.js +28 -0
- package/scripts/scaffold-project.js +61 -0
- package/scripts/setup-hooks.js +105 -0
- package/skill/SKILL.md +464 -0
- package/skill/docs/build/build-protocol.md +532 -0
- package/skill/docs/kb/odd-kb.md +462 -0
- package/skill/docs/planning/build-planner.md +315 -0
- package/skill/docs/planning/outcome-writer.md +328 -0
- package/skill/docs/planning/persona-architect.md +258 -0
- package/skill/docs/planning/systems-mapper.md +270 -0
- package/skill/docs/ui/accessibility.md +415 -0
- package/skill/docs/ui/component-guide.md +356 -0
- package/skill/docs/ui/design-system.md +403 -0
- package/templates/.odd/state.json +16 -0
- package/templates/CLAUDE.md +93 -0
- package/templates/docs/contract-map.md +60 -0
- package/templates/docs/outcomes/.gitkeep +0 -0
- package/templates/docs/outcomes/example-outcome.md +104 -0
- package/templates/docs/personas/.gitkeep +0 -0
- package/templates/docs/personas/example-persona.md +108 -0
- package/templates/docs/plan.md +73 -0
- package/templates/docs/ui/.gitkeep +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# odd-git-safety.sh
|
|
3
|
+
# ODD Studio — Git Safety Hook (PreToolUse: Bash)
|
|
4
|
+
#
|
|
5
|
+
# Intercepts dangerous git commands before they execute.
|
|
6
|
+
# Protects against force-push, hard-reset, and branch deletion
|
|
7
|
+
# that could destroy work in progress.
|
|
8
|
+
#
|
|
9
|
+
# Exit 0 → allow the command
|
|
10
|
+
# Exit 2 → block the command (Claude will not execute it)
|
|
11
|
+
# Stderr → message shown to the user
|
|
12
|
+
|
|
13
|
+
COMMAND="${CLAUDE_TOOL_INPUT:-}"
|
|
14
|
+
|
|
15
|
+
# ── Helper ────────────────────────────────────────────────────────────────────
|
|
16
|
+
block() {
|
|
17
|
+
echo "🔒 ODD GIT SAFETY BLOCK: $1" >&2
|
|
18
|
+
echo "" >&2
|
|
19
|
+
echo "$2" >&2
|
|
20
|
+
echo "" >&2
|
|
21
|
+
echo "To override: commit your work first, then re-run with explicit intent." >&2
|
|
22
|
+
exit 2
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
warn() {
|
|
26
|
+
echo "⚠️ ODD GIT SAFETY WARNING: $1" >&2
|
|
27
|
+
echo "$2" >&2
|
|
28
|
+
# Warnings don't block — they inform
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# ── Only check git commands ───────────────────────────────────────────────────
|
|
32
|
+
if ! echo "$COMMAND" | grep -qE '^\s*(git\s)'; then
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# ── Force push ────────────────────────────────────────────────────────────────
|
|
37
|
+
if echo "$COMMAND" | grep -qE 'git\s+push.*(--force|-f)(\s|$)'; then
|
|
38
|
+
block \
|
|
39
|
+
"Force push detected." \
|
|
40
|
+
"Force-pushing rewrites remote history and can permanently destroy teammates' work.
|
|
41
|
+
ODD Studio never force-pushes. If you need to update a remote branch:
|
|
42
|
+
- Use: git push --force-with-lease (safer — fails if remote has new commits)
|
|
43
|
+
- Or create a new branch for your changes"
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# ── Force push to main/master ─────────────────────────────────────────────────
|
|
47
|
+
if echo "$COMMAND" | grep -qE 'git\s+push.*--force-with-lease.*(main|master)'; then
|
|
48
|
+
block \
|
|
49
|
+
"Force push to main/master detected." \
|
|
50
|
+
"ODD Studio never force-pushes to the main branch.
|
|
51
|
+
This is a non-reversible operation on your primary branch.
|
|
52
|
+
Create a PR and merge instead."
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# ── Hard reset ────────────────────────────────────────────────────────────────
|
|
56
|
+
if echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then
|
|
57
|
+
# Check if there are uncommitted changes first
|
|
58
|
+
if git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null; then
|
|
59
|
+
# Working tree is clean — allow hard reset
|
|
60
|
+
exit 0
|
|
61
|
+
else
|
|
62
|
+
block \
|
|
63
|
+
"Hard reset with uncommitted changes detected." \
|
|
64
|
+
"You have uncommitted changes that would be permanently lost.
|
|
65
|
+
ODD Studio blocked this to protect your work.
|
|
66
|
+
Options:
|
|
67
|
+
- git stash (save changes temporarily)
|
|
68
|
+
- git commit -am 'WIP: saving before reset' (commit first)
|
|
69
|
+
- git diff (review what would be lost)"
|
|
70
|
+
fi
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# ── Discard all changes ───────────────────────────────────────────────────────
|
|
74
|
+
if echo "$COMMAND" | grep -qE 'git\s+checkout\s+--?\s*\.'; then
|
|
75
|
+
block \
|
|
76
|
+
"Discard all changes (git checkout -- .) detected." \
|
|
77
|
+
"This would permanently discard all uncommitted changes in your working tree.
|
|
78
|
+
ODD Studio blocked this to protect your work.
|
|
79
|
+
Options:
|
|
80
|
+
- git stash (save changes temporarily)
|
|
81
|
+
- git diff (review what would be lost)
|
|
82
|
+
- git commit -am 'WIP' (commit first)"
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# ── git restore all ───────────────────────────────────────────────────────────
|
|
86
|
+
if echo "$COMMAND" | grep -qE 'git\s+restore\s+\.'; then
|
|
87
|
+
block \
|
|
88
|
+
"Restore all files (git restore .) detected." \
|
|
89
|
+
"This would permanently discard all unstaged changes.
|
|
90
|
+
Commit or stash your work first."
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# ── git clean ─────────────────────────────────────────────────────────────────
|
|
94
|
+
if echo "$COMMAND" | grep -qE 'git\s+clean\s+.*-[a-zA-Z]*f'; then
|
|
95
|
+
block \
|
|
96
|
+
"git clean -f detected." \
|
|
97
|
+
"This permanently deletes untracked files with no recovery path.
|
|
98
|
+
ODD Studio blocked this.
|
|
99
|
+
Use git status to review untracked files before deleting them."
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# ── Force branch delete ───────────────────────────────────────────────────────
|
|
103
|
+
if echo "$COMMAND" | grep -qE 'git\s+branch\s+(-D|--delete --force)'; then
|
|
104
|
+
BRANCH=$(echo "$COMMAND" | grep -oE '(-D|--delete --force)\s+\S+' | awk '{print $2}')
|
|
105
|
+
warn \
|
|
106
|
+
"Force branch deletion: $BRANCH" \
|
|
107
|
+
"If this branch has unmerged commits they will be lost.
|
|
108
|
+
Verify the branch is fully merged before deleting."
|
|
109
|
+
# Warning only — does not block. Claude will proceed but user is informed.
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
# ── Amend published commits ───────────────────────────────────────────────────
|
|
113
|
+
if echo "$COMMAND" | grep -qE 'git\s+commit.*--amend'; then
|
|
114
|
+
# Check if HEAD is pushed to a remote
|
|
115
|
+
if git log --oneline "@{u}..HEAD" 2>/dev/null | grep -q .; then
|
|
116
|
+
# HEAD has commits not on remote — amend is safe
|
|
117
|
+
exit 0
|
|
118
|
+
elif git rev-parse "@{u}" >/dev/null 2>&1; then
|
|
119
|
+
block \
|
|
120
|
+
"Amending a published commit." \
|
|
121
|
+
"This commit has been pushed to the remote.
|
|
122
|
+
Amending it will require a force-push, which rewrites history.
|
|
123
|
+
Instead: create a new commit with your changes."
|
|
124
|
+
fi
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# ── Skip hooks ────────────────────────────────────────────────────────────────
|
|
128
|
+
if echo "$COMMAND" | grep -qE '(--no-verify|-n)\s'; then
|
|
129
|
+
block \
|
|
130
|
+
"--no-verify flag detected." \
|
|
131
|
+
"ODD Studio never skips pre-commit hooks (--no-verify).
|
|
132
|
+
Hooks exist to protect build quality. If a hook is failing:
|
|
133
|
+
1. Read the error carefully
|
|
134
|
+
2. Fix the underlying issue
|
|
135
|
+
3. Commit cleanly"
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
exit 0
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# odd-outcome-quality.sh
|
|
3
|
+
# ODD Studio — Outcome Quality Gate (PostToolUse: Write)
|
|
4
|
+
#
|
|
5
|
+
# Runs after a file is written. If the file is in docs/outcomes/ or docs/personas/,
|
|
6
|
+
# checks for completeness. Provides coaching — does not block.
|
|
7
|
+
|
|
8
|
+
FILE_PATH="${CLAUDE_TOOL_RESULT_FILE:-}"
|
|
9
|
+
|
|
10
|
+
# ── Only check ODD docs ───────────────────────────────────────────────────────
|
|
11
|
+
if ! echo "$FILE_PATH" | grep -qE 'docs/(outcomes|personas)/'; then
|
|
12
|
+
exit 0
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
if [ ! -f "$FILE_PATH" ]; then
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
CONTENT=$(cat "$FILE_PATH")
|
|
20
|
+
|
|
21
|
+
# ── Outcome quality check ─────────────────────────────────────────────────────
|
|
22
|
+
if echo "$FILE_PATH" | grep -q 'docs/outcomes/'; then
|
|
23
|
+
MISSING=""
|
|
24
|
+
|
|
25
|
+
# Check for all six fields
|
|
26
|
+
echo "$CONTENT" | grep -qiE 'Field 1.*Persona|## Persona' || MISSING="$MISSING\n • Field 1 (Persona) — not found"
|
|
27
|
+
echo "$CONTENT" | grep -qiE 'Field 2.*Trigger|## Trigger' || MISSING="$MISSING\n • Field 2 (Trigger) — not found"
|
|
28
|
+
echo "$CONTENT" | grep -qiE 'Field 3.*Walkthrough|## Walkthrough' || MISSING="$MISSING\n • Field 3 (Walkthrough) — not found"
|
|
29
|
+
echo "$CONTENT" | grep -qiE 'Field 4.*Verif|## Verif' || MISSING="$MISSING\n • Field 4 (Verification) — not found"
|
|
30
|
+
echo "$CONTENT" | grep -qiE 'Field 5.*Contracts|## Contracts' || MISSING="$MISSING\n • Field 5 (Contracts Exposed) — not found"
|
|
31
|
+
echo "$CONTENT" | grep -qiE 'Field 6.*Dep|## Dep' || MISSING="$MISSING\n • Field 6 (Dependencies) — not found"
|
|
32
|
+
|
|
33
|
+
# Check for banned vocabulary
|
|
34
|
+
BANNED=$(echo "$CONTENT" | grep -iE '\b(user story|user stories|sprint|epic|backlog|api endpoint|database schema|database table|json payload|http request)\b' | head -5)
|
|
35
|
+
|
|
36
|
+
if [ -n "$MISSING" ] || [ -n "$BANNED" ]; then
|
|
37
|
+
echo "" >&2
|
|
38
|
+
echo "🎯 ODD OUTCOME QUALITY CHECK: $(basename "$FILE_PATH")" >&2
|
|
39
|
+
if [ -n "$MISSING" ]; then
|
|
40
|
+
echo "" >&2
|
|
41
|
+
echo "Incomplete outcome — missing fields:" >&2
|
|
42
|
+
echo -e "$MISSING" >&2
|
|
43
|
+
echo "" >&2
|
|
44
|
+
echo "An outcome with missing fields will produce an incomplete build." >&2
|
|
45
|
+
echo "Run *review-outcome in /odd to fix before proceeding." >&2
|
|
46
|
+
fi
|
|
47
|
+
if [ -n "$BANNED" ]; then
|
|
48
|
+
echo "" >&2
|
|
49
|
+
echo "Banned vocabulary detected:" >&2
|
|
50
|
+
echo "$BANNED" | while IFS= read -r line; do echo " → $line" >&2; done
|
|
51
|
+
echo "" >&2
|
|
52
|
+
echo "ODD outcomes must be written in domain language only." >&2
|
|
53
|
+
echo "Translate technical terms back to what the user experiences." >&2
|
|
54
|
+
fi
|
|
55
|
+
echo "" >&2
|
|
56
|
+
fi
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# ── Persona quality check ─────────────────────────────────────────────────────
|
|
60
|
+
if echo "$FILE_PATH" | grep -q 'docs/personas/'; then
|
|
61
|
+
MISSING_DIMS=""
|
|
62
|
+
|
|
63
|
+
echo "$CONTENT" | grep -qiE '## Identity|identity' || MISSING_DIMS="$MISSING_DIMS\n • Identity dimension"
|
|
64
|
+
echo "$CONTENT" | grep -qiE '## Current Reality|current.?reality' || MISSING_DIMS="$MISSING_DIMS\n • Current Reality dimension"
|
|
65
|
+
echo "$CONTENT" | grep -qiE '## Technical Context|technical.?context' || MISSING_DIMS="$MISSING_DIMS\n • Technical Context dimension"
|
|
66
|
+
echo "$CONTENT" | grep -qiE '## Constraints|constraints' || MISSING_DIMS="$MISSING_DIMS\n • Constraints dimension"
|
|
67
|
+
echo "$CONTENT" | grep -qiE '## Trigger Patterns|trigger.?patterns' || MISSING_DIMS="$MISSING_DIMS\n • Trigger Patterns dimension"
|
|
68
|
+
echo "$CONTENT" | grep -qiE '## Success Definition|success.?definition' || MISSING_DIMS="$MISSING_DIMS\n • Success Definition dimension"
|
|
69
|
+
echo "$CONTENT" | grep -qiE '## Failure Tolerance|failure.?tolerance' || MISSING_DIMS="$MISSING_DIMS\n • Failure Tolerance dimension"
|
|
70
|
+
|
|
71
|
+
if [ -n "$MISSING_DIMS" ]; then
|
|
72
|
+
echo "" >&2
|
|
73
|
+
echo "👤 ODD PERSONA QUALITY CHECK: $(basename "$FILE_PATH")" >&2
|
|
74
|
+
echo "" >&2
|
|
75
|
+
echo "Thin persona — missing dimensions:" >&2
|
|
76
|
+
echo -e "$MISSING_DIMS" >&2
|
|
77
|
+
echo "" >&2
|
|
78
|
+
echo "A persona without all 7 dimensions produces outcomes designed for nobody in particular." >&2
|
|
79
|
+
echo "Run *review-persona in /odd to complete it." >&2
|
|
80
|
+
echo "" >&2
|
|
81
|
+
fi
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
exit 0
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# odd-pre-build.sh
|
|
3
|
+
# ODD Studio — Pre-Build Safety Check (PreToolUse: Bash)
|
|
4
|
+
#
|
|
5
|
+
# Runs before npm run build, npm test, and deployment commands.
|
|
6
|
+
# Checks that ODD project state is sane before the build proceeds.
|
|
7
|
+
|
|
8
|
+
COMMAND="${CLAUDE_TOOL_INPUT:-}"
|
|
9
|
+
|
|
10
|
+
# ── Only intercept build/deploy commands ─────────────────────────────────────
|
|
11
|
+
if ! echo "$COMMAND" | grep -qE '(npm\s+run\s+(build|deploy|start:prod)|yarn\s+(build|deploy)|pnpm\s+(build|deploy))'; then
|
|
12
|
+
exit 0
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# ── Check git is clean before production builds ───────────────────────────────
|
|
16
|
+
if echo "$COMMAND" | grep -qiE '(deploy|start:prod)'; then
|
|
17
|
+
if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
|
|
18
|
+
echo "🏗️ ODD PRE-BUILD: Uncommitted changes before deploy." >&2
|
|
19
|
+
echo "" >&2
|
|
20
|
+
echo "You have uncommitted changes. ODD Studio recommends:" >&2
|
|
21
|
+
echo " 1. Review changes: git diff" >&2
|
|
22
|
+
echo " 2. Commit: git add -A && git commit -m 'describe what changed'" >&2
|
|
23
|
+
echo " 3. Then deploy" >&2
|
|
24
|
+
echo "" >&2
|
|
25
|
+
echo "Deploying from a dirty working tree makes it hard to roll back." >&2
|
|
26
|
+
exit 2
|
|
27
|
+
fi
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# ── Check ODD plan exists before first build ─────────────────────────────────
|
|
31
|
+
if [ ! -f "docs/plan.md" ]; then
|
|
32
|
+
echo "🏗️ ODD PRE-BUILD: No Master Implementation Plan found." >&2
|
|
33
|
+
echo "" >&2
|
|
34
|
+
echo "docs/plan.md does not exist in this project." >&2
|
|
35
|
+
echo "ODD Studio recommends completing planning before building:" >&2
|
|
36
|
+
echo " Open Claude Code → /odd → *plan" >&2
|
|
37
|
+
echo "" >&2
|
|
38
|
+
echo "Building without a plan means building the wrong thing." >&2
|
|
39
|
+
# Warning only for missing plan — don't block a legitimate build
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# ── Check for any unverified outcomes before building next phase ──────────────
|
|
43
|
+
if [ -d "docs/outcomes" ]; then
|
|
44
|
+
UNVERIFIED=$(grep -rl "status.*draft\|Review Status.*DRAFT" docs/outcomes/ 2>/dev/null | wc -l | tr -d ' ')
|
|
45
|
+
if [ "$UNVERIFIED" -gt 0 ]; then
|
|
46
|
+
echo "🏗️ ODD PRE-BUILD: $UNVERIFIED unverified outcome(s) detected." >&2
|
|
47
|
+
echo "" >&2
|
|
48
|
+
echo "Some outcomes are in DRAFT status and have not been reviewed." >&2
|
|
49
|
+
echo "Building from unreviewed outcomes produces systems that do not match your intentions." >&2
|
|
50
|
+
echo "" >&2
|
|
51
|
+
echo "Run *review-outcome in /odd for each draft outcome before building." >&2
|
|
52
|
+
echo "" >&2
|
|
53
|
+
# Warning only — does not block the build
|
|
54
|
+
fi
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
exit 0
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# odd-session-save.sh
|
|
3
|
+
# ODD Studio — Session State Save (PostToolUse: Bash)
|
|
4
|
+
#
|
|
5
|
+
# After git commit commands, syncs project state to ruflo memory
|
|
6
|
+
# so that the next /odd session can resume from exactly this point.
|
|
7
|
+
# Falls back to local .odd/state.json if ruflo is unavailable.
|
|
8
|
+
|
|
9
|
+
COMMAND="${CLAUDE_TOOL_INPUT:-}"
|
|
10
|
+
|
|
11
|
+
# ── Only trigger on git commit ────────────────────────────────────────────────
|
|
12
|
+
if ! echo "$COMMAND" | grep -qE 'git\s+commit'; then
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
STATE_FILE=".odd/state.json"
|
|
17
|
+
|
|
18
|
+
if [ ! -f "$STATE_FILE" ]; then
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
# ── Update last-commit timestamp in local state ───────────────────────────────
|
|
23
|
+
LAST_COMMIT=$(git log -1 --format="%H %s" 2>/dev/null || echo "unknown")
|
|
24
|
+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
25
|
+
|
|
26
|
+
# Use node to update the JSON cleanly (available since odd-studio requires node 18+)
|
|
27
|
+
node -e "
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const state = JSON.parse(fs.readFileSync('$STATE_FILE', 'utf8'));
|
|
30
|
+
state.lastCommit = '$LAST_COMMIT';
|
|
31
|
+
state.lastSaved = '$TIMESTAMP';
|
|
32
|
+
fs.writeFileSync('$STATE_FILE', JSON.stringify(state, null, 2));
|
|
33
|
+
console.log('state updated');
|
|
34
|
+
" 2>/dev/null
|
|
35
|
+
|
|
36
|
+
echo "💾 ODD session state saved. Resume with /odd in Claude Code." >&2
|
|
37
|
+
|
|
38
|
+
exit 0
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# odd-ui-check.sh
|
|
3
|
+
# ODD Studio — UI Quality Reminder (PostToolUse: Edit)
|
|
4
|
+
#
|
|
5
|
+
# After editing UI/frontend files, surfaces accessibility and
|
|
6
|
+
# mobile-first reminders. Educational, not blocking.
|
|
7
|
+
|
|
8
|
+
FILE_PATH="${CLAUDE_TOOL_RESULT_FILE:-}"
|
|
9
|
+
|
|
10
|
+
# ── Only check frontend files ─────────────────────────────────────────────────
|
|
11
|
+
if ! echo "$FILE_PATH" | grep -qiE '\.(tsx|jsx|html|svelte|vue)$'; then
|
|
12
|
+
exit 0
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# Only run ~20% of the time to avoid noise (sample by PID)
|
|
16
|
+
if [ $(( $$ % 5 )) -ne 0 ]; then
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
CONTENT=""
|
|
21
|
+
if [ -f "$FILE_PATH" ]; then
|
|
22
|
+
CONTENT=$(cat "$FILE_PATH")
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
ISSUES=""
|
|
26
|
+
|
|
27
|
+
# ── Accessibility checks ──────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
# img without alt
|
|
30
|
+
if echo "$CONTENT" | grep -qE '<img[^>]+(?!alt=)'; then
|
|
31
|
+
if echo "$CONTENT" | grep -qE '<img' && ! echo "$CONTENT" | grep -qE '<img[^>]+alt='; then
|
|
32
|
+
ISSUES="$ISSUES\n • <img> elements should have alt= attributes (screen reader accessibility)"
|
|
33
|
+
fi
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Buttons without accessible text
|
|
37
|
+
if echo "$CONTENT" | grep -qE '<button[^>]*>\s*<'; then
|
|
38
|
+
ISSUES="$ISSUES\n • Buttons containing only icons need aria-label= for screen readers"
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# Interactive elements without focus management
|
|
42
|
+
if echo "$CONTENT" | grep -qE 'onClick|onPress' && ! echo "$CONTENT" | grep -qE '(focus|tabIndex|onKeyDown|onKeyPress|role=)'; then
|
|
43
|
+
ISSUES="$ISSUES\n • Interactive elements need keyboard support (onKeyDown or role= + tabIndex)"
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# Hardcoded colours instead of tokens
|
|
47
|
+
if echo "$CONTENT" | grep -qE '(style=.*color:\s*#|style=.*background:\s*#)'; then
|
|
48
|
+
ISSUES="$ISSUES\n • Hardcoded colours detected — use Tailwind tokens or CSS variables for theme consistency"
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# Non-responsive fixed widths
|
|
52
|
+
if echo "$CONTENT" | grep -qE 'width:\s*[0-9]{3,}px'; then
|
|
53
|
+
ISSUES="$ISSUES\n • Fixed pixel widths may break on mobile — prefer Tailwind responsive classes (w-full, max-w-*)"
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# ── Output coaching ───────────────────────────────────────────────────────────
|
|
57
|
+
if [ -n "$ISSUES" ]; then
|
|
58
|
+
echo "" >&2
|
|
59
|
+
echo "🎨 ODD UI QUALITY REMINDER: $(basename "$FILE_PATH")" >&2
|
|
60
|
+
echo "" >&2
|
|
61
|
+
echo "Things to check before marking this UI outcome verified:" >&2
|
|
62
|
+
echo -e "$ISSUES" >&2
|
|
63
|
+
echo "" >&2
|
|
64
|
+
echo "Remember: every UI outcome must be verified on a mobile screen before it passes." >&2
|
|
65
|
+
echo "Resize your browser to 375px wide and walk through your verification steps." >&2
|
|
66
|
+
echo "" >&2
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
exit 0
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "odd-studio",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Outcome-Driven Development for Claude Code — a planning and build harness for domain experts building serious software with AI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"odd",
|
|
8
|
+
"outcome-driven-development",
|
|
9
|
+
"ai-development",
|
|
10
|
+
"no-code",
|
|
11
|
+
"claude",
|
|
12
|
+
"anthropic"
|
|
13
|
+
],
|
|
14
|
+
"author": "ODD Studio",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"bin": {
|
|
17
|
+
"odd-studio": "./bin/odd-studio.js"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prepare": "chmod +x bin/odd-studio.js hooks/*.sh",
|
|
21
|
+
"prepublishOnly": "npm run prepare",
|
|
22
|
+
"postinstall": "node scripts/postinstall.js"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"files": [
|
|
26
|
+
"bin/",
|
|
27
|
+
"scripts/",
|
|
28
|
+
"skill/",
|
|
29
|
+
"hooks/",
|
|
30
|
+
"templates/",
|
|
31
|
+
".claude-plugin/"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"commander": "^12.0.0",
|
|
38
|
+
"chalk": "^5.3.0",
|
|
39
|
+
"ora": "^8.0.1",
|
|
40
|
+
"inquirer": "^10.0.0",
|
|
41
|
+
"fs-extra": "^11.2.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Installs the /odd skill into ~/.claude/skills/odd/
|
|
8
|
+
* Copies skill/ directory from the package root.
|
|
9
|
+
*/
|
|
10
|
+
export default async function installSkill(packageRoot, options = {}) {
|
|
11
|
+
const source = path.join(packageRoot, 'skill');
|
|
12
|
+
const destination = path.join(os.homedir(), '.claude', 'skills', 'odd');
|
|
13
|
+
|
|
14
|
+
if (!fs.existsSync(source)) {
|
|
15
|
+
throw new Error(`Skill source not found at ${source}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Ensure ~/.claude/skills/ exists
|
|
19
|
+
await fs.ensureDir(path.join(os.homedir(), '.claude', 'skills'));
|
|
20
|
+
|
|
21
|
+
// If destination exists and not forcing, back it up
|
|
22
|
+
if (fs.existsSync(destination) && !options.force) {
|
|
23
|
+
const backup = destination + '.backup-' + Date.now();
|
|
24
|
+
await fs.move(destination, backup);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await fs.copy(source, destination, { overwrite: true });
|
|
28
|
+
|
|
29
|
+
return { destination };
|
|
30
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// postinstall.js — runs after npm install
|
|
2
|
+
// Only installs skill + hooks when running as a global install (npx or npm install -g).
|
|
3
|
+
// Does NOT run during project-level npm install to avoid side effects.
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
// Detect if this is a global install or npx execution
|
|
8
|
+
const isGlobal = process.env.npm_config_global === 'true';
|
|
9
|
+
const isNpx = process.env.npm_lifecycle_script?.includes('npx');
|
|
10
|
+
|
|
11
|
+
if (!isGlobal && !isNpx) {
|
|
12
|
+
// Project-level install — skip automatic setup
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
import('./install-skill.js')
|
|
17
|
+
.then(({ default: installSkill }) => {
|
|
18
|
+
const pkg = new URL('..', import.meta.url).pathname;
|
|
19
|
+
return installSkill(pkg.replace(/\/$/, ''));
|
|
20
|
+
})
|
|
21
|
+
.then(() => {
|
|
22
|
+
console.log('✓ ODD Studio: /odd skill installed into Claude Code');
|
|
23
|
+
})
|
|
24
|
+
.catch((e) => {
|
|
25
|
+
// Non-fatal — user can run odd-studio init manually
|
|
26
|
+
console.log('⚠ ODD Studio: Could not auto-install skill (' + e.message + ')');
|
|
27
|
+
console.log(' Run: npx odd-studio init to complete setup.');
|
|
28
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const TEMPLATES = path.resolve(__dirname, '..', 'templates');
|
|
9
|
+
|
|
10
|
+
export default async function scaffoldProject(targetDir, projectName) {
|
|
11
|
+
await fs.ensureDir(targetDir);
|
|
12
|
+
|
|
13
|
+
// Copy templates
|
|
14
|
+
await fs.copy(TEMPLATES, targetDir, {
|
|
15
|
+
overwrite: false, // Never overwrite existing user files
|
|
16
|
+
filter: (src) => {
|
|
17
|
+
// Skip .gitkeep in non-empty directories
|
|
18
|
+
if (src.endsWith('.gitkeep')) {
|
|
19
|
+
const dir = path.dirname(src);
|
|
20
|
+
const destDir = dir.replace(TEMPLATES, targetDir);
|
|
21
|
+
return !fs.existsSync(destDir) || fs.readdirSync(destDir).length === 0;
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Patch CLAUDE.md with project name
|
|
28
|
+
const claudeMd = path.join(targetDir, 'CLAUDE.md');
|
|
29
|
+
if (fs.existsSync(claudeMd)) {
|
|
30
|
+
let content = await fs.readFile(claudeMd, 'utf8');
|
|
31
|
+
content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
|
|
32
|
+
await fs.writeFile(claudeMd, content);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Patch .odd/state.json with project name and timestamp
|
|
36
|
+
const stateFile = path.join(targetDir, '.odd', 'state.json');
|
|
37
|
+
if (fs.existsSync(stateFile)) {
|
|
38
|
+
const state = await fs.readJson(stateFile);
|
|
39
|
+
state.projectName = projectName;
|
|
40
|
+
state.initialisedAt = new Date().toISOString();
|
|
41
|
+
await fs.writeJson(stateFile, state, { spaces: 2 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Initialise git if not already a repo
|
|
45
|
+
const gitDir = path.join(targetDir, '.git');
|
|
46
|
+
if (!fs.existsSync(gitDir)) {
|
|
47
|
+
const { execSync } = await import('child_process');
|
|
48
|
+
try {
|
|
49
|
+
execSync('git init', { cwd: targetDir, stdio: 'ignore' });
|
|
50
|
+
execSync('git add -A', { cwd: targetDir, stdio: 'ignore' });
|
|
51
|
+
execSync(`git commit -m "Initial ODD Studio scaffold for ${projectName}"`, {
|
|
52
|
+
cwd: targetDir,
|
|
53
|
+
stdio: 'ignore',
|
|
54
|
+
});
|
|
55
|
+
} catch {
|
|
56
|
+
// Git init is best-effort — don't fail the whole install
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { targetDir };
|
|
61
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
7
|
+
const HOOKS_DEST = path.join(os.homedir(), '.claude', 'hooks');
|
|
8
|
+
|
|
9
|
+
// The hooks odd-studio installs, keyed by filename
|
|
10
|
+
const ODD_HOOKS = {
|
|
11
|
+
'odd-git-safety.sh': {
|
|
12
|
+
event: 'PreToolUse',
|
|
13
|
+
matcher: 'Bash',
|
|
14
|
+
timeout: 5,
|
|
15
|
+
statusMessage: '🔒 ODD git safety check...',
|
|
16
|
+
},
|
|
17
|
+
'odd-destructive-guard.sh': {
|
|
18
|
+
event: 'PreToolUse',
|
|
19
|
+
matcher: 'Bash',
|
|
20
|
+
timeout: 5,
|
|
21
|
+
statusMessage: '🛡️ ODD destructive command guard...',
|
|
22
|
+
},
|
|
23
|
+
'odd-outcome-quality.sh': {
|
|
24
|
+
event: 'PostToolUse',
|
|
25
|
+
matcher: 'Write',
|
|
26
|
+
timeout: 5,
|
|
27
|
+
statusMessage: '🎯 ODD outcome quality check...',
|
|
28
|
+
},
|
|
29
|
+
'odd-ui-check.sh': {
|
|
30
|
+
event: 'PostToolUse',
|
|
31
|
+
matcher: 'Edit',
|
|
32
|
+
timeout: 3,
|
|
33
|
+
statusMessage: '🎨 ODD UI quality reminder...',
|
|
34
|
+
},
|
|
35
|
+
'odd-session-save.sh': {
|
|
36
|
+
event: 'PostToolUse',
|
|
37
|
+
matcher: 'Bash',
|
|
38
|
+
timeout: 10,
|
|
39
|
+
statusMessage: '💾 ODD session save...',
|
|
40
|
+
},
|
|
41
|
+
'odd-pre-build.sh': {
|
|
42
|
+
event: 'PreToolUse',
|
|
43
|
+
matcher: 'Bash',
|
|
44
|
+
timeout: 5,
|
|
45
|
+
statusMessage: '🏗️ ODD build safety check...',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default async function setupHooks(packageRoot, options = {}) {
|
|
50
|
+
const hooksSource = path.join(packageRoot, 'hooks');
|
|
51
|
+
|
|
52
|
+
// Copy hook scripts to ~/.claude/hooks/
|
|
53
|
+
await fs.ensureDir(HOOKS_DEST);
|
|
54
|
+
for (const hookFile of Object.keys(ODD_HOOKS)) {
|
|
55
|
+
const src = path.join(hooksSource, hookFile);
|
|
56
|
+
const dest = path.join(HOOKS_DEST, hookFile);
|
|
57
|
+
if (fs.existsSync(src)) {
|
|
58
|
+
await fs.copy(src, dest, { overwrite: true });
|
|
59
|
+
// Make executable
|
|
60
|
+
await fs.chmod(dest, 0o755);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Read or initialise settings.json
|
|
65
|
+
await fs.ensureDir(path.dirname(SETTINGS_PATH));
|
|
66
|
+
let settings = {};
|
|
67
|
+
if (fs.existsSync(SETTINGS_PATH)) {
|
|
68
|
+
settings = await fs.readJson(SETTINGS_PATH);
|
|
69
|
+
}
|
|
70
|
+
if (!settings.hooks) settings.hooks = {};
|
|
71
|
+
|
|
72
|
+
// Build hook entries
|
|
73
|
+
const hooksByEvent = {};
|
|
74
|
+
for (const [hookFile, config] of Object.entries(ODD_HOOKS)) {
|
|
75
|
+
const { event, matcher, timeout, statusMessage } = config;
|
|
76
|
+
if (!hooksByEvent[event]) hooksByEvent[event] = {};
|
|
77
|
+
if (!hooksByEvent[event][matcher]) hooksByEvent[event][matcher] = [];
|
|
78
|
+
hooksByEvent[event][matcher].push({
|
|
79
|
+
type: 'command',
|
|
80
|
+
command: path.join(HOOKS_DEST, hookFile),
|
|
81
|
+
timeout,
|
|
82
|
+
statusMessage,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Merge into existing hooks, tagged with odd-studio so we can upgrade cleanly
|
|
87
|
+
for (const [event, matchers] of Object.entries(hooksByEvent)) {
|
|
88
|
+
if (!settings.hooks[event]) settings.hooks[event] = [];
|
|
89
|
+
for (const [matcher, hookList] of Object.entries(matchers)) {
|
|
90
|
+
// Remove existing odd-studio hooks for this matcher (clean upgrade)
|
|
91
|
+
settings.hooks[event] = settings.hooks[event].filter(
|
|
92
|
+
(entry) => !(entry.matcher === matcher && entry._oddStudio)
|
|
93
|
+
);
|
|
94
|
+
settings.hooks[event].push({
|
|
95
|
+
matcher,
|
|
96
|
+
_oddStudio: true,
|
|
97
|
+
hooks: hookList,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await fs.writeJson(SETTINGS_PATH, settings, { spaces: 2 });
|
|
103
|
+
|
|
104
|
+
return { hookCount: Object.keys(ODD_HOOKS).length };
|
|
105
|
+
}
|