specpipe 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.
Files changed (60) hide show
  1. package/README.md +1319 -0
  2. package/bin/devkit.js +3 -0
  3. package/package.json +61 -0
  4. package/src/cli.js +76 -0
  5. package/src/commands/check.js +33 -0
  6. package/src/commands/diff.js +84 -0
  7. package/src/commands/init-adopt.js +54 -0
  8. package/src/commands/init-agents.js +118 -0
  9. package/src/commands/init-global.js +102 -0
  10. package/src/commands/init.js +311 -0
  11. package/src/commands/list.js +54 -0
  12. package/src/commands/remove.js +133 -0
  13. package/src/commands/upgrade.js +215 -0
  14. package/src/lib/agent-guards.js +100 -0
  15. package/src/lib/agent-install.js +161 -0
  16. package/src/lib/agents.js +280 -0
  17. package/src/lib/claude-global.js +183 -0
  18. package/src/lib/detector.js +93 -0
  19. package/src/lib/hasher.js +21 -0
  20. package/src/lib/installer.js +213 -0
  21. package/src/lib/logger.js +16 -0
  22. package/src/lib/manifest.js +102 -0
  23. package/src/lib/reconcile.js +56 -0
  24. package/templates/.claude/CLAUDE.md +79 -0
  25. package/templates/.claude/hooks/comment-guard.js +126 -0
  26. package/templates/.claude/hooks/file-guard.js +216 -0
  27. package/templates/.claude/hooks/glob-guard.js +104 -0
  28. package/templates/.claude/hooks/path-guard.sh +118 -0
  29. package/templates/.claude/hooks/self-review.sh +27 -0
  30. package/templates/.claude/hooks/sensitive-guard.sh +227 -0
  31. package/templates/.claude/settings.json +68 -0
  32. package/templates/docs/WORKFLOW.md +325 -0
  33. package/templates/docs/specs/.gitkeep +0 -0
  34. package/templates/hooks/specpipe-read-guard.sh +42 -0
  35. package/templates/hooks/specpipe-shell-guard.sh +65 -0
  36. package/templates/rules/specpipe-guards.md +40 -0
  37. package/templates/scripts/test-hooks.sh +66 -0
  38. package/templates/skills/sp-build/SKILL.md +776 -0
  39. package/templates/skills/sp-challenge/SKILL.md +255 -0
  40. package/templates/skills/sp-commit/SKILL.md +174 -0
  41. package/templates/skills/sp-explore/SKILL.md +730 -0
  42. package/templates/skills/sp-fix/SKILL.md +266 -0
  43. package/templates/skills/sp-humanize/SKILL.md +212 -0
  44. package/templates/skills/sp-investigate/SKILL.md +648 -0
  45. package/templates/skills/sp-md-render/SKILL.md +200 -0
  46. package/templates/skills/sp-md-render/components.md +415 -0
  47. package/templates/skills/sp-md-render/template.html +283 -0
  48. package/templates/skills/sp-plan/SKILL.md +947 -0
  49. package/templates/skills/sp-review/SKILL.md +268 -0
  50. package/templates/skills/sp-scaffold/SKILL.md +237 -0
  51. package/templates/skills/sp-scaffold/references/ARCHITECTURE.md.tmpl +228 -0
  52. package/templates/skills/sp-scaffold/references/DESIGN.md.tmpl +113 -0
  53. package/templates/skills/sp-scaffold/references/adr/NNNN-template.md +92 -0
  54. package/templates/skills/sp-scaffold/references/stack-profiles/react.md +36 -0
  55. package/templates/skills/sp-spec-render/SKILL.md +254 -0
  56. package/templates/skills/sp-spec-render/components.md +418 -0
  57. package/templates/skills/sp-spec-render/examples/user-auth.html +749 -0
  58. package/templates/skills/sp-spec-render/examples/user-auth.md +114 -0
  59. package/templates/skills/sp-spec-render/template.html +222 -0
  60. package/templates/skills/sp-voices/SKILL.md +1184 -0
@@ -0,0 +1,213 @@
1
+ import { copyFile as fsCopyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { chmod } from 'node:fs/promises';
6
+ import { log } from './logger.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+
10
+ /**
11
+ * Component → file mappings.
12
+ */
13
+ export const COMPONENTS = {
14
+ hooks: [
15
+ '.claude/hooks/file-guard.js',
16
+ '.claude/hooks/path-guard.sh',
17
+ '.claude/hooks/comment-guard.js',
18
+ '.claude/hooks/glob-guard.js',
19
+ '.claude/hooks/self-review.sh',
20
+ '.claude/hooks/sensitive-guard.sh',
21
+ ],
22
+ skills: [
23
+ 'skills/sp-explore/SKILL.md',
24
+ 'skills/sp-scaffold/SKILL.md',
25
+ 'skills/sp-scaffold/references/ARCHITECTURE.md.tmpl',
26
+ 'skills/sp-scaffold/references/DESIGN.md.tmpl',
27
+ 'skills/sp-scaffold/references/adr/NNNN-template.md',
28
+ 'skills/sp-scaffold/references/stack-profiles/react.md',
29
+ 'skills/sp-plan/SKILL.md',
30
+ 'skills/sp-build/SKILL.md',
31
+ 'skills/sp-challenge/SKILL.md',
32
+ 'skills/sp-investigate/SKILL.md',
33
+ 'skills/sp-fix/SKILL.md',
34
+ 'skills/sp-review/SKILL.md',
35
+ 'skills/sp-commit/SKILL.md',
36
+ 'skills/sp-voices/SKILL.md',
37
+ 'skills/sp-spec-render/SKILL.md',
38
+ 'skills/sp-spec-render/template.html',
39
+ 'skills/sp-spec-render/components.md',
40
+ 'skills/sp-spec-render/examples/user-auth.md',
41
+ 'skills/sp-spec-render/examples/user-auth.html',
42
+ 'skills/sp-md-render/SKILL.md',
43
+ 'skills/sp-md-render/template.html',
44
+ 'skills/sp-md-render/components.md',
45
+ 'skills/sp-humanize/SKILL.md',
46
+ ],
47
+ config: [
48
+ '.claude/settings.json',
49
+ '.claude/CLAUDE.md',
50
+ ],
51
+ docs: [
52
+ 'docs/WORKFLOW.md',
53
+ ],
54
+ };
55
+
56
+ /**
57
+ * Placeholder directories to create.
58
+ */
59
+ export const PLACEHOLDER_DIRS = [
60
+ 'docs/specs',
61
+ 'docs/test-plans',
62
+ ];
63
+
64
+ /**
65
+ * Files that need +x permission.
66
+ */
67
+ export const EXECUTABLE_FILES = [
68
+ '.claude/hooks/path-guard.sh',
69
+ '.claude/hooks/self-review.sh',
70
+ '.claude/hooks/sensitive-guard.sh',
71
+ ];
72
+
73
+ /**
74
+ * Get path to kit (templates) directory.
75
+ * Published package: cli/templates/ | Dev mode: ../kit/
76
+ */
77
+ export function getTemplateDir() {
78
+ const bundled = resolve(__dirname, '../../templates');
79
+ if (existsSync(bundled)) return bundled;
80
+ return resolve(__dirname, '../../../kit');
81
+ }
82
+
83
+ /**
84
+ * Get all files for the given component list.
85
+ * @param {string[]} components - e.g. ['hooks', 'skills']
86
+ * @returns {string[]} relative file paths
87
+ */
88
+ export function getFilesForComponents(components) {
89
+ const files = [];
90
+ for (const comp of components) {
91
+ if (COMPONENTS[comp]) {
92
+ files.push(...COMPONENTS[comp]);
93
+ }
94
+ }
95
+ return files;
96
+ }
97
+
98
+ /**
99
+ * Get all installable files (all components).
100
+ */
101
+ export function getAllFiles() {
102
+ return Object.values(COMPONENTS).flat();
103
+ }
104
+
105
+ /**
106
+ * Copy a single file from templates to target.
107
+ * @returns {string} 'copied' | 'skipped' | 'identical'
108
+ */
109
+ export async function installFile(relativePath, targetDir, { force = false } = {}) {
110
+ const src = join(getTemplateDir(), relativePath);
111
+ const dst = join(targetDir, relativePath);
112
+
113
+ if (existsSync(dst) && !force) {
114
+ // Compare content to distinguish: identical, customized, or from another source
115
+ try {
116
+ const { hashFile } = await import('./hasher.js');
117
+ const srcHash = await hashFile(src);
118
+ const dstHash = await hashFile(dst);
119
+ if (srcHash === dstHash) {
120
+ log.same(`${relativePath} (identical)`);
121
+ return 'identical';
122
+ }
123
+ } catch { /* hash failed, treat as conflict */ }
124
+ log.warn(`${relativePath} (exists with different content — use --force to overwrite)`);
125
+ return 'skipped';
126
+ }
127
+
128
+ await mkdir(dirname(dst), { recursive: true });
129
+ await fsCopyFile(src, dst);
130
+ log.copy(relativePath);
131
+ return 'copied';
132
+ }
133
+
134
+ // Per-agent install (emit skills + guardrails) lives in agent-install.js;
135
+ // re-exported here so callers keep importing from installer.js.
136
+ export {
137
+ installSkillForAgent, installAgentSkills, installAgentRules,
138
+ mergeAgentsMdGuards, stripAgentsMdGuards,
139
+ installAgentHooks, removeAgentHooks,
140
+ } from './agent-install.js';
141
+
142
+ /**
143
+ * Create a placeholder directory with .gitkeep.
144
+ */
145
+ export async function ensurePlaceholderDir(dir, targetDir) {
146
+ const fullPath = join(targetDir, dir);
147
+ if (existsSync(fullPath)) {
148
+ log.skip(`${dir}/ (exists)`);
149
+ return;
150
+ }
151
+ await mkdir(fullPath, { recursive: true });
152
+ await writeFile(join(fullPath, '.gitkeep'), '');
153
+ log.make(`${dir}/`);
154
+ }
155
+
156
+ /**
157
+ * Set executable permissions on relevant files.
158
+ */
159
+ export async function setPermissions(targetDir) {
160
+ for (const file of EXECUTABLE_FILES) {
161
+ const fullPath = join(targetDir, file);
162
+ try {
163
+ await chmod(fullPath, 0o755);
164
+ } catch {
165
+ // File might not exist if component not installed
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Fill [CUSTOMIZE] placeholders in CLAUDE.md with detected project info.
172
+ */
173
+ export async function fillTemplate(targetDir, projectInfo) {
174
+ if (!projectInfo) return;
175
+
176
+ const claudeMdPath = join(targetDir, '.claude/CLAUDE.md');
177
+ try {
178
+ let content = await readFile(claudeMdPath, 'utf-8');
179
+ content = content
180
+ .replace(/\[CUSTOMIZE\] Language:.*/, `**Language:** ${projectInfo.lang}`)
181
+ .replace(/\[CUSTOMIZE\] Test framework:.*/, `**Test framework:** ${projectInfo.framework}`)
182
+ .replace(/\[CUSTOMIZE\] Source directory:.*/, `**Source directory:** ${projectInfo.srcDir}`)
183
+ .replace(/\[CUSTOMIZE\] Test directory:.*/, `**Test directory:** ${projectInfo.testDir}`)
184
+ // Also handle the format without [CUSTOMIZE] prefix
185
+ .replace(/\*\*Language:\*\* \[CUSTOMIZE\]/, `**Language:** ${projectInfo.lang}`)
186
+ .replace(/\*\*Test framework:\*\* \[CUSTOMIZE\]/, `**Test framework:** ${projectInfo.framework}`)
187
+ .replace(/\*\*Source directory:\*\* \[CUSTOMIZE\]/, `**Source directory:** ${projectInfo.srcDir}`)
188
+ .replace(/\*\*Test directory:\*\* \[CUSTOMIZE\]/, `**Test directory:** ${projectInfo.testDir}`);
189
+ await writeFile(claudeMdPath, content);
190
+ } catch {
191
+ // CLAUDE.md might not exist
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Verify settings.json is valid JSON.
197
+ */
198
+ export async function verifySettingsJson(targetDir) {
199
+ try {
200
+ const raw = await readFile(join(targetDir, '.claude/settings.json'), 'utf-8');
201
+ JSON.parse(raw);
202
+ return true;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ // Claude's global install (~/.claude/skills + hooks + settings.json) lives in
209
+ // claude-global.js; re-exported here so callers keep importing from installer.js.
210
+ export {
211
+ getGlobalSkillsDir, getGlobalHooksDir, installHookGlobal,
212
+ mergeGlobalSettings, removeGlobalHooksFromSettings, installSkillGlobal,
213
+ } from './claude-global.js';
@@ -0,0 +1,16 @@
1
+ import chalk from 'chalk';
2
+
3
+ export const log = {
4
+ info: (msg) => console.log(chalk.blue('[INFO]'), msg),
5
+ pass: (msg) => console.log(chalk.green('[PASS]'), msg),
6
+ fail: (msg) => console.log(chalk.red('[FAIL]'), msg),
7
+ warn: (msg) => console.log(chalk.yellow('[WARN]'), msg),
8
+ skip: (msg) => console.log(chalk.gray('[SKIP]'), msg),
9
+ copy: (msg) => console.log(chalk.cyan('[COPY]'), msg),
10
+ del: (msg) => console.log(chalk.red('[DEL]'), ' ', msg),
11
+ keep: (msg) => console.log(chalk.green('[KEEP]'), msg),
12
+ make: (msg) => console.log(chalk.cyan('[MAKE]'), msg),
13
+ adopt: (msg) => console.log(chalk.magenta('[ADOPT]'), msg),
14
+ same: (msg) => console.log(chalk.gray('[SAME]'), msg),
15
+ blank: () => console.log(),
16
+ };
@@ -0,0 +1,102 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { hashFile } from './hasher.js';
4
+
5
+ // Neutral, agent-agnostic location. Older installs used .claude/ — still read
6
+ // as a fallback so existing projects migrate on their next write.
7
+ export const MANIFEST_FILE = '.specpipe/manifest.json';
8
+ export const LEGACY_MANIFEST_FILE = '.claude/.devkit-manifest.json';
9
+
10
+ /**
11
+ * Read manifest from target directory (new location, then legacy fallback).
12
+ * @returns {object|null}
13
+ */
14
+ export async function readManifest(targetDir) {
15
+ for (const rel of [MANIFEST_FILE, LEGACY_MANIFEST_FILE]) {
16
+ try {
17
+ return JSON.parse(await readFile(join(targetDir, rel), 'utf-8'));
18
+ } catch { /* try next */ }
19
+ }
20
+ return null;
21
+ }
22
+
23
+ /**
24
+ * Write manifest to target directory (always to the new neutral location).
25
+ */
26
+ export async function writeManifest(targetDir, manifest) {
27
+ const filePath = join(targetDir, MANIFEST_FILE);
28
+ await mkdir(dirname(filePath), { recursive: true });
29
+ await writeFile(filePath, JSON.stringify(manifest, null, 2) + '\n');
30
+ }
31
+
32
+ /** Agents recorded in a manifest, defaulting to Claude for legacy installs. */
33
+ export function getAgents(manifest) {
34
+ return manifest?.agents?.length ? manifest.agents : ['claude'];
35
+ }
36
+
37
+ /** Union of already-installed agents (from manifest) with newly requested ones, order-stable. */
38
+ export function mergeAgents(existing, requested) {
39
+ const seen = new Set();
40
+ const out = [];
41
+ for (const a of [...(existing || []), ...requested]) {
42
+ if (!seen.has(a)) { seen.add(a); out.push(a); }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Create a new empty manifest.
49
+ */
50
+ export function createManifest(version, projectType, components) {
51
+ const now = new Date().toISOString();
52
+ return {
53
+ version,
54
+ installedAt: now,
55
+ updatedAt: now,
56
+ projectType: projectType || null,
57
+ components: components || ['hooks', 'skills', 'scripts', 'docs'],
58
+ agents: ['claude'],
59
+ files: {},
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Add or update a file entry in the manifest.
65
+ * `installedPath` is the on-disk key. `agent`/`templateRel` let lifecycle
66
+ * commands reproduce the file's desired content (default: Claude, verbatim).
67
+ */
68
+ export function setFileEntry(manifest, installedPath, kitHash, installedHash, { agent = 'claude', templateRel } = {}) {
69
+ manifest.files[installedPath] = {
70
+ agent,
71
+ templateRel: templateRel || installedPath,
72
+ kitHash,
73
+ installedHash: installedHash || kitHash,
74
+ customized: installedHash ? installedHash !== kitHash : false,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Check if a file has been customized by the user.
80
+ */
81
+ export function isCustomized(manifest, relativePath) {
82
+ const entry = manifest?.files?.[relativePath];
83
+ if (!entry) return false;
84
+ return entry.customized;
85
+ }
86
+
87
+ /**
88
+ * Refresh customization status by re-hashing installed files.
89
+ */
90
+ export async function refreshCustomizationStatus(targetDir, manifest) {
91
+ for (const [relativePath, entry] of Object.entries(manifest.files)) {
92
+ try {
93
+ const currentHash = await hashFile(join(targetDir, relativePath));
94
+ entry.installedHash = currentHash;
95
+ entry.customized = currentHash !== entry.kitHash;
96
+ } catch {
97
+ // File was deleted
98
+ entry.installedHash = null;
99
+ entry.customized = true;
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,56 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { hashContent } from './hasher.js';
4
+ import { getAllFiles, COMPONENTS, getTemplateDir } from './installer.js';
5
+ import { emitFile, emitRules } from './agents.js';
6
+
7
+ export const GUARDS_TEMPLATE_REL = 'rules/specpipe-guards.md';
8
+
9
+ /**
10
+ * Template files a given agent receives. Claude gets the full kit
11
+ * (hooks + config + docs + skills); every other agent gets skills only —
12
+ * their guardrails are emitted separately from kit/rules (see emitRules), since
13
+ * hooks are Claude-specific.
14
+ */
15
+ export function templateFilesForAgent(agentId) {
16
+ return agentId === 'claude' ? getAllFiles() : COMPONENTS.skills;
17
+ }
18
+
19
+ /**
20
+ * Compute the desired installed state for a set of agents.
21
+ * @param {string[]} agents
22
+ * @returns {Promise<Map<string, {agent, templateRel, content, kitHash}>>}
23
+ * keyed by installed (on-disk) relative path.
24
+ */
25
+ export async function computeDesired(agents) {
26
+ const dir = getTemplateDir();
27
+ const desired = new Map();
28
+ const guardsBody = await readFile(join(dir, GUARDS_TEMPLATE_REL), 'utf-8');
29
+
30
+ for (const agent of agents) {
31
+ for (const templateRel of templateFilesForAgent(agent)) {
32
+ const content = await readFile(join(dir, templateRel), 'utf-8');
33
+ const emitted = emitFile(agent, templateRel, content);
34
+ desired.set(emitted.path, {
35
+ agent,
36
+ templateRel,
37
+ content: emitted.content,
38
+ kitHash: hashContent(emitted.content),
39
+ });
40
+ }
41
+
42
+ // Owned guardrails files (Cursor .mdc, Antigravity rule, OpenClaw/Hermes doc)
43
+ // are reconciled like any other file. Codex's AGENTS.md is shared, not owned
44
+ // here — it's merged/stripped separately.
45
+ const rules = emitRules(agent, guardsBody);
46
+ if (rules && rules.mode !== 'agents-md') {
47
+ desired.set(rules.path, {
48
+ agent,
49
+ templateRel: GUARDS_TEMPLATE_REL,
50
+ content: rules.content,
51
+ kitHash: hashContent(rules.content),
52
+ });
53
+ }
54
+ }
55
+ return desired;
56
+ }
@@ -0,0 +1,79 @@
1
+ # Project Rules
2
+
3
+ ## Spec-First Development
4
+
5
+ Every change follows this cycle: **SPEC (with acceptance scenarios) → CODE + TESTS → BUILD PASS**.
6
+
7
+ - Business logic specs live in `docs/specs/<feature>/<feature>.md`
8
+ - Acceptance scenarios (Given/When/Then) are embedded in the spec under `## Stories`
9
+ - Never write code before the spec exists. Never auto-modify specs from code.
10
+ - Specs are the source of truth. If code contradicts the spec, the code is wrong.
11
+
12
+ ## Workflow Quick Reference
13
+
14
+ | Trigger | Commands | Details |
15
+ |---------|----------|---------|
16
+ | New project (no codebase yet) | `/sp-explore` (greenfield) → `/sp-scaffold` → `/sp-plan` → `/sp-build` | Scaffolds a runnable skeleton + ARCHITECTURE/ADRs before the first spec; `/sp-build` Foundation Gate blocks the TDD loop until a runnable harness exists |
17
+ | Feature unclear / complex | `/sp-explore` → `/sp-plan` | Clarify requirements before writing spec |
18
+ | New feature | `/sp-plan` → `/sp-challenge` (optional) → code in chunks → `/sp-build` each chunk | Start with spec or description |
19
+ | Update feature | `/sp-plan <spec-path> "changes"` → code → `/sp-build` | Do NOT manually edit spec before /sp-plan |
20
+ | Bug (complex/outage) | `/sp-investigate "description"` → `/sp-fix <investigation-file>` | OPTIONAL: diagnose root cause + blast radius before fixing |
21
+ | Bug fix | `/sp-fix "description"` | Test-first: write failing test → fix → green |
22
+ | Remove feature | `/sp-plan <spec-path> "remove stories"` → delete code + tests → build pass | /sp-plan handles snapshot before removal |
23
+ | Pre-merge check | `/sp-review` | Diff-based quality gate |
24
+ | Commit changes | `/sp-commit` | Secret scan + conventional commit |
25
+ | Render spec HTML | `/sp-spec-render <feature>` | Generates scannable `<feature>.html` (sidebar TOC, story cards). Run after `/sp-plan` if you want the HTML view, or to refresh a stale one |
26
+ | Render any markdown HTML | `/sp-md-render <file.md>` | Generic counterpart to `/sp-spec-render` for non-spec markdown (investigation, explore, RFC, retro, README). Callouts, step cards, Mermaid, dark/light theme |
27
+ | Multi-LLM review | `/sp-voices [target]` | Send material to 2–3 LLMs, synthesize consensus + disagreements |
28
+ | Rephrase to human voice | `/sp-humanize [text]` | Turn plan/notes/AI output into natural, send-ready text. Infers format + audience + tone, strips AI tone. Not part of the dev cycle |
29
+
30
+ For detailed workflow steps, templates, and decision trees, see `docs/WORKFLOW.md`.
31
+
32
+ ## Testing
33
+
34
+ - **Run tests:** use the project's native test command (e.g. `npx vitest run`, `swift test`, `python3 -m pytest`, `cargo test`, `go test ./...`). `/sp-build` and `/sp-fix` auto-detect it from project markers.
35
+ - **Compile/typecheck BEFORE running tests.** Catch syntax errors early.
36
+ - **Max 3 fix loops** for test failures. If tests still fail after 3 attempts, stop and report.
37
+ - **NEVER fix production code** to make a test pass — ask the user first.
38
+ - **No mocks, fakes, stubs, or cheats** to pass builds. Real implementations only.
39
+ Test doubles are acceptable only when they replace external services (APIs, databases)
40
+ that cannot run locally.
41
+
42
+ ## Project Info
43
+
44
+ > Fill these in when setting up the project (or let `setup.sh` do it automatically).
45
+
46
+ - **Language:** [CUSTOMIZE]
47
+ - **Test framework:** [CUSTOMIZE]
48
+ - **Source directory:** [CUSTOMIZE]
49
+ - **Test directory:** [CUSTOMIZE]
50
+
51
+ ## Conventions
52
+
53
+ - **Commits:** Conventional format — `type(scope): description`
54
+ Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `build`, `ci`
55
+ - **File naming:** Descriptive enough that AI tools understand the purpose from the path alone.
56
+ Prefer kebab-case for new files (e.g., `user-authentication-service.ts`).
57
+ - **Dates in filenames:** Use `$(date +%Y-%m-%d)` — never guess dates.
58
+ - **Spec naming:**
59
+ - kebab-case, lowercase: `user-auth/user-auth.md`, `file-sync/file-sync.md`
60
+ - Feature name, not module name: `user-auth/` not `AuthService/`
61
+ - Each feature gets its own directory: `docs/specs/<feature>/<feature>.md`
62
+ - Short (2-3 words): `payment-flow/` not `payment-processing-with-stripe-integration/`
63
+ - No prefix/suffix: `user-auth.md` not `spec-user-auth.md`
64
+
65
+ ## Forbidden
66
+
67
+ These patterns are never acceptable in this project:
68
+
69
+ - `any` / `Any` type without explicit justification in a comment
70
+ - Force unwrap (`!`) or force cast (`as!`) without a preceding guard
71
+ - Hardcoded secrets, API keys, tokens, or credentials in source files
72
+ - Mocks or fake data used solely to make tests pass
73
+ - `git push --force` to main or master branches
74
+ - Editing generated files, vendor directories, or lock files
75
+ - Committing `.env` files, certificates, or private keys
76
+ - Ignoring compiler/linter warnings without documented reason
77
+ - Replacing real code with placeholder comments like `// ... existing code ...`
78
+ - Renaming parameters to `_param` instead of actually fixing unused parameter issues
79
+ - Reading or writing `.env`, `.pem`, `.key`, or other sensitive files (use `.env.example` for templates)
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // comment-guard.js — PreToolUse hook for Claude Code
3
+ //
4
+ // Detects when an Edit would replace real code with placeholder comments like
5
+ // "// ... existing code ..." or "// rest of implementation". This is a
6
+ // common LLM failure mode where the model gets lazy and drops code.
7
+ //
8
+ // Blocking: Yes — exits 2 to reject the edit BEFORE it is applied.
9
+ // Event: PreToolUse on Edit|MultiEdit
10
+
11
+ "use strict";
12
+
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+
16
+ // Patterns that indicate lazy placeholder comments (case-insensitive)
17
+ const PLACEHOLDER_PATTERNS = [
18
+ /\/\/\s*\.{2,}\s*(existing|remaining|rest|previous|other|same|original)/i,
19
+ /\/\/\s*\.{2,}\s*(code|implementation|logic|methods|functions|properties)/i,
20
+ /\/\/\s*\[.*(?:remains?|unchanged|omitted|removed|truncated|collapsed).*\]/i,
21
+ /\/\/\s*(?:unchanged|omitted|keep|stays?)\s*(?:as\s*(?:is|before))?/i,
22
+ /\/\*\s*\.{2,}\s*\*\//, // /* ... */
23
+ /#\s*\.{2,}\s*(existing|remaining|rest|previous)/i, // Python: # ... existing
24
+ /\/\/\s*TODO:?\s*implement/i, // // TODO: implement
25
+ /\/\/\s*(?:add|put|insert)\s+.*\s+here/i, // // add code here
26
+ /\/\/\s*<\s*(?:your|actual)\s+/i, // // <your code>
27
+ /pass\s*#\s*(?:TODO|placeholder|implement)/i, // Python: pass # TODO
28
+ ];
29
+
30
+ function isCommentLine(line) {
31
+ const trimmed = line.trim();
32
+ if (trimmed === "") return true; // blank lines are neutral
33
+ if (trimmed.startsWith("//")) return true;
34
+ if (trimmed.startsWith("#") && !trimmed.startsWith("#!")) return true;
35
+ if (trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.endsWith("*/")) return true;
36
+ if (trimmed.startsWith("<!--")) return true;
37
+ if (trimmed === "pass" || /^pass\s*#/.test(trimmed)) return true; // Python pass / pass # comment
38
+ return false;
39
+ }
40
+
41
+ function getCodeLineCount(text) {
42
+ if (!text) return 0;
43
+ return text.split("\n").filter((line) => !isCommentLine(line)).length;
44
+ }
45
+
46
+ function hasPlaceholderPattern(text) {
47
+ return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(text));
48
+ }
49
+
50
+ function main() {
51
+ let input;
52
+ try {
53
+ input = fs.readFileSync(0, "utf-8").trim();
54
+ } catch {
55
+ process.exit(0);
56
+ }
57
+
58
+ if (!input) process.exit(0);
59
+
60
+ let payload;
61
+ try {
62
+ payload = JSON.parse(input);
63
+ } catch {
64
+ process.exit(0);
65
+ }
66
+
67
+ // Skip files outside the project directory (e.g. ~/.claude/plans/)
68
+ const filePath = payload.tool_input?.file_path;
69
+ if (filePath) {
70
+ const projectDir = process.cwd() + path.sep;
71
+ const resolved = path.resolve(filePath);
72
+ if (!resolved.startsWith(projectDir) && resolved !== process.cwd()) process.exit(0);
73
+ }
74
+
75
+ const oldStr = payload.tool_input?.old_string;
76
+ const newStr = payload.tool_input?.new_string;
77
+
78
+ // Only applies to Edit (not Write — Write creates new content)
79
+ if (!oldStr || !newStr) process.exit(0);
80
+
81
+ // If old content was already all comments, this is just editing comments — allow
82
+ const oldCodeLines = getCodeLineCount(oldStr);
83
+ if (oldCodeLines === 0) process.exit(0);
84
+
85
+ // If new content has real code, allow (even if it also has comments)
86
+ const newCodeLines = getCodeLineCount(newStr);
87
+ if (newCodeLines > 0) process.exit(0);
88
+
89
+ // At this point: old had code, new is all comments/blanks
90
+ // Check if the new content contains placeholder patterns
91
+ if (hasPlaceholderPattern(newStr)) {
92
+ process.stderr.write(
93
+ "Blocked: real code was replaced with placeholder comments. " +
94
+ "Preserve the original code and make targeted changes instead.\n"
95
+ );
96
+ process.exit(2);
97
+ }
98
+
99
+ // New is all comments but no placeholder pattern — could be intentional
100
+ // (e.g., replacing a code block with documentation comments)
101
+ // Allow but only if the replacement is not drastically shorter
102
+ const oldLines = oldStr.split("\n").length;
103
+ const newLines = newStr.split("\n").length;
104
+
105
+ // Allow 1-2 line replacements: a brief deletion note ("// Removed in v2") is
106
+ // intentional removal, not truncation. Only multi-line comment blocks that are
107
+ // still much shorter than the original suggest lazy placeholder replacement.
108
+ if (newLines > 2 && newLines < oldLines * 0.3) {
109
+ // Suspiciously shorter and all comments — likely lazy replacement
110
+ process.stderr.write(
111
+ "Blocked: code block was replaced with a much shorter comment-only block. " +
112
+ "This looks like an accidental truncation. Preserve the original code.\n"
113
+ );
114
+ process.exit(2);
115
+ }
116
+
117
+ // Seems intentional
118
+ process.exit(0);
119
+ }
120
+
121
+ try {
122
+ main();
123
+ } catch {
124
+ // Never crash — allow on error
125
+ process.exit(0);
126
+ }