@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/.claude/commands/brainstorm.md +360 -0
- package/.claude/commands/build.md +383 -0
- package/.claude/commands/init.md +371 -0
- package/.claude/commands/ship.md +349 -0
- package/.claude/settings.json +40 -0
- package/CLAUDE.md +179 -0
- package/README.md +452 -0
- package/agents/bolt.md +84 -0
- package/agents/echo.md +87 -0
- package/agents/friday.md +83 -0
- package/agents/jarvis.md +87 -0
- package/agents/muse.md +87 -0
- package/agents/neo.md +78 -0
- package/agents/oracle.md +81 -0
- package/agents/phantom.md +85 -0
- package/agents/pulse.md +95 -0
- package/bin/pdlc.js +221 -0
- package/hooks/pdlc-context-monitor.js +129 -0
- package/hooks/pdlc-guardrails.js +307 -0
- package/hooks/pdlc-session-start.sh +73 -0
- package/hooks/pdlc-statusline.js +183 -0
- package/package.json +48 -0
- package/scripts/frame-template.html +332 -0
- package/scripts/helper.js +88 -0
- package/scripts/server.cjs +357 -0
- package/scripts/start-server.sh +173 -0
- package/scripts/stop-server.sh +54 -0
- package/skills/reflect.md +189 -0
- package/skills/repo-scan.md +266 -0
- package/skills/review.md +156 -0
- package/skills/safety-guardrails.md +168 -0
- package/skills/ship.md +148 -0
- package/skills/tdd.md +88 -0
- package/skills/test.md +153 -0
- package/templates/CONSTITUTION.md +254 -0
- package/templates/INTENT.md +120 -0
- package/templates/OVERVIEW.md +93 -0
- package/templates/PRD.md +212 -0
- package/templates/STATE.md +113 -0
- package/templates/episode.md +182 -0
- package/templates/review.md +215 -0
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();
|