agileflow 4.0.0-alpha.1 → 4.0.0-alpha.2
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/CHANGELOG.md +29 -0
- package/content/plugins/core/hooks/babysit-mentor-injector.js +55 -0
- package/content/plugins/core/hooks/context-loader.js +169 -0
- package/content/plugins/core/hooks/damage-control-bash.js +78 -0
- package/content/plugins/core/hooks/damage-control-edit.js +76 -0
- package/content/plugins/core/hooks/damage-control-patterns.yaml +100 -0
- package/content/plugins/core/hooks/damage-control-write.js +72 -0
- package/content/plugins/core/hooks/pre-compact-state.js +90 -0
- package/content/plugins/core/plugin.yaml +52 -4
- package/package.json +1 -1
- package/src/cli/commands/setup.js +85 -47
- package/src/cli/commands/update.js +11 -10
- package/src/cli/wizard/behaviors-picker.js +108 -0
- package/src/runtime/config/defaults.js +21 -5
- package/src/runtime/config/loader.js +14 -15
- package/src/runtime/config/schema.json +23 -0
- package/src/runtime/config/writer.js +7 -6
- package/src/runtime/hooks/aggregator.js +44 -20
- package/src/runtime/installer/install.js +54 -31
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
All notable changes to `agileflow` v4 are documented here.
|
|
4
4
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [4.0.0-alpha.2] — 2026-04-20
|
|
7
|
+
|
|
8
|
+
Curated behavior presets — first hooks ship, but never as a free-for-all.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Behavior presets in `agileflow.config.json`** (`behaviors: { loadContext, babysitDefault, damageControl, preCompactState }`). Each preset maps 1:N to hooks declared in plugin manifests via a new `behavior: <key>` field. Disabling a preset excludes its hooks from the generated `.agileflow/hook-manifest.yaml` — the script literally does not run.
|
|
13
|
+
- **Wizard step**: `pickBehaviors()` (`src/cli/wizard/behaviors-picker.js`) — Clack multiselect with all four presets pre-checked. Only shown for IDEs that support hooks (claude-code today; cursor/windsurf/codex skip it).
|
|
14
|
+
- **Six behavior-gated hooks in the `core` plugin**:
|
|
15
|
+
- `context-loader` (SessionStart, gated by `loadContext`) — lean v4 replacement for v3's 79KB welcome banner. Prints stories, dirty files, and recent commits.
|
|
16
|
+
- `babysit-mentor-injector` (SessionStart, gated by `babysitDefault`) — HARD mode mentor injection. Claude defaults to the `/agileflow:babysit` mentor pattern without explicit invocation.
|
|
17
|
+
- `damage-control-bash` / `-edit` / `-write` (PreToolUse, gated by `damageControl`, `skipOnError: false`) — pattern-driven safety net (`damage-control-patterns.yaml`). Blocks `rm -rf /`, `dd to /dev/sda`, fork bombs, writes to `.env`/`.ssh/`, and similar.
|
|
18
|
+
- `pre-compact-state` (PreCompact, gated by `preCompactState`) — dumps active stories, current command, and dirty git state so they survive Claude's compaction summary.
|
|
19
|
+
- **Schema + loader + writer updates**: `behaviors` is now a first-class config section. `mergeConfig` deep-merges across one level so partial user overrides don't wipe defaults.
|
|
20
|
+
- **Aggregator behavior gating**: `buildHookManifest(orderedPlugins, behaviors)` filters hooks where `behaviors[entry.behavior] === false`. Missing keys treated as enabled (preserves intent of partial configs).
|
|
21
|
+
- **15 new tests** covering behaviors filtering in the aggregator, behaviors-picker pure helpers, and the install-time integration test that asserts the manifest reflects the toggle map. Total: **304 passing** (+15 from alpha.1).
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- `installPlugins(options)` accepts a `behaviors` option threaded through to `writeAggregatedManifest`.
|
|
26
|
+
- `agileflow setup` now includes a behaviors step after personalization for hook-capable IDEs.
|
|
27
|
+
- `agileflow update` re-reads `behaviors` from the saved config so manual edits to `agileflow.config.json` propagate without re-running the wizard.
|
|
28
|
+
- `defaultConfig()` ships with all four behaviors enabled. Users opt **out** at the wizard, never opt in.
|
|
29
|
+
|
|
30
|
+
### Why curated, not free configuration
|
|
31
|
+
|
|
32
|
+
Per user feedback in alpha.1: "I don't want it to be super customizable. Users would be able to customize pretty much anything." Free hook configuration (declare any event + any matcher + any script) is technically possible via the `hooks:` map, but the wizard surface is restricted to four presets. This keeps the install path predictable and prevents the v3-era anti-pattern of every plugin author shipping their own SessionStart welcome banner.
|
|
33
|
+
|
|
6
34
|
## [4.0.0-alpha.1] — Unreleased
|
|
7
35
|
|
|
8
36
|
v4 Phase 1 skeleton. Not yet publishable.
|
|
@@ -254,6 +282,7 @@ The path from "green test suite" to "user can `npm i agileflow@alpha`" closes he
|
|
|
254
282
|
- **Verified `npm pack --dry-run`** from `apps/cli/`: ships 52 files (bin/, src/, content/, README.md, LICENSE, CHANGELOG.md) — 65 KB tarball, 210 KB unpacked. Tests + vitest config + node_modules excluded.
|
|
255
283
|
|
|
256
284
|
**To ship `4.0.0-alpha.1` now**:
|
|
285
|
+
|
|
257
286
|
```bash
|
|
258
287
|
cd apps/cli && npm version 4.0.0-alpha.1 --no-git-tag-version
|
|
259
288
|
git commit -am "release(v4): 4.0.0-alpha.1"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Core hook: babysit-mentor-injector (SessionStart, HARD mode).
|
|
4
|
+
*
|
|
5
|
+
* Pre-loads the babysit mentor pattern into the session by printing
|
|
6
|
+
* its operating rules to stdout. Claude Code includes this in the
|
|
7
|
+
* session context, so Claude defaults to mentor behavior (smart
|
|
8
|
+
* AskUserQuestion at every decision point, plan mode for non-trivial
|
|
9
|
+
* tasks, expert delegation, task tracking, audits) without the user
|
|
10
|
+
* needing to type "walk me through".
|
|
11
|
+
*
|
|
12
|
+
* Disable this hook in agileflow.config.json if you want quick-edit
|
|
13
|
+
* mode by default — the agileflow-babysit-mentor skill still
|
|
14
|
+
* activates on its keyword triggers when needed.
|
|
15
|
+
*
|
|
16
|
+
* Always exits 0.
|
|
17
|
+
*/
|
|
18
|
+
process.stdout.write(`## AgileFlow Mentor Mode (default-on)
|
|
19
|
+
|
|
20
|
+
This session has the babysit-mentor pattern enabled. Apply these
|
|
21
|
+
rules unless the user explicitly opts out for a specific request:
|
|
22
|
+
|
|
23
|
+
1. **Smart AskUserQuestion at every decision point.** When the work
|
|
24
|
+
reaches a meaningful choice (which task, which approach, whether
|
|
25
|
+
to commit), end the response with the AskUserQuestion tool —
|
|
26
|
+
specific options with one marked (Recommended). Never generic
|
|
27
|
+
"Continue?" / "What next?".
|
|
28
|
+
|
|
29
|
+
2. **Plan mode for non-trivial implementation.** Call EnterPlanMode
|
|
30
|
+
for anything more than a typo/one-liner. Explore 3–5 files,
|
|
31
|
+
write the plan, ExitPlanMode. Skip for trivial fixes.
|
|
32
|
+
|
|
33
|
+
3. **Delegate complex work to domain experts.** Use the Task tool
|
|
34
|
+
with appropriate subagent_type (database / api / ui / testing /
|
|
35
|
+
security / etc.) for complex single-domain work. Use the
|
|
36
|
+
orchestrator for multi-domain features. Use multi-expert for
|
|
37
|
+
review/analysis questions.
|
|
38
|
+
|
|
39
|
+
4. **Track progress.** TaskCreate for any task with 3+ steps.
|
|
40
|
+
TaskUpdate as each completes. Don't batch.
|
|
41
|
+
|
|
42
|
+
5. **Suggest a logic audit after every implementation.** After tests
|
|
43
|
+
pass, present "🔍 Run logic audit on modified files" as
|
|
44
|
+
(Recommended). 5 analyzers catch edge cases tests miss.
|
|
45
|
+
|
|
46
|
+
6. **Suggest a flow audit when user-flows changed.** Plans for
|
|
47
|
+
non-trivial features must include a "Verify flow integrity"
|
|
48
|
+
step. After tests pass on flow-touching code, suggest
|
|
49
|
+
"🔄 Run flow audit" before commit.
|
|
50
|
+
|
|
51
|
+
To disable mentor mode entirely: edit agileflow.config.json,
|
|
52
|
+
set hooks.babysit-mentor-injector.enabled to false, run
|
|
53
|
+
\`agileflow update\`.
|
|
54
|
+
`);
|
|
55
|
+
process.exit(0);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Core hook: context-loader (SessionStart).
|
|
4
|
+
*
|
|
5
|
+
* Prints a compact project snapshot to stdout so Claude Code includes
|
|
6
|
+
* it in the session prompt. Lean v4 implementation (~200 lines, vs.
|
|
7
|
+
* v3's 79KB welcome banner). Output is pure text — no ANSI colors.
|
|
8
|
+
*
|
|
9
|
+
* Sections (ordered by load-bearing-ness for Claude):
|
|
10
|
+
* 1. Project header (cwd, agileflow version, ide)
|
|
11
|
+
* 2. Active story / epic (from docs/09-agents/status.json if present)
|
|
12
|
+
* 3. Ready stories (top 5 by priority)
|
|
13
|
+
* 4. Git state (branch, dirty files)
|
|
14
|
+
* 5. Recent commits (last 5)
|
|
15
|
+
* 6. Hook health (last 3 entries from hook-execution.jsonl)
|
|
16
|
+
*
|
|
17
|
+
* Fail-open: any read error becomes a one-line "(unavailable)" note.
|
|
18
|
+
* The hook always exits 0; orchestrator-side `skipOnError: true` is
|
|
19
|
+
* defensive belt-and-suspenders.
|
|
20
|
+
*/
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const { execSync } = require("child_process");
|
|
24
|
+
|
|
25
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
26
|
+
const agileflowDir = path.join(projectDir, ".agileflow");
|
|
27
|
+
|
|
28
|
+
/** Read JSON file, returning null on any error. */
|
|
29
|
+
function readJSON(p) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Run a git command; return its stdout trimmed, or null on error. */
|
|
38
|
+
function git(args) {
|
|
39
|
+
try {
|
|
40
|
+
return execSync(`git ${args}`, {
|
|
41
|
+
cwd: projectDir,
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
44
|
+
}).trim();
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Tail the last N JSONL entries from a file. */
|
|
51
|
+
function tailJSONL(p, n) {
|
|
52
|
+
try {
|
|
53
|
+
const lines = fs.readFileSync(p, "utf8").trim().split("\n");
|
|
54
|
+
return lines
|
|
55
|
+
.slice(-n)
|
|
56
|
+
.map((l) => {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(l);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lines = [];
|
|
70
|
+
const out = (s = "") => lines.push(s);
|
|
71
|
+
|
|
72
|
+
out("## AgileFlow project context");
|
|
73
|
+
out("");
|
|
74
|
+
|
|
75
|
+
// 1. Project header
|
|
76
|
+
const config = readJSON(path.join(projectDir, "agileflow.config.json")) || {};
|
|
77
|
+
const enabledPlugins = Object.entries(config.plugins || {})
|
|
78
|
+
.filter(([, v]) => v && v.enabled)
|
|
79
|
+
.map(([id]) => id);
|
|
80
|
+
out(`cwd: ${projectDir}`);
|
|
81
|
+
out(`ide: ${(config.ide && config.ide.primary) || "claude-code"}`);
|
|
82
|
+
out(`plugins: ${enabledPlugins.join(", ") || "(none)"}`);
|
|
83
|
+
out(
|
|
84
|
+
`tone: ${(config.personalization && config.personalization.tone) || "concise"}`,
|
|
85
|
+
);
|
|
86
|
+
out("");
|
|
87
|
+
|
|
88
|
+
// 2. Active story / epic
|
|
89
|
+
const status = readJSON(path.join(projectDir, "docs/09-agents/status.json"));
|
|
90
|
+
if (status && status.stories) {
|
|
91
|
+
const inProgress = Object.entries(status.stories)
|
|
92
|
+
.filter(([, s]) => s && s.status === "in_progress")
|
|
93
|
+
.map(([id, s]) => ({ id, ...s }));
|
|
94
|
+
if (inProgress.length) {
|
|
95
|
+
out("## In progress");
|
|
96
|
+
for (const s of inProgress) {
|
|
97
|
+
out(
|
|
98
|
+
` ${s.id} ${s.title || ""} (owner: ${s.owner || "?"}, est: ${s.estimate || "?"})`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
out("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Ready stories — top 5 by priority then estimate
|
|
105
|
+
const ready = Object.entries(status.stories)
|
|
106
|
+
.filter(([, s]) => s && s.status === "ready")
|
|
107
|
+
.map(([id, s]) => ({ id, ...s }))
|
|
108
|
+
.sort((a, b) => {
|
|
109
|
+
const pa = parseInt(String(a.priority || "P9").replace("P", ""), 10);
|
|
110
|
+
const pb = parseInt(String(b.priority || "P9").replace("P", ""), 10);
|
|
111
|
+
if (pa !== pb) return pa - pb;
|
|
112
|
+
return (a.estimate || 99) - (b.estimate || 99);
|
|
113
|
+
})
|
|
114
|
+
.slice(0, 5);
|
|
115
|
+
if (ready.length) {
|
|
116
|
+
out("## Ready (top 5)");
|
|
117
|
+
for (const s of ready) {
|
|
118
|
+
out(
|
|
119
|
+
` ${s.id} [${s.priority || "P?"} · ${s.estimate || "?"}pts] ${s.title || ""}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
out("");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 4. Git state
|
|
127
|
+
const branch = git("rev-parse --abbrev-ref HEAD");
|
|
128
|
+
const dirty = git("status --short");
|
|
129
|
+
if (branch) {
|
|
130
|
+
out("## Git");
|
|
131
|
+
out(` branch: ${branch}`);
|
|
132
|
+
if (dirty) {
|
|
133
|
+
const lines2 = dirty.split("\n").slice(0, 10);
|
|
134
|
+
out(
|
|
135
|
+
` changes: ${lines2.length} file(s)${dirty.split("\n").length > 10 ? " (showing first 10)" : ""}`,
|
|
136
|
+
);
|
|
137
|
+
for (const l of lines2) out(` ${l}`);
|
|
138
|
+
} else {
|
|
139
|
+
out(" changes: (clean)");
|
|
140
|
+
}
|
|
141
|
+
out("");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 5. Recent commits
|
|
145
|
+
const log = git("log --oneline -5");
|
|
146
|
+
if (log) {
|
|
147
|
+
out("## Recent commits");
|
|
148
|
+
for (const l of log.split("\n")) out(` ${l}`);
|
|
149
|
+
out("");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 6. Hook health
|
|
153
|
+
const hookLog = tailJSONL(
|
|
154
|
+
path.join(agileflowDir, "logs/hook-execution.jsonl"),
|
|
155
|
+
3,
|
|
156
|
+
);
|
|
157
|
+
if (hookLog.length) {
|
|
158
|
+
out("## Recent hook runs");
|
|
159
|
+
for (const e of hookLog) {
|
|
160
|
+
const status = e.status === "ok" ? "OK" : e.status.toUpperCase();
|
|
161
|
+
out(
|
|
162
|
+
` ${e.timestamp || "?"} [${status}] ${e.event}/${e.hookId} (${e.durationMs || 0}ms)`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
out("");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
169
|
+
process.exit(0);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Core hook: damage-control-bash (PreToolUse, matcher: Bash).
|
|
4
|
+
*
|
|
5
|
+
* Reads damage-control-patterns.yaml and rejects Bash commands that
|
|
6
|
+
* match an `error`-severity pattern of kind `bash`. `warn`-severity
|
|
7
|
+
* patterns log but do not block.
|
|
8
|
+
*
|
|
9
|
+
* Stdin payload (Claude Code-shaped):
|
|
10
|
+
* { tool_name: "Bash", tool_input: { command: "...", description: "..." } }
|
|
11
|
+
*
|
|
12
|
+
* Exit codes:
|
|
13
|
+
* 0 allow (default)
|
|
14
|
+
* 2 block (Claude Code surfaces stderr to the user)
|
|
15
|
+
*/
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const yaml = require("js-yaml");
|
|
19
|
+
|
|
20
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
21
|
+
const patternsPath = path.join(
|
|
22
|
+
projectDir,
|
|
23
|
+
".agileflow/plugins/core/hooks/damage-control-patterns.yaml",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
async function readStdin() {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
29
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
const raw = await readStdin();
|
|
34
|
+
let payload;
|
|
35
|
+
try {
|
|
36
|
+
payload = JSON.parse(raw);
|
|
37
|
+
} catch {
|
|
38
|
+
process.exit(0); // No payload, nothing to gate.
|
|
39
|
+
}
|
|
40
|
+
const command =
|
|
41
|
+
payload &&
|
|
42
|
+
payload.tool_input &&
|
|
43
|
+
typeof payload.tool_input.command === "string"
|
|
44
|
+
? payload.tool_input.command
|
|
45
|
+
: "";
|
|
46
|
+
if (!command) process.exit(0);
|
|
47
|
+
|
|
48
|
+
let patterns = [];
|
|
49
|
+
try {
|
|
50
|
+
const parsed = yaml.load(fs.readFileSync(patternsPath, "utf8"));
|
|
51
|
+
patterns = Array.isArray(parsed && parsed.patterns) ? parsed.patterns : [];
|
|
52
|
+
} catch {
|
|
53
|
+
process.exit(0); // No patterns file, fail open.
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const p of patterns) {
|
|
57
|
+
if (p.kind !== "bash") continue;
|
|
58
|
+
let re;
|
|
59
|
+
try {
|
|
60
|
+
re = new RegExp(p.regex, "i");
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (re.test(command)) {
|
|
65
|
+
if (p.severity === "error") {
|
|
66
|
+
process.stderr.write(
|
|
67
|
+
`agileflow damage-control: BLOCKED — ${p.reason}\n pattern: ${p.regex}\n command: ${command.slice(0, 200)}\n`,
|
|
68
|
+
);
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
// severity: warn — log, do not block
|
|
72
|
+
process.stderr.write(`agileflow damage-control: WARN — ${p.reason}\n`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Core hook: damage-control-edit (PreToolUse, matcher: Edit).
|
|
4
|
+
*
|
|
5
|
+
* Reads damage-control-patterns.yaml and rejects Edit operations whose
|
|
6
|
+
* file_path matches an `error`-severity pattern of kind `edit`.
|
|
7
|
+
*
|
|
8
|
+
* Stdin payload:
|
|
9
|
+
* { tool_name: "Edit", tool_input: { file_path: "...", old_string, new_string } }
|
|
10
|
+
*
|
|
11
|
+
* Exit codes: 0 allow, 2 block.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const yaml = require("js-yaml");
|
|
16
|
+
|
|
17
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
18
|
+
const patternsPath = path.join(
|
|
19
|
+
projectDir,
|
|
20
|
+
".agileflow/plugins/core/hooks/damage-control-patterns.yaml",
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
async function readStdin() {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
26
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
const raw = await readStdin();
|
|
31
|
+
let payload;
|
|
32
|
+
try {
|
|
33
|
+
payload = JSON.parse(raw);
|
|
34
|
+
} catch {
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
const filePath =
|
|
38
|
+
payload &&
|
|
39
|
+
payload.tool_input &&
|
|
40
|
+
typeof payload.tool_input.file_path === "string"
|
|
41
|
+
? payload.tool_input.file_path
|
|
42
|
+
: "";
|
|
43
|
+
if (!filePath) process.exit(0);
|
|
44
|
+
|
|
45
|
+
let patterns = [];
|
|
46
|
+
try {
|
|
47
|
+
const parsed = yaml.load(fs.readFileSync(patternsPath, "utf8"));
|
|
48
|
+
patterns = Array.isArray(parsed && parsed.patterns) ? parsed.patterns : [];
|
|
49
|
+
} catch {
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const p of patterns) {
|
|
54
|
+
if (p.kind !== "edit") continue;
|
|
55
|
+
let re;
|
|
56
|
+
try {
|
|
57
|
+
re = new RegExp(p.regex, "i");
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (re.test(filePath)) {
|
|
62
|
+
if (p.severity === "error") {
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
`agileflow damage-control: BLOCKED edit — ${p.reason}\n path: ${filePath}\n`,
|
|
65
|
+
);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
process.stderr.write(
|
|
69
|
+
`agileflow damage-control: WARN — ${p.reason} (path: ${filePath})\n`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Damage control pattern library.
|
|
2
|
+
#
|
|
3
|
+
# The pre-Bash / pre-Edit / pre-Write hooks load this file and reject
|
|
4
|
+
# tool invocations whose payload matches any pattern below. Each entry
|
|
5
|
+
# explains WHY it's blocked so the error message can cite the rule.
|
|
6
|
+
#
|
|
7
|
+
# Pattern syntax:
|
|
8
|
+
# - `regex`: JS RegExp source (used with `new RegExp(pattern, 'i')`)
|
|
9
|
+
# - `kind`: one of bash | edit | write — which hook applies the rule
|
|
10
|
+
# - `severity`: error (block, exit 2) | warn (allow but log warning)
|
|
11
|
+
# - `reason`: human-readable rationale shown in the block message
|
|
12
|
+
#
|
|
13
|
+
# Adding a pattern: keep it surgical. False positives are worse than
|
|
14
|
+
# missed catches because they train users to bypass the guard.
|
|
15
|
+
|
|
16
|
+
patterns:
|
|
17
|
+
# --- Catastrophic filesystem operations ---
|
|
18
|
+
- regex: 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\s+(/|~|\$HOME)\b'
|
|
19
|
+
kind: bash
|
|
20
|
+
severity: error
|
|
21
|
+
reason: rm -rf on / or $HOME is almost certainly a mistake — refuse outright
|
|
22
|
+
|
|
23
|
+
- regex: 'rm\s+-[a-zA-Z]*[rfRF][a-zA-Z]*\s+/\*'
|
|
24
|
+
kind: bash
|
|
25
|
+
severity: error
|
|
26
|
+
reason: rm -rf /* deletes the entire filesystem
|
|
27
|
+
|
|
28
|
+
- regex: '\bdd\s+if=.*\s+of=/dev/(sd|nvme|hd)'
|
|
29
|
+
kind: bash
|
|
30
|
+
severity: error
|
|
31
|
+
reason: dd to a raw block device wipes the disk
|
|
32
|
+
|
|
33
|
+
- regex: '\bmkfs\.\w+\s+/dev/'
|
|
34
|
+
kind: bash
|
|
35
|
+
severity: error
|
|
36
|
+
reason: mkfs on a device formats it — destroys all data
|
|
37
|
+
|
|
38
|
+
- regex: '>\s*/dev/(sd|nvme|hd)\w+'
|
|
39
|
+
kind: bash
|
|
40
|
+
severity: error
|
|
41
|
+
reason: writing directly to a block device corrupts the disk
|
|
42
|
+
|
|
43
|
+
- regex: ':\(\)\{\s*:\|:\&\s*\};:'
|
|
44
|
+
kind: bash
|
|
45
|
+
severity: error
|
|
46
|
+
reason: classic fork bomb
|
|
47
|
+
|
|
48
|
+
# --- Risky git operations ---
|
|
49
|
+
- regex: 'git\s+push\s+.*--force(\s|$)'
|
|
50
|
+
kind: bash
|
|
51
|
+
severity: warn
|
|
52
|
+
reason: force push can rewrite shared history — confirm intent
|
|
53
|
+
|
|
54
|
+
- regex: 'git\s+(reset|checkout)\s+.*--hard'
|
|
55
|
+
kind: bash
|
|
56
|
+
severity: warn
|
|
57
|
+
reason: --hard discards uncommitted work — confirm before running
|
|
58
|
+
|
|
59
|
+
- regex: 'git\s+clean\s+-[a-zA-Z]*[fdx]+'
|
|
60
|
+
kind: bash
|
|
61
|
+
severity: warn
|
|
62
|
+
reason: git clean -fdx removes untracked files including .gitignored — confirm
|
|
63
|
+
|
|
64
|
+
# --- Edit / Write path scope ---
|
|
65
|
+
# The edit/write hooks evaluate the file_path payload, not the command.
|
|
66
|
+
- regex: "^/etc/(passwd|shadow|sudoers)$"
|
|
67
|
+
kind: edit
|
|
68
|
+
severity: error
|
|
69
|
+
reason: editing system credential files would compromise the host
|
|
70
|
+
|
|
71
|
+
- regex: "^/etc/(passwd|shadow|sudoers)$"
|
|
72
|
+
kind: write
|
|
73
|
+
severity: error
|
|
74
|
+
reason: writing to system credential files would compromise the host
|
|
75
|
+
|
|
76
|
+
- regex: '\.ssh/(id_[a-z]+|authorized_keys|known_hosts)$'
|
|
77
|
+
kind: edit
|
|
78
|
+
severity: warn
|
|
79
|
+
reason: editing SSH credentials — confirm this is intentional
|
|
80
|
+
|
|
81
|
+
- regex: '\.ssh/(id_[a-z]+|authorized_keys|known_hosts)$'
|
|
82
|
+
kind: write
|
|
83
|
+
severity: warn
|
|
84
|
+
reason: writing to SSH credentials — confirm this is intentional
|
|
85
|
+
|
|
86
|
+
- regex: '\.env(\.|$)'
|
|
87
|
+
kind: write
|
|
88
|
+
severity: warn
|
|
89
|
+
reason: writing to a .env file — confirm it does not commit secrets
|
|
90
|
+
|
|
91
|
+
# --- npm / pnpm risk ---
|
|
92
|
+
- regex: 'npm\s+publish(\s|$)'
|
|
93
|
+
kind: bash
|
|
94
|
+
severity: warn
|
|
95
|
+
reason: npm publish ships to a public registry — confirm version + auth
|
|
96
|
+
|
|
97
|
+
- regex: 'rm\s+-rf?\s+(node_modules|\.git)\b'
|
|
98
|
+
kind: bash
|
|
99
|
+
severity: warn
|
|
100
|
+
reason: nuking node_modules / .git is recoverable but slow — confirm
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Core hook: damage-control-write (PreToolUse, matcher: Write).
|
|
4
|
+
*
|
|
5
|
+
* Mirror of damage-control-edit.js for the Write tool. Same patterns
|
|
6
|
+
* file (kind: write entries). Blocks writing system credential files,
|
|
7
|
+
* warns on writes to .ssh/ and .env*.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const yaml = require("js-yaml");
|
|
12
|
+
|
|
13
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
14
|
+
const patternsPath = path.join(
|
|
15
|
+
projectDir,
|
|
16
|
+
".agileflow/plugins/core/hooks/damage-control-patterns.yaml",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
async function readStdin() {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
22
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
const raw = await readStdin();
|
|
27
|
+
let payload;
|
|
28
|
+
try {
|
|
29
|
+
payload = JSON.parse(raw);
|
|
30
|
+
} catch {
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
const filePath =
|
|
34
|
+
payload &&
|
|
35
|
+
payload.tool_input &&
|
|
36
|
+
typeof payload.tool_input.file_path === "string"
|
|
37
|
+
? payload.tool_input.file_path
|
|
38
|
+
: "";
|
|
39
|
+
if (!filePath) process.exit(0);
|
|
40
|
+
|
|
41
|
+
let patterns = [];
|
|
42
|
+
try {
|
|
43
|
+
const parsed = yaml.load(fs.readFileSync(patternsPath, "utf8"));
|
|
44
|
+
patterns = Array.isArray(parsed && parsed.patterns) ? parsed.patterns : [];
|
|
45
|
+
} catch {
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const p of patterns) {
|
|
50
|
+
if (p.kind !== "write") continue;
|
|
51
|
+
let re;
|
|
52
|
+
try {
|
|
53
|
+
re = new RegExp(p.regex, "i");
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (re.test(filePath)) {
|
|
58
|
+
if (p.severity === "error") {
|
|
59
|
+
process.stderr.write(
|
|
60
|
+
`agileflow damage-control: BLOCKED write — ${p.reason}\n path: ${filePath}\n`,
|
|
61
|
+
);
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
process.stderr.write(
|
|
65
|
+
`agileflow damage-control: WARN — ${p.reason} (path: ${filePath})\n`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Core hook: pre-compact-state (PreCompact).
|
|
4
|
+
*
|
|
5
|
+
* Captures the state Claude needs to know AFTER the compaction summary
|
|
6
|
+
* replaces most of the conversation: the active story, the active
|
|
7
|
+
* command, recent decisions, and the dirty git state. Without this,
|
|
8
|
+
* compaction often loses thread on multi-turn implementation work.
|
|
9
|
+
*
|
|
10
|
+
* Output is plain markdown printed to stdout — Claude Code includes
|
|
11
|
+
* the hook's stdout in the prompt context after compaction.
|
|
12
|
+
*
|
|
13
|
+
* Exits 0 always (must not block compaction).
|
|
14
|
+
*/
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
const { execSync } = require("child_process");
|
|
18
|
+
|
|
19
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
20
|
+
|
|
21
|
+
function readJSON(p) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function git(args) {
|
|
30
|
+
try {
|
|
31
|
+
return execSync(`git ${args}`, {
|
|
32
|
+
cwd: projectDir,
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
35
|
+
}).trim();
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const out = [];
|
|
42
|
+
out.push("## Pre-compaction state preservation");
|
|
43
|
+
out.push("");
|
|
44
|
+
|
|
45
|
+
const status = readJSON(path.join(projectDir, "docs/09-agents/status.json"));
|
|
46
|
+
if (status && status.stories) {
|
|
47
|
+
const inProgress = Object.entries(status.stories)
|
|
48
|
+
.filter(([, s]) => s && s.status === "in_progress")
|
|
49
|
+
.map(([id, s]) => `${id} ${s.title || ""}`);
|
|
50
|
+
if (inProgress.length) {
|
|
51
|
+
out.push("Active stories:");
|
|
52
|
+
for (const s of inProgress) out.push(` - ${s}`);
|
|
53
|
+
out.push("");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Active command from session-state if it exists
|
|
58
|
+
const sessionState = readJSON(
|
|
59
|
+
path.join(projectDir, "docs/09-agents/session-state.json"),
|
|
60
|
+
);
|
|
61
|
+
if (sessionState && sessionState.active_command) {
|
|
62
|
+
out.push(
|
|
63
|
+
`Active command: ${sessionState.active_command.name || "(unknown)"}`,
|
|
64
|
+
);
|
|
65
|
+
out.push("");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const branch = git("rev-parse --abbrev-ref HEAD");
|
|
69
|
+
const dirty = git("status --short");
|
|
70
|
+
if (branch || dirty) {
|
|
71
|
+
out.push("Git:");
|
|
72
|
+
if (branch) out.push(` branch: ${branch}`);
|
|
73
|
+
if (dirty) {
|
|
74
|
+
const lines = dirty.split("\n").slice(0, 8);
|
|
75
|
+
out.push(` dirty: ${lines.length} file(s)`);
|
|
76
|
+
for (const l of lines) out.push(` ${l}`);
|
|
77
|
+
}
|
|
78
|
+
out.push("");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const log = git("log --oneline -3");
|
|
82
|
+
if (log) {
|
|
83
|
+
out.push("Recent commits:");
|
|
84
|
+
for (const l of log.split("\n")) out.push(` ${l}`);
|
|
85
|
+
out.push("");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
out.push("After compaction, resume from this state.");
|
|
89
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
90
|
+
process.exit(0);
|