@pdlc-os/pdlc 0.1.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/bin/pdlc.js ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ const PLUGIN_ROOT = path.resolve(__dirname, '..');
10
+ const VERSION = require(path.join(PLUGIN_ROOT, 'package.json')).version;
11
+ const GLOBAL_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
12
+ const PLUGIN_SETTINGS_PATH = path.join(PLUGIN_ROOT, '.claude', 'settings.json');
13
+
14
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
15
+
16
+ function readJson(filePath) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
19
+ } catch (err) {
20
+ if (err.code === 'ENOENT') return null;
21
+ throw new Error(`Failed to parse ${filePath}: ${err.message}`);
22
+ }
23
+ }
24
+
25
+ function writeJson(filePath, data) {
26
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
27
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
28
+ }
29
+
30
+ function deepMerge(target, source) {
31
+ const result = Object.assign({}, target);
32
+ for (const key of Object.keys(source)) {
33
+ const src = source[key];
34
+ const tgt = result[key];
35
+ if (src !== null && typeof src === 'object' && !Array.isArray(src)) {
36
+ result[key] = deepMerge(tgt || {}, src);
37
+ } else if (Array.isArray(src) && Array.isArray(tgt)) {
38
+ const seen = new Set(tgt.map(JSON.stringify));
39
+ result[key] = [...tgt, ...src.filter(i => !seen.has(JSON.stringify(i)))];
40
+ } else {
41
+ result[key] = src;
42
+ }
43
+ }
44
+ return result;
45
+ }
46
+
47
+ function resolvePlaceholders(obj) {
48
+ return JSON.parse(
49
+ JSON.stringify(obj).replace(
50
+ /\$\{CLAUDE_PLUGIN_ROOT\}/g,
51
+ PLUGIN_ROOT.replace(/\\/g, '/')
52
+ )
53
+ );
54
+ }
55
+
56
+ /** Remove all PDLC-injected entries from a settings object (by plugin root path). */
57
+ function stripPdlc(settings) {
58
+ const rootStr = PLUGIN_ROOT.replace(/\\/g, '/');
59
+ const cleaned = JSON.parse(JSON.stringify(settings));
60
+
61
+ // Remove statusLine if it points to PDLC
62
+ if (cleaned.statusLine?.command?.includes(rootStr)) {
63
+ delete cleaned.statusLine;
64
+ }
65
+
66
+ // Remove PDLC entries from hook arrays
67
+ for (const event of ['PostToolUse', 'PreToolUse', 'SessionStart']) {
68
+ if (!Array.isArray(cleaned.hooks?.[event])) continue;
69
+ cleaned.hooks[event] = cleaned.hooks[event]
70
+ .map(group => {
71
+ if (!Array.isArray(group.hooks)) return group;
72
+ const filtered = group.hooks.filter(h => !h.command?.includes(rootStr));
73
+ return filtered.length ? { ...group, hooks: filtered } : null;
74
+ })
75
+ .filter(Boolean);
76
+ if (cleaned.hooks[event].length === 0) delete cleaned.hooks[event];
77
+ }
78
+ if (cleaned.hooks && Object.keys(cleaned.hooks).length === 0) {
79
+ delete cleaned.hooks;
80
+ }
81
+ return cleaned;
82
+ }
83
+
84
+ function isPdlcInstalled(settings) {
85
+ const rootStr = PLUGIN_ROOT.replace(/\\/g, '/');
86
+ return (
87
+ settings?.statusLine?.command?.includes(rootStr) ||
88
+ Object.values(settings?.hooks || {}).some(arr =>
89
+ arr.some(group =>
90
+ group.hooks?.some(h => h.command?.includes(rootStr))
91
+ )
92
+ )
93
+ );
94
+ }
95
+
96
+ // ─── Commands ─────────────────────────────────────────────────────────────────
97
+
98
+ function install() {
99
+ const pluginSettings = readJson(PLUGIN_SETTINGS_PATH);
100
+ if (!pluginSettings) {
101
+ console.error(`Error: Plugin settings not found at ${PLUGIN_SETTINGS_PATH}`);
102
+ process.exit(1);
103
+ }
104
+
105
+ const resolved = resolvePlaceholders(pluginSettings);
106
+ const global = readJson(GLOBAL_SETTINGS_PATH) ?? {};
107
+
108
+ if (isPdlcInstalled(global)) {
109
+ // Re-install: strip old entries first so paths stay current
110
+ const stripped = stripPdlc(global);
111
+ writeJson(GLOBAL_SETTINGS_PATH, deepMerge(stripped, resolved));
112
+ console.log(`\nPDLC updated to v${VERSION}.`);
113
+ } else {
114
+ writeJson(GLOBAL_SETTINGS_PATH, deepMerge(global, resolved));
115
+ console.log(`\nPDLC v${VERSION} installed successfully.`);
116
+ }
117
+
118
+ console.log(` Plugin root : ${PLUGIN_ROOT}`);
119
+ console.log(` Settings : ${GLOBAL_SETTINGS_PATH}`);
120
+ console.log('\nStart a new Claude Code session to activate.');
121
+ console.log('Next step : open a project and run /pdlc init\n');
122
+ }
123
+
124
+ function uninstall() {
125
+ const global = readJson(GLOBAL_SETTINGS_PATH);
126
+ if (!global) {
127
+ console.log('\nNo Claude settings found — nothing to uninstall.\n');
128
+ return;
129
+ }
130
+ if (!isPdlcInstalled(global)) {
131
+ console.log('\nPDLC is not currently installed in ~/.claude/settings.json.\n');
132
+ return;
133
+ }
134
+ writeJson(GLOBAL_SETTINGS_PATH, stripPdlc(global));
135
+ console.log('\nPDLC uninstalled. Hooks and statusLine removed from ~/.claude/settings.json.\n');
136
+ }
137
+
138
+ function status() {
139
+ const global = readJson(GLOBAL_SETTINGS_PATH);
140
+ const installed = global ? isPdlcInstalled(global) : false;
141
+
142
+ console.log(`\npdlc v${VERSION}`);
143
+ console.log(`Plugin root : ${PLUGIN_ROOT}`);
144
+ console.log(`Global hooks : ${GLOBAL_SETTINGS_PATH}`);
145
+ console.log(`Status : ${installed ? '✓ installed' : '✗ not installed'}`);
146
+
147
+ if (installed) {
148
+ console.log('\nHooks registered:');
149
+ console.log(' statusLine → pdlc-statusline.js');
150
+ console.log(' PostToolUse → pdlc-context-monitor.js');
151
+ console.log(' PreToolUse → pdlc-guardrails.js');
152
+ console.log(' SessionStart → pdlc-session-start.sh');
153
+ }
154
+ console.log('');
155
+ }
156
+
157
+ /**
158
+ * Called by `npm postinstall`. Skips silently during local development
159
+ * (when the package's own node_modules are being installed) to avoid
160
+ * the hook running in unexpected contexts.
161
+ */
162
+ function postinstall() {
163
+ // INIT_CWD is set by npm to the directory where `npm install` was run.
164
+ // If it equals the plugin root, the developer is installing the plugin's
165
+ // own deps — skip auto-install.
166
+ const initCwd = process.env.INIT_CWD || '';
167
+ if (path.resolve(initCwd) === PLUGIN_ROOT) return;
168
+
169
+ // Also skip in CI environments unless explicitly opted in.
170
+ if (process.env.CI && !process.env.PDLC_INSTALL_IN_CI) return;
171
+
172
+ install();
173
+ }
174
+
175
+ function printUsage() {
176
+ console.log(`
177
+ pdlc v${VERSION} — Product Development Lifecycle plugin for Claude Code
178
+
179
+ Usage:
180
+ npx @pdlc-os/pdlc install Register PDLC hooks in ~/.claude/settings.json
181
+ npx @pdlc-os/pdlc uninstall Remove PDLC hooks from ~/.claude/settings.json
182
+ npx @pdlc-os/pdlc status Show install status
183
+ npx @pdlc-os/pdlc --version Print version
184
+
185
+ Slash commands (inside a Claude Code session after install):
186
+ /pdlc init Phase 0 — Initialization: Constitution · Intent · Memory Bank · Beads
187
+ /pdlc brainstorm Phase 1 — Inception: Discover → Define → Design → Plan
188
+ /pdlc build Phase 2 — Construction: Build → Review → Test
189
+ /pdlc ship Phase 3 — Operation: Ship → Verify → Reflect
190
+
191
+ Marketplace: https://github.com/pdlc-os
192
+ `);
193
+ }
194
+
195
+ // ─── Entry point ──────────────────────────────────────────────────────────────
196
+
197
+ const [,, command, ...rest] = process.argv;
198
+
199
+ switch (command) {
200
+ case 'install':
201
+ install();
202
+ break;
203
+ case 'uninstall':
204
+ uninstall();
205
+ break;
206
+ case 'status':
207
+ status();
208
+ break;
209
+ case 'postinstall':
210
+ postinstall();
211
+ break;
212
+ case '--version':
213
+ case '-v':
214
+ console.log(VERSION);
215
+ break;
216
+ case '--help':
217
+ case '-h':
218
+ default:
219
+ printUsage();
220
+ break;
221
+ }
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ // pdlc-context-monitor.js — PDLC PostToolUse hook for Claude Code
3
+ // Fires after every tool execution; injects context warnings when context usage
4
+ // is high, and auto-checkpoints STATE.md on CRITICAL.
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ // ── Bridge file helpers ───────────────────────────────────────────────────────
12
+ function readBridge(bridgePath) {
13
+ try {
14
+ const raw = fs.readFileSync(bridgePath, 'utf8');
15
+ return JSON.parse(raw);
16
+ } catch (_) {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function writeBridge(bridgePath, data) {
22
+ try {
23
+ fs.writeFileSync(bridgePath, JSON.stringify(data, null, 2), 'utf8');
24
+ } catch (_) {
25
+ // Best-effort
26
+ }
27
+ }
28
+
29
+ // ── STATE.md checkpoint update ────────────────────────────────────────────────
30
+ // Replaces the JSON block inside the "## Context Checkpoint" section.
31
+ function updateContextCheckpoint(stateMdPath, sessionId) {
32
+ try {
33
+ let content = fs.readFileSync(stateMdPath, 'utf8');
34
+
35
+ const checkpoint = {
36
+ triggered_at: new Date().toISOString(),
37
+ session_id: sessionId,
38
+ active_task: null, // hooks don't have task context — Claude fills this in
39
+ sub_phase: null,
40
+ work_in_progress: null,
41
+ next_action: null,
42
+ files_open: [],
43
+ };
44
+
45
+ // Replace the JSON block inside the Context Checkpoint section.
46
+ // The block starts with ```json and ends with ``` on its own line.
47
+ const jsonBlock = '```json\n' + JSON.stringify(checkpoint, null, 2) + '\n```';
48
+ const updated = content.replace(
49
+ /```json[\s\S]*?```/,
50
+ jsonBlock
51
+ );
52
+
53
+ if (updated !== content) {
54
+ fs.writeFileSync(stateMdPath, updated, 'utf8');
55
+ }
56
+ } catch (_) {
57
+ // Best-effort — never crash the hook
58
+ }
59
+ }
60
+
61
+ // ── Main ──────────────────────────────────────────────────────────────────────
62
+ function main() {
63
+ let input = {};
64
+
65
+ try {
66
+ const raw = fs.readFileSync('/dev/stdin', 'utf8').trim();
67
+ if (raw) input = JSON.parse(raw);
68
+ } catch (_) {
69
+ // Unreadable stdin — proceed normally
70
+ process.stdout.write(JSON.stringify({ continue: true }));
71
+ process.exit(0);
72
+ }
73
+
74
+ const sessionId = input.session_id || 'unknown';
75
+ const cwd = input.cwd || process.cwd();
76
+
77
+ const bridgePath = `/tmp/pdlc-ctx-${sessionId}.json`;
78
+ const stateMdPath = path.join(cwd, 'docs', 'pdlc', 'memory', 'STATE.md');
79
+
80
+ // ── Read bridge file ────────────────────────────────────────────────────────
81
+ let bridge = readBridge(bridgePath);
82
+
83
+ if (!bridge) {
84
+ // First time we've seen this session — create defaults and proceed quietly
85
+ writeBridge(bridgePath, { used_pct: 0, tool_count: 0, session_id: sessionId });
86
+ process.stdout.write(JSON.stringify({ continue: true }));
87
+ process.exit(0);
88
+ }
89
+
90
+ const usedPct = typeof bridge.used_pct === 'number' ? bridge.used_pct : 0;
91
+ const toolCount = (typeof bridge.tool_count === 'number' ? bridge.tool_count : 0) + 1;
92
+
93
+ // Write updated tool_count immediately (before any early exit)
94
+ writeBridge(bridgePath, Object.assign({}, bridge, {
95
+ tool_count: toolCount,
96
+ session_id: sessionId,
97
+ }));
98
+
99
+ // ── Threshold checks ────────────────────────────────────────────────────────
100
+
101
+ // CRITICAL: used_pct >= 80 — fire on every tool call (modulo 1 === 0 always true)
102
+ if (usedPct >= 80) {
103
+ // Auto-save checkpoint in STATE.md
104
+ updateContextCheckpoint(stateMdPath, sessionId);
105
+
106
+ const msg =
107
+ `🚨 PDLC CRITICAL: Context at ${usedPct}% — PDLC is auto-saving your position. ` +
108
+ `Please finish this tool call and then run /pdlc build to resume from STATE.md.`;
109
+
110
+ process.stdout.write(JSON.stringify({ continue: true, systemMessage: msg }));
111
+ process.exit(0);
112
+ }
113
+
114
+ // WARNING: used_pct >= 65 — fire every 5 tool calls
115
+ if (usedPct >= 65 && toolCount % 5 === 0) {
116
+ const msg =
117
+ `⚠️ PDLC Context Warning: Context at ${usedPct}% — recommend wrapping up current task ` +
118
+ `and saving state to docs/pdlc/memory/STATE.md before context compacts.`;
119
+
120
+ process.stdout.write(JSON.stringify({ continue: true, systemMessage: msg }));
121
+ process.exit(0);
122
+ }
123
+
124
+ // Normal: proceed without injection
125
+ process.stdout.write(JSON.stringify({ continue: true }));
126
+ process.exit(0);
127
+ }
128
+
129
+ main();
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ // pdlc-guardrails.js — PDLC PreToolUse hook for Claude Code
3
+ // Fires before every Bash tool execution; enforces Tier 1 (hard block) and
4
+ // Tier 2 (pause & confirm) safety guardrails defined in plan.md / CONSTITUTION.md.
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ // ── Output helpers ────────────────────────────────────────────────────────────
12
+ function allow() {
13
+ process.stdout.write(JSON.stringify({ continue: true }));
14
+ process.exit(0);
15
+ }
16
+
17
+ function block(reason) {
18
+ process.stdout.write(JSON.stringify({ continue: false, reason }));
19
+ process.exit(0);
20
+ }
21
+
22
+ function warn(systemMessage) {
23
+ process.stdout.write(JSON.stringify({ continue: true, systemMessage }));
24
+ process.exit(0);
25
+ }
26
+
27
+ // ── Tier 1 block message builder ─────────────────────────────────────────────
28
+ function tier1Block(reason, command) {
29
+ const msg =
30
+ `\x1b[41m\x1b[37m ⛔ PDLC HARD BLOCK — TIER 1 SAFETY ⛔ \x1b[0m\n` +
31
+ `\x1b[31mThis action is blocked: ${reason}.\n\n` +
32
+ `To override, you must confirm TWICE by running:\n` +
33
+ ` /pdlc override-tier1 "${command}"\x1b[0m`;
34
+ block(msg);
35
+ }
36
+
37
+ // ── Tier 2 confirmation message builder ──────────────────────────────────────
38
+ function tier2Block(command) {
39
+ const msg =
40
+ `⚠️ PDLC Tier 2 Confirmation Required\n\n` +
41
+ `About to run: ${command}\n\n` +
42
+ `Type 'yes' to confirm or 'no' to cancel.`;
43
+ block(msg);
44
+ }
45
+
46
+ function tier2Logged(command) {
47
+ warn(`⚠️ Tier 2 action logged: ${command}`);
48
+ }
49
+
50
+ // ── CONSTITUTION.md helpers ───────────────────────────────────────────────────
51
+ function readConstitution(cwd) {
52
+ try {
53
+ const constitutionPath = path.join(cwd, 'docs', 'pdlc', 'memory', 'CONSTITUTION.md');
54
+ return fs.readFileSync(constitutionPath, 'utf8');
55
+ } catch (_) {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // Parse tier2_as_tier3 overrides from the "## 8. Safety Guardrail Overrides" table.
61
+ // Returns an array of lowercase tier 2 item strings that have been downgraded.
62
+ function parseTier2Overrides(constitutionContent) {
63
+ if (!constitutionContent) return [];
64
+
65
+ const overrides = [];
66
+
67
+ // Find the Safety Guardrail Overrides section
68
+ const sectionMatch = constitutionContent.match(
69
+ /##\s*8\.\s*Safety Guardrail Overrides([\s\S]*?)(?=\n##\s|\n---\s*$|$)/i
70
+ );
71
+ if (!sectionMatch) return overrides;
72
+
73
+ const section = sectionMatch[1];
74
+
75
+ // Parse markdown table rows — skip the header and separator rows
76
+ const tableRowRe = /^\|\s*([^|]+?)\s*\|/gm;
77
+ let match;
78
+ let rowIndex = 0;
79
+
80
+ while ((match = tableRowRe.exec(section)) !== null) {
81
+ rowIndex++;
82
+ if (rowIndex <= 2) continue; // skip header + separator
83
+
84
+ const cell = match[1].trim().toLowerCase();
85
+ // Skip placeholder rows
86
+ if (!cell || cell.startsWith('<!--') || cell === 'tier 2 item') continue;
87
+ overrides.push(cell);
88
+ }
89
+
90
+ return overrides;
91
+ }
92
+
93
+ // Check if a command string matches any of the downgraded tier2 items
94
+ function isTier2Downgraded(command, overrides) {
95
+ const cmdLower = command.toLowerCase();
96
+ return overrides.some(override => {
97
+ if (!override || override.startsWith('<!--')) return false;
98
+ // Match by keyword presence
99
+ return cmdLower.includes(override) || override.includes(cmdLower.slice(0, 20));
100
+ });
101
+ }
102
+
103
+ // ── STATE.md test-gate check ──────────────────────────────────────────────────
104
+ // Returns true if test gates appear to have passed (or if STATE.md is absent —
105
+ // in that case we give the benefit of the doubt).
106
+ function testGatesHavePassed(cwd) {
107
+ try {
108
+ const statePath = path.join(cwd, 'docs', 'pdlc', 'memory', 'STATE.md');
109
+ const content = fs.readFileSync(statePath, 'utf8');
110
+ // Look for explicit gate-failure markers written by PDLC hooks.
111
+ // If the phase is NOT Operation/Ship, deploy is likely premature.
112
+ const phaseMatch = content.match(/##\s*Current Phase\s*\n[\s\S]*?\n([A-Za-z]+)/);
113
+ if (phaseMatch) {
114
+ const phase = phaseMatch[1].trim().toLowerCase();
115
+ // Only allow deploy commands during Operation phase
116
+ if (phase !== 'operation') return false;
117
+ }
118
+ return true;
119
+ } catch (_) {
120
+ return true; // No STATE.md — don't block
121
+ }
122
+ }
123
+
124
+ // ── Deploy command detection ──────────────────────────────────────────────────
125
+ const DEPLOY_PATTERNS = [
126
+ /\bfly\s+deploy\b/i,
127
+ /\bvercel\s+deploy\b/i,
128
+ /\bnpm\s+run\s+deploy\b/i,
129
+ /\byarn\s+deploy\b/i,
130
+ /\bpnpm\s+deploy\b/i,
131
+ /\bheroku\s+.*deploy\b/i,
132
+ /\baws\s+.*deploy\b/i,
133
+ /\bgcloud\s+.*deploy\b/i,
134
+ /\bdocker\s+.*push\b/i,
135
+ /\bkubectl\s+apply\b/i,
136
+ /\bterraform\s+apply\b/i,
137
+ /\bpulumi\s+up\b/i,
138
+ /\bcdk\s+deploy\b/i,
139
+ /\beb\s+deploy\b/i,
140
+ /\bsam\s+deploy\b/i,
141
+ /\bserverless\s+deploy\b/i,
142
+ ];
143
+
144
+ function isDeployCommand(cmd) {
145
+ return DEPLOY_PATTERNS.some(re => re.test(cmd));
146
+ }
147
+
148
+ // ── Production DB detection ───────────────────────────────────────────────────
149
+ const PROD_DB_INDICATORS = [
150
+ /prod(uction)?[_-]?(db|database|host|url|pg|mysql|sqlite)/i,
151
+ /DATABASE_URL.*prod/i,
152
+ /-h\s+(prod|production|db\.prod|rds\.amazonaws)/i,
153
+ /postgresql:\/\/.*prod/i,
154
+ /mysql:\/\/.*prod/i,
155
+ /@prod[^a-z]/i,
156
+ ];
157
+
158
+ function isProductionDbCommand(cmd) {
159
+ if (!/\b(psql|mysql|sqlite3|mariadb)\b/.test(cmd)) return false;
160
+ return PROD_DB_INDICATORS.some(re => re.test(cmd));
161
+ }
162
+
163
+ // ── External write call detection ─────────────────────────────────────────────
164
+ const EXTERNAL_WRITE_PATTERNS = [
165
+ /curl\s+.*-X\s+(POST|PUT|PATCH|DELETE)/i,
166
+ /curl\s+.*(--data|--data-raw|--data-binary|-d\s)/i,
167
+ /wget\s+.*--post(-data)?/i,
168
+ /\bfetch\b.*\b(POST|PUT|PATCH|DELETE)\b/i,
169
+ /\baxios\.(post|put|patch|delete)\b/i,
170
+ /\bhttpie\b.*\b(POST|PUT|DELETE)\b/i,
171
+ ];
172
+
173
+ // Only flag calls to clearly external (non-localhost) URLs
174
+ const EXTERNAL_URL_PATTERN = /https?:\/\/(?!localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|\[::1\])/i;
175
+
176
+ function isExternalWriteCall(cmd) {
177
+ return EXTERNAL_WRITE_PATTERNS.some(re => re.test(cmd)) &&
178
+ EXTERNAL_URL_PATTERN.test(cmd);
179
+ }
180
+
181
+ // ── Main ──────────────────────────────────────────────────────────────────────
182
+ function main() {
183
+ let input = {};
184
+
185
+ try {
186
+ const raw = fs.readFileSync('/dev/stdin', 'utf8').trim();
187
+ if (raw) input = JSON.parse(raw);
188
+ } catch (_) {
189
+ allow();
190
+ }
191
+
192
+ // Only act on Bash tool calls
193
+ if (input.tool_name !== 'Bash') {
194
+ allow();
195
+ }
196
+
197
+ const command = (input.tool_input && input.tool_input.command) || '';
198
+ const cwd = input.cwd || process.cwd();
199
+
200
+ if (!command) allow();
201
+
202
+ // ── Read CONSTITUTION.md ────────────────────────────────────────────────────
203
+ const constitution = readConstitution(cwd);
204
+ const tier2Overrides = parseTier2Overrides(constitution);
205
+
206
+ // ═══════════════════════════════════════════════════════════════════════════
207
+ // TIER 1 — Hard blocks
208
+ // ═══════════════════════════════════════════════════════════════════════════
209
+
210
+ // 1a. Force-push to main / master
211
+ if (
212
+ /git\s+push\s+.*(-f\b|--force\b)/.test(command) &&
213
+ /\b(main|master)\b/.test(command)
214
+ ) {
215
+ tier1Block('force-pushing to main/master is not allowed', command);
216
+ }
217
+
218
+ // Also catch: git push --force (without explicit branch but implicitly dangerous)
219
+ if (/git\s+push\s+(-f\b|--force\b)/.test(command) && !/git\s+push\s+.*-f\s+\S+\s+\S+:\S+/.test(command)) {
220
+ // If no refspec that maps to a non-protected branch, be safe and check:
221
+ if (!command.includes('feature/') && !command.includes('fix/') && !command.includes('chore/')) {
222
+ tier1Block('force-pushing without an explicit non-protected branch target', command);
223
+ }
224
+ }
225
+
226
+ // 1b. DROP TABLE without preceding migration file
227
+ if (/DROP\s+TABLE\b/i.test(command)) {
228
+ // We don't have full session history here, so always block and ask for
229
+ // confirmation that a migration file has been created.
230
+ tier1Block(
231
+ 'DROP TABLE detected — a migration file must be created before dropping tables',
232
+ command
233
+ );
234
+ }
235
+
236
+ // 1c. rm -rf on paths that look outside the project/feature scope
237
+ if (/rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\s+/.test(command)) {
238
+ // Extract the target path
239
+ const rmMatch = command.match(/rm\s+(?:-[a-zA-Z]+\s+)+(.+)$/);
240
+ const target = rmMatch ? rmMatch[1].trim() : '';
241
+
242
+ // Block if target is absolute and appears to be outside the project
243
+ const isAbsolute = target.startsWith('/');
244
+ const isInProject = isAbsolute && target.startsWith(cwd);
245
+ const isDotDot = target.includes('../') || target.includes('/..');
246
+ const isHome = /^~\/|^\/home\/|^\/Users\//.test(target) && !isInProject;
247
+ const isSystemPath = /^(\/etc|\/var|\/usr|\/bin|\/sbin|\/lib|\/opt|\/sys|\/proc|\/dev)/.test(target);
248
+
249
+ if (isSystemPath || (isAbsolute && !isInProject) || isDotDot || isHome) {
250
+ tier1Block(
251
+ `rm -rf on a path outside the project directory (${target || 'unknown'})`,
252
+ command
253
+ );
254
+ }
255
+ // Otherwise falls through to Tier 2 check below
256
+ }
257
+
258
+ // 1d. Deploy commands when test gates haven't passed
259
+ if (isDeployCommand(command) && !testGatesHavePassed(cwd)) {
260
+ tier1Block(
261
+ 'deploy attempted before test gates have passed — run /pdlc ship to go through the proper Ship flow',
262
+ command
263
+ );
264
+ }
265
+
266
+ // ═══════════════════════════════════════════════════════════════════════════
267
+ // TIER 2 — Pause & confirm (or log if downgraded via CONSTITUTION.md)
268
+ // ═══════════════════════════════════════════════════════════════════════════
269
+
270
+ function handleTier2(label) {
271
+ if (isTier2Downgraded(label, tier2Overrides)) {
272
+ tier2Logged(command);
273
+ } else {
274
+ tier2Block(command);
275
+ }
276
+ }
277
+
278
+ // 2a. rm -rf (non-Tier-1 cases — reached here only if not blocked above)
279
+ if (/rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\s+/.test(command)) {
280
+ handleTier2('rm -rf');
281
+ }
282
+
283
+ // 2b. git reset --hard
284
+ if (/git\s+reset\s+--hard\b/.test(command)) {
285
+ handleTier2('git reset --hard');
286
+ }
287
+
288
+ // 2c. Production DB access
289
+ if (isProductionDbCommand(command)) {
290
+ handleTier2('running db migrations in production');
291
+ }
292
+
293
+ // 2d. External API write calls (curl/fetch with POST/PUT/DELETE to external URLs)
294
+ if (isExternalWriteCall(command)) {
295
+ handleTier2('any external api call that writes/posts/sends');
296
+ }
297
+
298
+ // 2e. Modifying CONSTITUTION.md
299
+ if (/CONSTITUTION\.md/.test(command) && /\b(write|edit|echo|printf|sed|awk|mv|cp|tee|>|>>)\b/.test(command)) {
300
+ handleTier2('changing constitution.md');
301
+ }
302
+
303
+ // ── All clear ───────────────────────────────────────────────────────────────
304
+ allow();
305
+ }
306
+
307
+ main();