feed-the-machine 1.2.0 → 1.3.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/install.mjs CHANGED
@@ -3,8 +3,13 @@
3
3
  /**
4
4
  * npx feed-the-machine — installs ftm skills into ~/.claude/skills/
5
5
  *
6
- * Works by finding the npm package root (where the skill files live)
7
- * and symlinking them into the Claude Code skills directory.
6
+ * Full install: skills, hooks, settings.json merge, and verification.
7
+ * Safe to re-run idempotent.
8
+ *
9
+ * Flags:
10
+ * --with-inbox Also install the inbox service
11
+ * --no-hooks Skip hooks entirely
12
+ * --skip-merge Install hook files but don't touch settings.json
8
13
  */
9
14
 
10
15
  import { existsSync, mkdirSync, readdirSync, lstatSync, readFileSync, writeFileSync, copyFileSync, symlinkSync, unlinkSync, chmodSync, cpSync } from "fs";
@@ -21,15 +26,25 @@ const SKILLS_DIR = join(HOME, ".claude", "skills");
21
26
  const STATE_DIR = join(HOME, ".claude", "ftm-state");
22
27
  const CONFIG_DIR = join(HOME, ".claude");
23
28
  const HOOKS_DIR = join(HOME, ".claude", "hooks");
29
+ const SETTINGS_FILE = join(CONFIG_DIR, "settings.json");
24
30
  const INBOX_INSTALL_DIR = join(HOME, ".claude", "ftm-inbox");
25
31
 
26
32
  const ARGS = process.argv.slice(2);
27
33
  const WITH_INBOX = ARGS.includes("--with-inbox");
34
+ const NO_HOOKS = ARGS.includes("--no-hooks");
35
+ const SKIP_MERGE = ARGS.includes("--skip-merge");
36
+
37
+ let warnCount = 0;
28
38
 
29
39
  function log(msg) {
30
40
  console.log(` ${msg}`);
31
41
  }
32
42
 
43
+ function warn(msg) {
44
+ console.log(` WARN: ${msg}`);
45
+ warnCount++;
46
+ }
47
+
33
48
  function ensureDir(dir) {
34
49
  if (!existsSync(dir)) {
35
50
  mkdirSync(dir, { recursive: true });
@@ -52,7 +67,212 @@ function safeSymlink(src, dest) {
52
67
  log(`LINK ${name}`);
53
68
  }
54
69
 
70
+ function commandExists(cmd) {
71
+ try {
72
+ execSync(`command -v ${cmd}`, { stdio: "ignore" });
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ function commandVersion(cmd, flag = "--version") {
80
+ try {
81
+ return execSync(`${cmd} ${flag}`, { encoding: "utf8" }).trim().split("\n")[0];
82
+ } catch {
83
+ return "unknown";
84
+ }
85
+ }
86
+
87
+ // --- Preflight ---
88
+
89
+ function preflight() {
90
+ console.log("Preflight checks...");
91
+
92
+ if (!NO_HOOKS) {
93
+ // jq is required for all shell hooks (they parse JSON stdin via jq)
94
+ if (!commandExists("jq")) {
95
+ console.log("");
96
+ console.log(" ERROR: jq is required for FTM hooks.");
97
+ console.log("");
98
+ console.log(" Install it:");
99
+ console.log(" macOS: brew install jq");
100
+ console.log(" Ubuntu: sudo apt-get install jq");
101
+ console.log(" Alpine: apk add jq");
102
+ console.log("");
103
+ console.log(" Or skip hooks: npx feed-the-machine --no-hooks");
104
+ process.exit(1);
105
+ }
106
+ log(`jq: ${commandVersion("jq")}`);
107
+ log(`node: ${process.version}`);
108
+ } else {
109
+ log("hooks skipped (--no-hooks)");
110
+ }
111
+
112
+ console.log("");
113
+ }
114
+
115
+ // --- Settings Merge ---
116
+
117
+ function mergeHooksIntoSettings() {
118
+ const templatePath = join(REPO_DIR, "hooks", "settings-template.json");
119
+ if (!existsSync(templatePath)) {
120
+ warn("hooks/settings-template.json not found — hooks installed but not registered");
121
+ return;
122
+ }
123
+
124
+ console.log("");
125
+ console.log("Registering hooks in settings.json...");
126
+
127
+ // Read and expand ~ to actual home directory
128
+ const rawTemplate = readFileSync(templatePath, "utf8");
129
+ const expandedTemplate = rawTemplate.replace(/~\/.claude/g, join(HOME, ".claude"));
130
+ const template = JSON.parse(expandedTemplate);
131
+ const templateHooks = template.hooks || {};
132
+
133
+ if (!existsSync(SETTINGS_FILE)) {
134
+ // No settings.json — create one with just the hooks
135
+ writeFileSync(SETTINGS_FILE, JSON.stringify({ hooks: templateHooks }, null, 2) + "\n");
136
+ log("CREATED settings.json with FTM hooks");
137
+ return;
138
+ }
139
+
140
+ // Read existing settings
141
+ const existing = JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
142
+
143
+ // Backup
144
+ const ts = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
145
+ const backupPath = `${SETTINGS_FILE}.ftm-backup-${ts}`;
146
+ copyFileSync(SETTINGS_FILE, backupPath);
147
+ log(`BACKUP ${backupPath}`);
148
+
149
+ // Ensure hooks key exists
150
+ if (!existing.hooks) {
151
+ existing.hooks = {};
152
+ }
153
+
154
+ // Merge each event type
155
+ const events = ["PreToolUse", "UserPromptSubmit", "PostToolUse", "Stop"];
156
+ for (const event of events) {
157
+ const templateEntries = templateHooks[event] || [];
158
+ const existingEntries = existing.hooks[event] || [];
159
+
160
+ if (templateEntries.length === 0) continue;
161
+
162
+ // Check if FTM hooks are already present by looking for ftm- in command paths
163
+ const existingCommands = JSON.stringify(existingEntries);
164
+ const alreadyPresent = templateEntries.some((entry) => {
165
+ const hooks = entry.hooks || [];
166
+ return hooks.some((h) => {
167
+ const cmd = h.command || "";
168
+ const cmdBase = basename(cmd.split(" ").pop()); // handle "node foo.mjs"
169
+ return existingCommands.includes(cmdBase);
170
+ });
171
+ });
172
+
173
+ if (alreadyPresent) {
174
+ log(`SKIP ${event} hooks (already configured)`);
175
+ continue;
176
+ }
177
+
178
+ existing.hooks[event] = [...existingEntries, ...templateEntries];
179
+ log(`MERGE ${event} hooks`);
180
+ }
181
+
182
+ writeFileSync(SETTINGS_FILE, JSON.stringify(existing, null, 2) + "\n");
183
+ log("UPDATED settings.json");
184
+ console.log("");
185
+ log("Hooks are active.");
186
+ }
187
+
188
+ // --- Verification ---
189
+
190
+ function verify(skillCount, hookCount) {
191
+ console.log("");
192
+ console.log("Verifying installation...");
193
+
194
+ let errors = 0;
195
+
196
+ // Check skill symlinks resolve
197
+ let brokenLinks = 0;
198
+ const skillEntries = readdirSync(SKILLS_DIR).filter((f) => f.startsWith("ftm"));
199
+ for (const entry of skillEntries) {
200
+ const fullPath = join(SKILLS_DIR, entry);
201
+ try {
202
+ if (lstatSync(fullPath).isSymbolicLink() && !existsSync(fullPath)) {
203
+ warn(`broken symlink: ${entry}`);
204
+ brokenLinks++;
205
+ }
206
+ } catch {
207
+ // ignore
208
+ }
209
+ }
210
+ if (brokenLinks === 0) {
211
+ log(`Skills: ${skillCount} linked, all symlinks valid`);
212
+ } else {
213
+ errors++;
214
+ }
215
+
216
+ // Check blackboard state
217
+ const contextFile = join(STATE_DIR, "blackboard", "context.json");
218
+ const patternsFile = join(STATE_DIR, "blackboard", "patterns.json");
219
+ if (existsSync(contextFile) && existsSync(patternsFile)) {
220
+ log("Blackboard: initialized");
221
+ } else {
222
+ warn("blackboard state incomplete");
223
+ errors++;
224
+ }
225
+
226
+ // Check config
227
+ if (existsSync(join(CONFIG_DIR, "ftm-config.yml"))) {
228
+ log("Config: present");
229
+ } else {
230
+ warn("ftm-config.yml missing");
231
+ errors++;
232
+ }
233
+
234
+ // Check hooks
235
+ if (!NO_HOOKS && hookCount > 0) {
236
+ const hookFiles = readdirSync(HOOKS_DIR).filter((f) => f.startsWith("ftm-"));
237
+ const allExecutable = hookFiles
238
+ .filter((f) => f.endsWith(".sh"))
239
+ .every((f) => {
240
+ try {
241
+ const stat = lstatSync(join(HOOKS_DIR, f));
242
+ return (stat.mode & 0o111) !== 0;
243
+ } catch {
244
+ return false;
245
+ }
246
+ });
247
+
248
+ if (allExecutable) {
249
+ log(`Hooks: ${hookCount} installed, all executable`);
250
+ } else {
251
+ warn("some hook files not executable");
252
+ errors++;
253
+ }
254
+
255
+ // Verify settings.json has FTM hooks
256
+ if (!SKIP_MERGE && existsSync(SETTINGS_FILE)) {
257
+ const settingsContent = readFileSync(SETTINGS_FILE, "utf8");
258
+ const ftmMatches = (settingsContent.match(/ftm-/g) || []).length;
259
+ if (ftmMatches > 0) {
260
+ log(`Settings: ${ftmMatches} FTM entries in settings.json`);
261
+ } else {
262
+ warn("no FTM hooks found in settings.json");
263
+ errors++;
264
+ }
265
+ }
266
+ }
267
+
268
+ return { errors };
269
+ }
270
+
271
+ // --- Main ---
272
+
55
273
  function main() {
274
+ preflight();
275
+
56
276
  console.log(`Installing ftm skills from: ${REPO_DIR}`);
57
277
  console.log(`Linking into: ${SKILLS_DIR}`);
58
278
  console.log("");
@@ -82,9 +302,13 @@ function main() {
82
302
  safeSymlink(join(REPO_DIR, dir), join(SKILLS_DIR, dir));
83
303
  }
84
304
 
305
+ console.log("");
306
+ log(`${ymlFiles.length} skills linked.`);
307
+
85
308
  // Set up blackboard state (copy templates, don't overwrite existing data)
86
309
  const bbDir = join(REPO_DIR, "ftm-state", "blackboard");
87
310
  if (existsSync(bbDir)) {
311
+ console.log("");
88
312
  ensureDir(join(STATE_DIR, "blackboard", "experiences"));
89
313
 
90
314
  const jsonFiles = readdirSync(bbDir).filter((f) => f.endsWith(".json"));
@@ -113,39 +337,62 @@ function main() {
113
337
  }
114
338
 
115
339
  // Install hooks
116
- const hooksDir = join(REPO_DIR, "hooks");
117
340
  let hookCount = 0;
118
- if (existsSync(hooksDir)) {
119
- ensureDir(HOOKS_DIR);
341
+
342
+ if (NO_HOOKS) {
120
343
  console.log("");
121
- console.log("Installing hooks...");
122
-
123
- const hookFiles = readdirSync(hooksDir).filter(
124
- (f) => f.startsWith("ftm-") && (f.endsWith(".sh") || f.endsWith(".mjs"))
125
- );
126
- for (const hook of hookFiles) {
127
- const src = join(hooksDir, hook);
128
- const dest = join(HOOKS_DIR, hook);
129
- const action = existsSync(dest) ? "UPDATE" : "INSTALL";
130
- copyFileSync(src, dest);
131
- if (hook.endsWith(".sh")) {
132
- chmodSync(dest, 0o755);
344
+ console.log("Skipping hooks (--no-hooks).");
345
+ } else {
346
+ const hooksDir = join(REPO_DIR, "hooks");
347
+ if (existsSync(hooksDir)) {
348
+ ensureDir(HOOKS_DIR);
349
+ console.log("");
350
+ console.log("Installing hooks...");
351
+
352
+ const hookFiles = readdirSync(hooksDir).filter(
353
+ (f) => f.startsWith("ftm-") && (f.endsWith(".sh") || f.endsWith(".mjs"))
354
+ );
355
+ for (const hook of hookFiles) {
356
+ const src = join(hooksDir, hook);
357
+ const dest = join(HOOKS_DIR, hook);
358
+ const action = existsSync(dest) ? "UPDATE" : "INSTALL";
359
+ copyFileSync(src, dest);
360
+ if (hook.endsWith(".sh")) {
361
+ chmodSync(dest, 0o755);
362
+ }
363
+ log(`${action} ${hook}`);
364
+ hookCount++;
133
365
  }
134
- log(`${action} ${hook}`);
135
- hookCount++;
366
+
367
+ console.log("");
368
+ log(`${hookCount} hooks installed to ${HOOKS_DIR}`);
369
+ }
370
+
371
+ // Merge hooks into settings.json
372
+ if (SKIP_MERGE) {
373
+ console.log("");
374
+ log("Skipping settings.json merge (--skip-merge).");
375
+ log("Add entries from hooks/settings-template.json to ~/.claude/settings.json manually.");
376
+ } else {
377
+ mergeHooksIntoSettings();
136
378
  }
137
379
  }
138
380
 
381
+ // Verification
382
+ const { errors } = verify(ymlFiles.length, hookCount);
383
+
384
+ // Summary
139
385
  console.log("");
140
- console.log(`Done. ${ymlFiles.length} skills linked, ${hookCount} hooks installed.`);
141
- console.log("");
142
- console.log("To activate hooks, add them to ~/.claude/settings.json");
143
- console.log(" Option A: ./install.sh --setup-hooks (auto-merge)");
144
- console.log(" Option B: Copy entries from hooks/settings-template.json manually");
145
- console.log(" See docs/HOOKS.md for details.");
386
+ if (errors === 0 && warnCount === 0) {
387
+ console.log(`Done. ${ymlFiles.length} skills, ${hookCount} hooks. Everything checks out.`);
388
+ } else {
389
+ console.log(`Done. ${ymlFiles.length} skills, ${hookCount} hooks. ${warnCount} warning(s).`);
390
+ }
146
391
  console.log("");
392
+ console.log("Restart Claude Code (or start a new session) to pick up the skills.");
147
393
 
148
394
  if (WITH_INBOX) {
395
+ console.log("");
149
396
  installInbox();
150
397
  } else {
151
398
  console.log("Try: /ftm help");
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  # ftm-blackboard-enforcer.sh
3
- # Stop hook that checks if meaningful work was done but no blackboard
4
- # experience was recorded. If so, blocks the stop and tells Claude
5
- # to write the experience first.
3
+ # Stop hook that nudges Claude to record an experience if meaningful work
4
+ # was done but no blackboard entry was written.
6
5
  #
7
- # "Meaningful work" = 3+ tool uses detected by the edit counter,
6
+ # Uses additionalContext (not "decision: block") so Claude can still act on
7
+ # the reminder. A blocking stop creates a deadlock — Claude can't write files
8
+ # after the user ends the conversation.
9
+ #
10
+ # "Meaningful work" = 3+ edits tracked by the edit counter,
8
11
  # or ftm skills were invoked (checked via context.json).
9
12
  #
10
13
  # Hook: Stop
@@ -13,29 +16,23 @@ set -euo pipefail
13
16
 
14
17
  INPUT=$(cat)
15
18
 
16
- # Prevent infinite loop — if this hook already fired, let Claude stop
17
- STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
18
- if [[ "$STOP_HOOK_ACTIVE" == "true" ]]; then
19
- exit 0
20
- fi
21
-
22
19
  STATE_DIR="$HOME/.claude/ftm-state"
23
20
  BB_DIR="$STATE_DIR/blackboard"
24
21
  EDIT_COUNTER="$STATE_DIR/.edit-count"
25
22
  CONTEXT_FILE="$BB_DIR/context.json"
26
23
  EXPERIENCES_DIR="$BB_DIR/experiences"
27
- EXPERIENCE_INDEX="$EXPERIENCES_DIR/index.json"
28
-
29
- CURRENT_SESSION="${CLAUDE_SESSION_ID:-unknown}"
30
24
 
31
25
  # Check 1: Were there meaningful edits this session?
26
+ # Edit counter contains just a number now (no session ID).
27
+ # If the counter file is recent (< 4 hours) and >= 3, count as meaningful.
32
28
  HAD_EDITS=false
33
29
  if [[ -f "$EDIT_COUNTER" ]]; then
34
- STORED=$(cat "$EDIT_COUNTER" 2>/dev/null || echo "0:unknown")
35
- STORED_SESSION=$(echo "$STORED" | cut -d: -f2)
36
- STORED_COUNT=$(echo "$STORED" | cut -d: -f1)
37
- if [[ "$STORED_SESSION" == "$CURRENT_SESSION" && "$STORED_COUNT" -ge 3 ]]; then
38
- HAD_EDITS=true
30
+ COUNTER_AGE=$(( $(date +%s) - $(stat -c %Y "$EDIT_COUNTER" 2>/dev/null || stat -f %m "$EDIT_COUNTER" 2>/dev/null || echo "0") ))
31
+ if [[ "$COUNTER_AGE" -lt 14400 ]]; then
32
+ STORED_COUNT=$(cat "$EDIT_COUNTER" 2>/dev/null || echo "0")
33
+ if [[ "$STORED_COUNT" -ge 3 ]]; then
34
+ HAD_EDITS=true
35
+ fi
39
36
  fi
40
37
  fi
41
38
 
@@ -48,8 +45,10 @@ if [[ -f "$CONTEXT_FILE" ]]; then
48
45
  fi
49
46
  fi
50
47
 
51
- # If no meaningful work detected, allow stop
48
+ # If no meaningful work detected, allow stop quietly
52
49
  if [[ "$HAD_EDITS" == "false" && "$HAD_SKILLS" == "false" ]]; then
50
+ # Clean up session markers
51
+ rm -f "$EDIT_COUNTER" "$STATE_DIR/.plan-presented" 2>/dev/null
53
52
  exit 0
54
53
  fi
55
54
 
@@ -58,19 +57,17 @@ TODAY=$(date +%Y-%m-%d)
58
57
  HAS_EXPERIENCE=false
59
58
 
60
59
  if [[ -d "$EXPERIENCES_DIR" ]]; then
61
- # Check for experience files created today
62
60
  TODAY_EXPERIENCE=$(find "$EXPERIENCES_DIR" -name "${TODAY}*" -type f 2>/dev/null | head -1)
63
61
  if [[ -n "$TODAY_EXPERIENCE" ]]; then
64
62
  HAS_EXPERIENCE=true
65
63
  fi
66
64
  fi
67
65
 
68
- # Also check if context.json was updated this session (recent_decisions not empty)
66
+ # Also check if context.json was updated today (recent_decisions not empty)
69
67
  if [[ -f "$CONTEXT_FILE" ]]; then
70
68
  DECISIONS_COUNT=$(jq -r '.recent_decisions | length' "$CONTEXT_FILE" 2>/dev/null || echo "0")
71
69
  LAST_UPDATED=$(jq -r '.session_metadata.last_updated // ""' "$CONTEXT_FILE" 2>/dev/null || echo "")
72
70
  if [[ "$DECISIONS_COUNT" -gt 0 && -n "$LAST_UPDATED" ]]; then
73
- # Check if last_updated is from today
74
71
  if [[ "$LAST_UPDATED" == *"$TODAY"* ]]; then
75
72
  HAS_EXPERIENCE=true
76
73
  fi
@@ -78,17 +75,21 @@ if [[ -f "$CONTEXT_FILE" ]]; then
78
75
  fi
79
76
 
80
77
  if [[ "$HAS_EXPERIENCE" == "true" ]]; then
81
- # Blackboard was written, allow stop
82
- # Clean up session markers
78
+ # Blackboard was written, clean up and allow stop
83
79
  rm -f "$EDIT_COUNTER" "$STATE_DIR/.plan-presented" 2>/dev/null
84
80
  exit 0
85
81
  fi
86
82
 
87
- # Work was done but no blackboard write — block the stop
83
+ # Work was done but no blackboard write — nudge (don't block)
88
84
  cat <<'JSON'
89
85
  {
90
- "decision": "block",
91
- "reason": "[ftm-blackboard-enforcer] You did meaningful work this session (3+ edits or ftm skills used) but did not record an experience to the blackboard. Before stopping, you MUST: (1) Update ~/.claude/ftm-state/blackboard/context.json with current_task status and recent_decisions. (2) Write an experience file to ~/.claude/ftm-state/blackboard/experiences/ with task_type, tags, outcome, lessons, files_touched, stakeholders, and decisions_made. (3) Update ~/.claude/ftm-state/blackboard/experiences/index.json with the new entry. This is how ftm learns — skipping it means the next session starts from zero."
86
+ "hookSpecificOutput": {
87
+ "hookEventName": "Stop",
88
+ "additionalContext": "[ftm-blackboard-enforcer] You did meaningful work this session but did not record an experience to the blackboard. Before finishing, please: (1) Update ~/.claude/ftm-state/blackboard/context.json with current_task status and recent_decisions. (2) Write an experience file to ~/.claude/ftm-state/blackboard/experiences/ with task_type, tags, outcome, and lessons. (3) Update ~/.claude/ftm-state/blackboard/experiences/index.json with the new entry. This is how ftm learns — skipping it means the next session starts from zero."
89
+ }
92
90
  }
93
91
  JSON
92
+
93
+ # Clean up session markers regardless — don't let stale state carry over
94
+ rm -f "$EDIT_COUNTER" "$STATE_DIR/.plan-presented" 2>/dev/null
94
95
  exit 0
@@ -2,14 +2,13 @@
2
2
  # ftm-plan-gate.sh
3
3
  # PreToolUse hook for Edit/Write tools.
4
4
  #
5
- # Checks if a plan has been presented and approved for this session before
6
- # allowing code edits. If no plan marker exists and the session involves
7
- # a medium+ task (detected by ftm-state), injects additionalContext
5
+ # Checks if a plan has been presented this session before allowing code edits.
6
+ # If no plan marker exists and the edit count is climbing, injects warnings
8
7
  # telling Claude to stop and present a plan first.
9
8
  #
10
- # The marker file is created by Claude when it presents a plan — we check
11
- # for it here. If the marker doesn't exist but edits are happening, it
12
- # means Claude skipped the planning step.
9
+ # The marker file (~/.claude/ftm-state/.plan-presented) is created by Claude
10
+ # when it presents a plan. Any non-empty content counts as "plan presented".
11
+ # The file is cleaned up by the blackboard enforcer at session end.
13
12
  #
14
13
  # Hook: PreToolUse (matcher: Edit|Write)
15
14
 
@@ -25,9 +24,7 @@ fi
25
24
 
26
25
  STATE_DIR="$HOME/.claude/ftm-state"
27
26
  PLAN_MARKER="$STATE_DIR/.plan-presented"
28
- SESSION_MARKER="$STATE_DIR/.session-id"
29
27
  EDIT_COUNTER="$STATE_DIR/.edit-count"
30
- SKILL_FILES_DIR="$HOME/.claude/skills"
31
28
 
32
29
  # Get the file being edited
33
30
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
@@ -47,37 +44,36 @@ if [[ "$FILE_PATH" == *".claude/skills/"* ]] || \
47
44
  exit 0
48
45
  fi
49
46
 
50
- # If plan marker exists and matches current session, allow
51
- CURRENT_SESSION="${CLAUDE_SESSION_ID:-unknown}"
52
- if [[ -f "$PLAN_MARKER" ]]; then
53
- MARKER_SESSION=$(cat "$PLAN_MARKER" 2>/dev/null || echo "")
54
- if [[ "$MARKER_SESSION" == "$CURRENT_SESSION" ]]; then
55
- exit 0 # Plan was presented this session, allow edits
47
+ # If plan marker exists (any content), allow edits
48
+ if [[ -f "$PLAN_MARKER" ]] && [[ -s "$PLAN_MARKER" ]]; then
49
+ exit 0
50
+ fi
51
+
52
+ # Reset edit counter if it's stale (older than 4 hours = likely a new session)
53
+ if [[ -f "$EDIT_COUNTER" ]]; then
54
+ COUNTER_AGE=$(( $(date +%s) - $(stat -c %Y "$EDIT_COUNTER" 2>/dev/null || echo "0") ))
55
+ if [[ "$COUNTER_AGE" -gt 14400 ]]; then
56
+ rm -f "$EDIT_COUNTER"
56
57
  fi
57
58
  fi
58
59
 
59
- # Count edits this session (without a plan marker)
60
+ # Count edits without a plan marker
60
61
  EDIT_COUNT=0
61
62
  if [[ -f "$EDIT_COUNTER" ]]; then
62
- STORED=$(cat "$EDIT_COUNTER" 2>/dev/null || echo "0:unknown")
63
- STORED_SESSION=$(echo "$STORED" | cut -d: -f2)
64
- if [[ "$STORED_SESSION" == "$CURRENT_SESSION" ]]; then
65
- EDIT_COUNT=$(echo "$STORED" | cut -d: -f1)
66
- fi
63
+ EDIT_COUNT=$(cat "$EDIT_COUNTER" 2>/dev/null || echo "0")
67
64
  fi
68
65
 
69
66
  EDIT_COUNT=$((EDIT_COUNT + 1))
70
- echo "${EDIT_COUNT}:${CURRENT_SESSION}" > "$EDIT_COUNTER"
67
+ echo "$EDIT_COUNT" > "$EDIT_COUNTER"
71
68
 
72
- # First 2 edits get a warning injected as context (don't block — could be micro tasks)
69
+ # First 2 edits get a soft reminder (don't block — could be micro tasks)
73
70
  # After 3+ edits without a plan marker, escalate the warning
74
71
  if [[ $EDIT_COUNT -le 2 ]]; then
75
- # Soft reminder — inject context but allow
76
72
  cat <<'JSON'
77
73
  {
78
74
  "hookSpecificOutput": {
79
75
  "hookEventName": "PreToolUse",
80
- "additionalContext": "[ftm-plan-gate] You are editing files without having presented a plan this session. If this task is medium+ (touches 3+ files, involves external systems, or has stakeholder coordination), you MUST present a numbered plan and get user approval BEFORE editing code. If this is a micro/small task, you can proceed — but create the plan marker by writing the current session ID to ~/.claude/ftm-state/.plan-presented after confirming the task is genuinely small. To create the marker: Write tool → ~/.claude/ftm-state/.plan-presented with content being the session ID."
76
+ "additionalContext": "[ftm-plan-gate] You are editing files without having presented a plan this session. If this task is medium+ (touches 3+ files, involves external systems, or has stakeholder coordination), you MUST present a numbered plan and get user approval BEFORE editing code. If this is a micro/small task, you can proceed — but create the plan marker: write any content to ~/.claude/ftm-state/.plan-presented to acknowledge you've considered it."
81
77
  }
82
78
  }
83
79
  JSON
@@ -89,7 +85,7 @@ cat <<'JSON'
89
85
  {
90
86
  "hookSpecificOutput": {
91
87
  "hookEventName": "PreToolUse",
92
- "additionalContext": "[ftm-plan-gate WARNING] You have made 3+ file edits this session without presenting a plan. This is exactly the 'grinding without a plan' pattern that ftm-mind is supposed to prevent. STOP editing and do one of: (1) Present a numbered plan to the user and wait for approval, then write the session ID to ~/.claude/ftm-state/.plan-presented. (2) If the user explicitly said 'just do it' or this is genuinely a micro task, write the plan marker to acknowledge you've considered it. Do NOT continue editing without addressing this."
88
+ "additionalContext": "[ftm-plan-gate WARNING] You have made 3+ file edits this session without presenting a plan. This is exactly the 'grinding without a plan' pattern that ftm-mind is supposed to prevent. STOP editing and do one of: (1) Present a numbered plan to the user and wait for approval, then write any content to ~/.claude/ftm-state/.plan-presented. (2) If the user explicitly said 'just do it' or this is genuinely a micro task, write the plan marker to acknowledge you've considered it. Do NOT continue editing without addressing this."
93
89
  }
94
90
  }
95
91
  JSON
package/install.sh CHANGED
@@ -3,11 +3,13 @@ set -euo pipefail
3
3
 
4
4
  # FTM Skills Installer
5
5
  # Creates symlinks from this repo into ~/.claude/skills/ so slash commands work.
6
- # Safe to re-run idempotent. Run after cloning or adding new skills.
6
+ # Installs hooks, merges them into settings.json, and verifies the result.
7
+ # Safe to re-run — idempotent.
7
8
  #
8
9
  # Usage:
9
- # ./install.sh # Install skills, hooks, and state templates
10
- # ./install.sh --setup-hooks # Also merge hook config into settings.json
10
+ # ./install.sh # Full install (skills + hooks + settings merge)
11
+ # ./install.sh --no-hooks # Skills and state only, skip hooks entirely
12
+ # ./install.sh --skip-merge # Install hook files but don't touch settings.json
11
13
 
12
14
  REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
13
15
  SKILLS_DIR="$HOME/.claude/skills"
@@ -16,13 +18,64 @@ CONFIG_DIR="$HOME/.claude"
16
18
  HOOKS_DIR="$HOME/.claude/hooks"
17
19
  SETTINGS_FILE="$CONFIG_DIR/settings.json"
18
20
 
19
- SETUP_HOOKS=false
21
+ NO_HOOKS=false
22
+ SKIP_MERGE=false
20
23
  for arg in "$@"; do
21
24
  case "$arg" in
22
- --setup-hooks) SETUP_HOOKS=true ;;
25
+ --no-hooks) NO_HOOKS=true ;;
26
+ --skip-merge) SKIP_MERGE=true ;;
27
+ # Keep --setup-hooks for backwards compat (now a no-op since merge is default)
28
+ --setup-hooks) ;;
23
29
  esac
24
30
  done
25
31
 
32
+ WARN_COUNT=0
33
+ warn() {
34
+ echo " WARN: $1"
35
+ WARN_COUNT=$((WARN_COUNT + 1))
36
+ }
37
+
38
+ # --- Preflight Checks ---
39
+
40
+ echo "Preflight checks..."
41
+
42
+ # Check jq (required for hooks and settings merge)
43
+ if ! command -v jq &>/dev/null; then
44
+ if [ "$NO_HOOKS" = true ]; then
45
+ echo " jq not found (ok — hooks skipped)"
46
+ else
47
+ echo ""
48
+ echo " ERROR: jq is required for FTM hooks."
49
+ echo ""
50
+ echo " Install it:"
51
+ echo " macOS: brew install jq"
52
+ echo " Ubuntu: sudo apt-get install jq"
53
+ echo " Alpine: apk add jq"
54
+ echo ""
55
+ echo " Or skip hooks: ./install.sh --no-hooks"
56
+ exit 1
57
+ fi
58
+ else
59
+ echo " jq: $(jq --version)"
60
+ fi
61
+
62
+ # Check node (required for event logger hook)
63
+ if ! command -v node &>/dev/null; then
64
+ if [ "$NO_HOOKS" = true ]; then
65
+ echo " node not found (ok — hooks skipped)"
66
+ else
67
+ echo ""
68
+ echo " ERROR: Node.js is required for the FTM event logger hook."
69
+ echo ""
70
+ echo " Install it: https://nodejs.org/"
71
+ echo " Or skip hooks: ./install.sh --no-hooks"
72
+ exit 1
73
+ fi
74
+ else
75
+ echo " node: $(node --version)"
76
+ fi
77
+
78
+ echo ""
26
79
  echo "Installing FTM skills from: $REPO_DIR"
27
80
  echo "Linking into: $SKILLS_DIR"
28
81
  echo ""
@@ -98,134 +151,208 @@ fi
98
151
 
99
152
  # --- Hooks ---
100
153
 
101
- echo ""
102
- echo "Installing hooks..."
103
-
104
- if [ -d "$REPO_DIR/hooks" ]; then
105
- mkdir -p "$HOOKS_DIR"
106
- HOOK_COUNT=0
107
-
108
- # Install shell hooks
109
- for hook in "$REPO_DIR/hooks"/ftm-*.sh; do
110
- [ -f "$hook" ] || continue
111
- name=$(basename "$hook")
112
- target="$HOOKS_DIR/$name"
113
- if [ -f "$target" ]; then
114
- cp "$hook" "$target"
115
- chmod +x "$target"
116
- echo " UPDATE $name"
117
- else
118
- cp "$hook" "$target"
119
- chmod +x "$target"
120
- echo " INSTALL $name"
121
- fi
122
- HOOK_COUNT=$((HOOK_COUNT + 1))
123
- done
124
-
125
- # Install Node.js hooks
126
- for hook in "$REPO_DIR/hooks"/ftm-*.mjs; do
127
- [ -f "$hook" ] || continue
128
- name=$(basename "$hook")
129
- target="$HOOKS_DIR/$name"
130
- if [ -f "$target" ]; then
131
- cp "$hook" "$target"
132
- echo " UPDATE $name"
133
- else
134
- cp "$hook" "$target"
135
- echo " INSTALL $name"
136
- fi
137
- HOOK_COUNT=$((HOOK_COUNT + 1))
138
- done
154
+ HOOK_COUNT=0
139
155
 
156
+ if [ "$NO_HOOKS" = true ]; then
140
157
  echo ""
141
- echo " $HOOK_COUNT hooks installed to $HOOKS_DIR"
142
- fi
143
-
144
- # --- Hook Config Merge (--setup-hooks) ---
145
-
146
- if [ "$SETUP_HOOKS" = true ]; then
158
+ echo "Skipping hooks (--no-hooks)."
159
+ else
147
160
  echo ""
148
- echo "Setting up hook configuration in settings.json..."
161
+ echo "Installing hooks..."
162
+
163
+ if [ -d "$REPO_DIR/hooks" ]; then
164
+ mkdir -p "$HOOKS_DIR"
165
+
166
+ # Install shell hooks
167
+ for hook in "$REPO_DIR/hooks"/ftm-*.sh; do
168
+ [ -f "$hook" ] || continue
169
+ name=$(basename "$hook")
170
+ target="$HOOKS_DIR/$name"
171
+ if [ -f "$target" ]; then
172
+ cp "$hook" "$target"
173
+ chmod +x "$target"
174
+ echo " UPDATE $name"
175
+ else
176
+ cp "$hook" "$target"
177
+ chmod +x "$target"
178
+ echo " INSTALL $name"
179
+ fi
180
+ HOOK_COUNT=$((HOOK_COUNT + 1))
181
+ done
149
182
 
150
- TEMPLATE="$REPO_DIR/hooks/settings-template.json"
151
- if [ ! -f "$TEMPLATE" ]; then
152
- echo " ERROR: hooks/settings-template.json not found"
153
- exit 1
154
- fi
183
+ # Install Node.js hooks
184
+ for hook in "$REPO_DIR/hooks"/ftm-*.mjs; do
185
+ [ -f "$hook" ] || continue
186
+ name=$(basename "$hook")
187
+ target="$HOOKS_DIR/$name"
188
+ if [ -f "$target" ]; then
189
+ cp "$hook" "$target"
190
+ echo " UPDATE $name"
191
+ else
192
+ cp "$hook" "$target"
193
+ echo " INSTALL $name"
194
+ fi
195
+ HOOK_COUNT=$((HOOK_COUNT + 1))
196
+ done
155
197
 
156
- if ! command -v jq &>/dev/null; then
157
- echo " ERROR: jq is required for --setup-hooks. Install with: brew install jq"
158
- exit 1
198
+ echo ""
199
+ echo " $HOOK_COUNT hooks installed to $HOOKS_DIR"
159
200
  fi
160
201
 
161
- # Expand ~ to $HOME in the template (jq doesn't expand shell paths)
162
- EXPANDED_TEMPLATE=$(sed "s|~/.claude|$HOME/.claude|g" "$TEMPLATE")
202
+ # --- Hook Config Merge (default behavior now) ---
163
203
 
164
- if [ ! -f "$SETTINGS_FILE" ]; then
165
- # No settings.json — create one from the template hooks section
166
- echo "$EXPANDED_TEMPLATE" | jq '{hooks: .hooks}' > "$SETTINGS_FILE"
167
- echo " CREATED $SETTINGS_FILE with FTM hooks"
204
+ if [ "$SKIP_MERGE" = true ]; then
205
+ echo ""
206
+ echo " Skipping settings.json merge (--skip-merge)."
207
+ echo " Add entries from hooks/settings-template.json to ~/.claude/settings.json manually."
168
208
  else
169
- # Merge FTM hooks into existing settings.json
170
- # Strategy: for each hook event type, append FTM entries that don't already exist
171
- BACKUP="$SETTINGS_FILE.ftm-backup-$(date +%Y%m%d%H%M%S)"
172
- cp "$SETTINGS_FILE" "$BACKUP"
173
- echo " BACKUP $BACKUP"
209
+ echo ""
210
+ echo "Registering hooks in settings.json..."
174
211
 
175
- # Extract the hooks section from the template
176
- TEMPLATE_HOOKS=$(echo "$EXPANDED_TEMPLATE" | jq '.hooks')
212
+ TEMPLATE="$REPO_DIR/hooks/settings-template.json"
213
+ if [ ! -f "$TEMPLATE" ]; then
214
+ warn "hooks/settings-template.json not found — hooks installed but not registered"
215
+ else
216
+ # Expand ~ to $HOME in the template (jq doesn't expand shell paths)
217
+ EXPANDED_TEMPLATE=$(sed "s|~/.claude|$HOME/.claude|g" "$TEMPLATE")
218
+
219
+ if [ ! -f "$SETTINGS_FILE" ]; then
220
+ # No settings.json — create one from the template hooks section
221
+ echo "$EXPANDED_TEMPLATE" | jq '{hooks: .hooks}' > "$SETTINGS_FILE"
222
+ echo " CREATED $SETTINGS_FILE with FTM hooks"
223
+ else
224
+ # Merge FTM hooks into existing settings.json
225
+ BACKUP="$SETTINGS_FILE.ftm-backup-$(date +%Y%m%d%H%M%S)"
226
+ cp "$SETTINGS_FILE" "$BACKUP"
227
+ echo " BACKUP $BACKUP"
228
+
229
+ # Extract the hooks section from the template
230
+ TEMPLATE_HOOKS=$(echo "$EXPANDED_TEMPLATE" | jq '.hooks')
231
+
232
+ # Read existing settings
233
+ EXISTING=$(cat "$SETTINGS_FILE")
234
+
235
+ # Ensure hooks key exists
236
+ if echo "$EXISTING" | jq -e '.hooks' >/dev/null 2>&1; then
237
+ : # hooks key exists
238
+ else
239
+ EXISTING=$(echo "$EXISTING" | jq '. + {hooks: {}}')
240
+ fi
177
241
 
178
- # Read existing settings
179
- EXISTING=$(cat "$SETTINGS_FILE")
242
+ # Merge each hook event type
243
+ for EVENT in PreToolUse UserPromptSubmit PostToolUse Stop; do
244
+ TEMPLATE_ENTRIES=$(echo "$TEMPLATE_HOOKS" | jq --arg e "$EVENT" '.[$e] // []')
245
+ EXISTING_ENTRIES=$(echo "$EXISTING" | jq --arg e "$EVENT" '.hooks[$e] // []')
246
+
247
+ # Check if any FTM hooks are already present (by checking command paths)
248
+ FTM_COMMANDS=$(echo "$TEMPLATE_ENTRIES" | jq -r '.[].hooks[]?.command // empty' 2>/dev/null)
249
+ ALREADY_PRESENT=false
250
+
251
+ for cmd in $FTM_COMMANDS; do
252
+ cmd_basename=$(basename "$cmd")
253
+ if echo "$EXISTING_ENTRIES" | jq -r '.[].hooks[]?.command // empty' 2>/dev/null | grep -q "$cmd_basename"; then
254
+ ALREADY_PRESENT=true
255
+ break
256
+ fi
257
+ done
258
+
259
+ if [ "$ALREADY_PRESENT" = true ]; then
260
+ echo " SKIP $EVENT hooks (already configured)"
261
+ continue
262
+ fi
263
+
264
+ # Append template entries to existing
265
+ MERGED=$(jq -n --argjson existing "$EXISTING_ENTRIES" --argjson template "$TEMPLATE_ENTRIES" '$existing + $template')
266
+ EXISTING=$(echo "$EXISTING" | jq --arg e "$EVENT" --argjson m "$MERGED" '.hooks[$e] = $m')
267
+ echo " MERGE $EVENT hooks"
268
+ done
269
+
270
+ echo "$EXISTING" | jq '.' > "$SETTINGS_FILE"
271
+ echo " UPDATED $SETTINGS_FILE"
272
+ fi
180
273
 
181
- # Ensure hooks key exists
182
- if echo "$EXISTING" | jq -e '.hooks' >/dev/null 2>&1; then
183
- : # hooks key exists
184
- else
185
- EXISTING=$(echo "$EXISTING" | jq '. + {hooks: {}}')
274
+ echo ""
275
+ echo " Hooks are active."
186
276
  fi
277
+ fi
278
+ fi
187
279
 
188
- # Merge each hook event type
189
- for EVENT in PreToolUse UserPromptSubmit PostToolUse Stop; do
190
- TEMPLATE_ENTRIES=$(echo "$TEMPLATE_HOOKS" | jq --arg e "$EVENT" '.[$e] // []')
191
- EXISTING_ENTRIES=$(echo "$EXISTING" | jq --arg e "$EVENT" '.hooks[$e] // []')
280
+ # --- Verification ---
192
281
 
193
- # Check if any FTM hooks are already present (by checking command paths)
194
- FTM_COMMANDS=$(echo "$TEMPLATE_ENTRIES" | jq -r '.[].hooks[]?.command // empty' 2>/dev/null)
195
- ALREADY_PRESENT=false
282
+ echo ""
283
+ echo "Verifying installation..."
196
284
 
197
- for cmd in $FTM_COMMANDS; do
198
- cmd_basename=$(basename "$cmd")
199
- if echo "$EXISTING_ENTRIES" | jq -r '.[].hooks[]?.command // empty' 2>/dev/null | grep -q "$cmd_basename"; then
200
- ALREADY_PRESENT=true
201
- break
202
- fi
203
- done
285
+ ERRORS=0
204
286
 
205
- if [ "$ALREADY_PRESENT" = true ]; then
206
- echo " SKIP $EVENT hooks (already configured)"
207
- continue
208
- fi
287
+ # Check skill symlinks resolve
288
+ BROKEN_LINKS=0
289
+ for link in "$SKILLS_DIR"/ftm*; do
290
+ [ -L "$link" ] || continue
291
+ if [ ! -e "$link" ]; then
292
+ warn "broken symlink: $link"
293
+ BROKEN_LINKS=$((BROKEN_LINKS + 1))
294
+ fi
295
+ done
296
+ if [ "$BROKEN_LINKS" -eq 0 ]; then
297
+ echo " Skills: $SKILL_COUNT linked, all symlinks valid"
298
+ else
299
+ ERRORS=$((ERRORS + 1))
300
+ fi
209
301
 
210
- # Append template entries to existing
211
- MERGED=$(jq -n --argjson existing "$EXISTING_ENTRIES" --argjson template "$TEMPLATE_ENTRIES" '$existing + $template')
212
- EXISTING=$(echo "$EXISTING" | jq --arg e "$EVENT" --argjson m "$MERGED" '.hooks[$e] = $m')
213
- echo " MERGE $EVENT hooks"
214
- done
302
+ # Check blackboard state
303
+ if [ -f "$STATE_DIR/blackboard/context.json" ] && [ -f "$STATE_DIR/blackboard/patterns.json" ]; then
304
+ echo " Blackboard: initialized"
305
+ else
306
+ warn "blackboard state incomplete"
307
+ ERRORS=$((ERRORS + 1))
308
+ fi
215
309
 
216
- echo "$EXISTING" | jq '.' > "$SETTINGS_FILE"
217
- echo " UPDATED $SETTINGS_FILE"
310
+ # Check config
311
+ if [ -f "$CONFIG_DIR/ftm-config.yml" ]; then
312
+ echo " Config: present"
313
+ else
314
+ warn "ftm-config.yml missing"
315
+ ERRORS=$((ERRORS + 1))
316
+ fi
317
+
318
+ # Check hooks (if installed)
319
+ if [ "$NO_HOOKS" = false ] && [ "$HOOK_COUNT" -gt 0 ]; then
320
+ # Verify hook files exist and are executable
321
+ HOOK_OK=true
322
+ for hook in "$HOOKS_DIR"/ftm-*.sh; do
323
+ [ -f "$hook" ] || continue
324
+ if [ ! -x "$hook" ]; then
325
+ warn "$(basename "$hook") not executable"
326
+ HOOK_OK=false
327
+ fi
328
+ done
329
+
330
+ if [ "$HOOK_OK" = true ]; then
331
+ echo " Hooks: $HOOK_COUNT installed, all executable"
332
+ else
333
+ ERRORS=$((ERRORS + 1))
218
334
  fi
219
335
 
220
- echo ""
221
- echo " Hooks are now active. See docs/HOOKS.md for details."
222
- else
223
- echo ""
224
- echo " To activate hooks, run: ./install.sh --setup-hooks"
225
- echo " Or manually add entries from hooks/settings-template.json to ~/.claude/settings.json"
226
- echo " See docs/HOOKS.md for details."
336
+ # Verify settings.json has FTM hooks registered
337
+ if [ "$SKIP_MERGE" = false ] && [ -f "$SETTINGS_FILE" ]; then
338
+ FTM_REGISTERED=$(grep -c 'ftm-' "$SETTINGS_FILE" 2>/dev/null || echo "0")
339
+ if [ "$FTM_REGISTERED" -gt 0 ]; then
340
+ echo " Settings: $FTM_REGISTERED FTM entries in settings.json"
341
+ else
342
+ warn "no FTM hooks found in settings.json"
343
+ ERRORS=$((ERRORS + 1))
344
+ fi
345
+ fi
227
346
  fi
228
347
 
348
+ # --- Summary ---
349
+
350
+ echo ""
351
+ if [ "$ERRORS" -eq 0 ] && [ "$WARN_COUNT" -eq 0 ]; then
352
+ echo "Done. $SKILL_COUNT skills, $HOOK_COUNT hooks. Everything checks out."
353
+ else
354
+ echo "Done. $SKILL_COUNT skills, $HOOK_COUNT hooks. $WARN_COUNT warning(s)."
355
+ fi
229
356
  echo ""
230
- echo "Done. $SKILL_COUNT skills, $HOOK_COUNT hooks."
357
+ echo "Restart Claude Code (or start a new session) to pick up the skills."
231
358
  echo "Try: /ftm help"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feed-the-machine",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A unified intelligence layer for Claude Code — 22 skills with OODA-based reasoning, persistent memory, multi-model deliberation, and optional operator cockpit inbox",
5
5
  "license": "MIT",
6
6
  "author": "kkudumu",