azclaude-copilot 0.4.39 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +9 -7
  4. package/bin/cli.js +53 -1
  5. package/package.json +2 -2
  6. package/templates/CLAUDE.md +35 -1
  7. package/templates/agents/cc-cli-integrator.md +5 -0
  8. package/templates/agents/cc-template-author.md +7 -0
  9. package/templates/agents/cc-test-maintainer.md +5 -0
  10. package/templates/agents/code-reviewer.md +11 -0
  11. package/templates/agents/constitution-guard.md +9 -0
  12. package/templates/agents/devops-engineer.md +9 -0
  13. package/templates/agents/loop-controller.md +7 -0
  14. package/templates/agents/milestone-builder.md +7 -0
  15. package/templates/agents/orchestrator-init.md +9 -1
  16. package/templates/agents/orchestrator.md +8 -0
  17. package/templates/agents/problem-architect.md +29 -1
  18. package/templates/agents/qa-engineer.md +9 -0
  19. package/templates/agents/security-auditor.md +9 -0
  20. package/templates/agents/spec-reviewer.md +9 -0
  21. package/templates/agents/test-writer.md +11 -0
  22. package/templates/capabilities/manifest.md +2 -0
  23. package/templates/capabilities/shared/context-inoculation.md +39 -0
  24. package/templates/capabilities/shared/reward-hack-detection.md +32 -0
  25. package/templates/commands/audit.md +8 -0
  26. package/templates/commands/ghost-test.md +99 -0
  27. package/templates/commands/inoculate.md +76 -0
  28. package/templates/commands/sentinel.md +3 -0
  29. package/templates/commands/ship.md +6 -0
  30. package/templates/commands/test.md +10 -0
  31. package/templates/hooks/post-tool-use.js +341 -277
  32. package/templates/hooks/pre-tool-use.js +344 -292
  33. package/templates/hooks/stop.js +198 -151
  34. package/templates/hooks/user-prompt.js +369 -163
  35. package/templates/scripts/statusline.sh +105 -0
  36. package/templates/skills/agent-creator/SKILL.md +11 -0
  37. package/templates/skills/architecture-advisor/SKILL.md +21 -16
  38. package/templates/skills/debate/SKILL.md +5 -0
  39. package/templates/skills/env-scanner/SKILL.md +5 -0
  40. package/templates/skills/frontend-design/SKILL.md +5 -0
  41. package/templates/skills/mcp/SKILL.md +3 -0
  42. package/templates/skills/security/SKILL.md +3 -0
  43. package/templates/skills/session-guard/SKILL.md +3 -0
  44. package/templates/skills/skill-creator/SKILL.md +12 -0
  45. package/templates/skills/test-first/SKILL.md +5 -0
@@ -1,277 +1,341 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
- /**
4
- * AZCLAUDE — PostToolUse hook
5
- * Tracks edits to goals.md after every Write/Edit.
6
- * Captures: timestamp, file path, git diff stat (+N/-N), and change summary.
7
- * Survives Claude Code context compaction — goals.md is the external memory.
8
- * No user action required. Silent. Works on Windows/macOS/Linux.
9
- */
10
- const fs = require('fs');
11
- const path = require('path');
12
- const os = require('os');
13
- const { spawnSync } = require('child_process');
14
-
15
- // ── Hook profile gate ───────────────────────────────────────────────────────
16
- // AZCLAUDE_HOOK_PROFILE=minimal|standard|strict (default: standard)
17
- // minimal = goals.md tracking only (no observations, no cost tracking)
18
- // standard = all features (default)
19
- // strict = all features + extra validation
20
- const HOOK_PROFILE = process.env.AZCLAUDE_HOOK_PROFILE || 'standard';
21
-
22
- // Read tool input + response from stdin — Claude Code sends JSON and closes stdin
23
- let filePath = '';
24
- let changeSummary = '';
25
- let toolName = '';
26
- try {
27
- const raw = fs.readFileSync(0, 'utf8'); // fd 0 = stdin, cross-platform
28
- const data = JSON.parse(raw);
29
- toolName = data.tool_name || '';
30
- filePath = data.tool_input?.file_path || data.tool_input?.path || data.tool_input?.command || '';
31
- // Extract change summary from old_string/new_string diff hint (Edit tool)
32
- // MultiEdit: edits[] array use first edit's new_string
33
- const oldStr = data.tool_input?.old_string || data.tool_input?.edits?.[0]?.old_string || '';
34
- const newStr = data.tool_input?.new_string || data.tool_input?.edits?.[0]?.new_string || '';
35
- if (oldStr && newStr) {
36
- // Summarize: first non-empty line of new content (what was added)
37
- const firstNew = newStr.split('\n').find(l => l.trim().length > 0) || '';
38
- if (firstNew.length > 0 && firstNew.length < 80) {
39
- changeSummary = ' ' + firstNew.trim().replace(/^[-*#`]+\s*/, '').slice(0, 60);
40
- }
41
- }
42
- } catch (_) {}
43
-
44
- // Also accept env var fallback (older Claude Code versions)
45
- if (!filePath) filePath = process.env.CLAUDE_FILE_PATH || '';
46
-
47
- const cfg = process.env.AZCLAUDE_CFG || '.claude';
48
- // Guard: cfg must resolve inside the project root
49
- if (path.resolve(cfg).indexOf(process.cwd()) !== 0) process.exit(0);
50
- const goalsPath = path.join(cfg, 'memory', 'goals.md');
51
- if (!fs.existsSync(goalsPath)) process.exit(0); // not an AZCLAUDE project
52
-
53
- // For non-file tools (Bash, Grep without file_path), still capture observations but skip goals tracking
54
- const isFileTool = toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit' || (!toolName && filePath);
55
- const rel = filePath ? path.relative(process.cwd(), path.resolve(filePath)) : toolName || 'unknown';
56
-
57
- if (isFileTool) {
58
- if (!filePath) process.exit(0);
59
- if (rel.startsWith('..')) process.exit(0); // outside project
60
- if (/goals\.md$/.test(rel)) process.exit(0); // prevent loop
61
- if (/node_modules[\\/]|\.git[\\/]/.test(rel)) process.exit(0); // noise
62
- }
63
-
64
- // Timestamp HH:MM
65
- const now = new Date();
66
- const ts = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
67
-
68
- // ── Goals.md tracking (Write/Edit only — file modifications) ────────────────
69
- if (isFileTool) {
70
-
71
- // Git diff stat: "+N/-M"cached for 5s to avoid repeated git calls on consecutive edits
72
- let diffStat = '';
73
- const diffCachePath = path.join(os.tmpdir(), `.azclaude-diff-${process.ppid || process.pid}`);
74
- let diffCache = {};
75
- try { diffCache = JSON.parse(fs.readFileSync(diffCachePath, 'utf8')); } catch (_) {}
76
- const cacheAge = Date.now() - (diffCache._ts || 0);
77
- const cached = diffCache[rel];
78
- if (cached && cacheAge < 5000) {
79
- diffStat = cached;
80
- } else {
81
- try {
82
- const r = spawnSync('git', ['diff', 'HEAD', '--numstat', '--', rel],
83
- { encoding: 'utf8', cwd: process.cwd(), timeout: 3000 });
84
- if (r.status === 0 && r.stdout.trim()) {
85
- const [added, deleted] = r.stdout.trim().split('\t');
86
- const a = parseInt(added, 10);
87
- const d = parseInt(deleted, 10);
88
- if (!isNaN(a) && !isNaN(d) && (a > 0 || d > 0)) {
89
- diffStat = ` (+${a}/-${d})`;
90
- }
91
- }
92
- } catch (_) {}
93
- // Update cache
94
- if (cacheAge >= 5000) diffCache = {};
95
- diffCache[rel] = diffStat;
96
- diffCache._ts = Date.now();
97
- try { fs.writeFileSync(diffCachePath, JSON.stringify(diffCache)); } catch (_) {}
98
- }
99
-
100
- const entry = `- ${ts} ${rel}${diffStat}${changeSummary}`;
101
-
102
- let content = fs.readFileSync(goalsPath, 'utf8');
103
-
104
- const HEADING = '## In progress';
105
-
106
- if (!content.includes(HEADING)) {
107
- // Add section at end
108
- content = content.trimEnd() + `\n\n${HEADING}\n${entry}\n`;
109
- } else {
110
- const lines = content.split('\n');
111
- const hIdx = lines.findIndex(l => l.trim() === HEADING);
112
-
113
- // Remove any existing entry for the same file (dedup — keep latest timestamp)
114
- const cleaned = lines.filter((l, i) => {
115
- if (i <= hIdx) return true; // keep heading and everything before
116
- if (!l.startsWith('- ')) return true; // keep non-entry lines
117
- return !l.includes(rel); // remove old entry for this file
118
- });
119
-
120
- // Insert new entry right after heading
121
- cleaned.splice(hIdx + 1, 0, entry);
122
- content = cleaned.join('\n');
123
- }
124
-
125
- try { fs.writeFileSync(goalsPath, content); } catch (_) {}
126
-
127
- // ── Memory rotation — keep ## In progress bounded at 30 entries ──────────────
128
- const ROTATE_THRESHOLD = 30;
129
- const KEEP_NEWEST = 15;
130
- const rotLines = content.split('\n');
131
- const rotHIdx = rotLines.findIndex(l => l.trim() === HEADING);
132
- if (rotHIdx !== -1) {
133
- const ipEntries = [];
134
- for (let i = rotHIdx + 1; i < rotLines.length; i++) {
135
- if (rotLines[i].startsWith('## ')) break;
136
- if (rotLines[i].startsWith('- ')) ipEntries.push({ line: rotLines[i], idx: i });
137
- }
138
- if (ipEntries.length >= ROTATE_THRESHOLD) {
139
- const toArchive = ipEntries.slice(KEEP_NEWEST);
140
- const archiveTs = new Date().toISOString().slice(0, 16);
141
- const archiveDate = new Date().toISOString().slice(0, 10);
142
- const archivePath = path.join(cfg, 'memory', 'sessions', `${archiveDate}-edits.md`);
143
- try { fs.mkdirSync(path.join(cfg, 'memory', 'sessions'), { recursive: true }); } catch (_) {}
144
- const header = `\n<!-- archived: ${archiveTs} source: post-tool-use -->\n`;
145
- const payload = toArchive.map(e => e.line).join('\n') + '\n';
146
- try { fs.appendFileSync(archivePath, header + payload); } catch (_) {}
147
- // Rewrite goals.md keeping only newest 15 entries
148
- const archivedSet = new Set(toArchive.map(e => e.idx));
149
- const pruned = rotLines.filter((_, i) => !archivedSet.has(i));
150
- try { fs.writeFileSync(goalsPath, pruned.join('\n')); } catch (_) {}
151
- }
152
- }
153
-
154
- } // end isFileTool goals tracking
155
-
156
- // ── Reflex observation capture (standard/strict only) ───────────────────────
157
- // Append tool-use observation to observations.jsonl for pattern detection.
158
- // Tracks actual tool name + tool sequences (last 3 tools) for pattern detection.
159
- if (HOOK_PROFILE !== 'minimal') {
160
- const reflexDir = path.join(cfg, 'memory', 'reflexes');
161
- try {
162
- fs.mkdirSync(reflexDir, { recursive: true });
163
- const obsPath = path.join(reflexDir, 'observations.jsonl');
164
- const obsTs = now.toISOString().replace(/\.\d{3}Z$/, 'Z');
165
- const tool = toolName || 'Edit';
166
- // Scrub secrets: strip API keys, tokens, passwords from file paths
167
- const safeRel = rel.replace(/\.(env|key|pem|secret|credential)/gi, '.[REDACTED]');
168
-
169
- // Track tool sequence: last 3 tools for pattern detection (Read→Edit→Bash)
170
- const seqPath = path.join(os.tmpdir(), `.azclaude-seq-${process.ppid || process.pid}`);
171
- let seq = [];
172
- try { seq = JSON.parse(fs.readFileSync(seqPath, 'utf8')); } catch (_) {}
173
- seq.push(tool);
174
- if (seq.length > 3) seq = seq.slice(-3);
175
- try { fs.writeFileSync(seqPath, JSON.stringify(seq)); } catch (_) {}
176
-
177
- const obs = JSON.stringify({
178
- ts: obsTs, tool, file: safeRel, session: process.ppid || process.pid,
179
- event: 'complete', seq: seq.join('→')
180
- });
181
- fs.appendFileSync(obsPath, obs + '\n');
182
-
183
- // ── Behavioral security: detect dangerous tool sequences ─────────────────
184
- // Maintain a security-focused seq separate from the reflex seq.
185
- // Stores {tool, file} pairs to detect cross-tool exfiltration patterns.
186
- const secSeqPath = path.join(os.tmpdir(), `.azclaude-secseq-${process.ppid || process.pid}`);
187
- let secSeq = [];
188
- try { secSeq = JSON.parse(fs.readFileSync(secSeqPath, 'utf8')); } catch (_) {}
189
- secSeq.push({ tool, file: rel });
190
- if (secSeq.length > 5) secSeq = secSeq.slice(-5);
191
- try { fs.writeFileSync(secSeqPath, JSON.stringify(secSeq)); } catch (_) {}
192
-
193
- if (secSeq.length >= 2) {
194
- const prev = secSeq[secSeq.length - 2];
195
- const curr = secSeq[secSeq.length - 1];
196
- const CRED = /\.env$|secrets?\.(json|ya?ml)$|credentials?(\.json)?$|id_rsa$|\.pem$/i;
197
- // Pattern: Read credential file → Bash or WebFetch
198
- if (prev.tool === 'Read' && CRED.test(prev.file || '')
199
- && (curr.tool === 'Bash' || curr.tool === 'WebFetch')) {
200
- const seclogPath = path.join(os.tmpdir(), `.azclaude-seclog-${process.ppid || process.pid}`);
201
- const entry = JSON.stringify({
202
- ts: obsTs, hook: 'post-tool-use',
203
- rule: 'credential-read-then-exec', level: 'warn',
204
- target: `${path.basename(prev.file || '')} ${curr.tool}`
205
- });
206
- try { fs.appendFileSync(seclogPath, entry + '\n'); } catch (_) {}
207
- process.stderr.write(
208
- `\n⚠ SECURITY: Credential file (${path.basename(prev.file || '')}) read then ${curr.tool} verify no secrets are being transmitted.\n`
209
- );
210
- }
211
- }
212
- // Auto-truncate: keep last 2000 lines max (prevent unbounded growth)
213
- try {
214
- const obsContent = fs.readFileSync(obsPath, 'utf8');
215
- const obsLines = obsContent.split('\n').filter(Boolean);
216
- if (obsLines.length > 2000) {
217
- fs.writeFileSync(obsPath, obsLines.slice(-500).join('\n') + '\n');
218
- }
219
- } catch (_) {}
220
- } catch (_) {}
221
- }
222
-
223
- // ── Cost tracking (standard/strict only) ────────────────────────────────────
224
- // Append estimated cost per tool call to costs.jsonl for budget awareness.
225
- if (HOOK_PROFILE !== 'minimal') {
226
- try {
227
- const costsDir = path.join(cfg, 'memory', 'metrics');
228
- fs.mkdirSync(costsDir, { recursive: true });
229
- const costsPath = path.join(costsDir, 'costs.jsonl');
230
- const costEntry = JSON.stringify({
231
- ts: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
232
- tool: toolName || 'Edit',
233
- file: rel,
234
- session: process.ppid || process.pid
235
- });
236
- fs.appendFileSync(costsPath, costEntry + '\n');
237
- // Auto-truncate: keep last 1000 entries
238
- try {
239
- const costLines = fs.readFileSync(costsPath, 'utf8').split('\n').filter(Boolean);
240
- if (costLines.length > 1000) {
241
- fs.writeFileSync(costsPath, costLines.slice(-500).join('\n') + '\n');
242
- }
243
- } catch (_) {}
244
- } catch (_) {}
245
- }
246
-
247
- // ── Checkpoint reminder every 15 edits ──────────────────────────────────────
248
- const counterPath = path.join(os.tmpdir(), `.azclaude-edit-count-${process.ppid || process.pid}`);
249
- let editCount = 1;
250
- try { editCount = parseInt(fs.readFileSync(counterPath, 'utf8'), 10) + 1; } catch (_) {}
251
- try { fs.writeFileSync(counterPath, String(editCount)); } catch (_) {}
252
- if (editCount > 0 && editCount % 15 === 0) {
253
- process.stderr.write(`\n⚠ ${editCount} edits this session — run /snapshot before context compaction loses your reasoning\n`);
254
- }
255
-
256
- // ── Rapid-edit detection — same file edited 5+ times in <5 min ───────────────
257
- // Signal: unclear spec before coding. Warn once, suggest /blueprint.
258
- if (isFileTool && rel) {
259
- const rapidPath = path.join(os.tmpdir(), `.azclaude-rapid-${process.ppid || process.pid}`);
260
- let rapidLog = {};
261
- try { rapidLog = JSON.parse(fs.readFileSync(rapidPath, 'utf8')); } catch (_) {}
262
- const fileLog = rapidLog[rel] || { count: 0, firstTs: Date.now(), warned: false };
263
- const elapsed = Date.now() - fileLog.firstTs;
264
- if (elapsed > 5 * 60 * 1000) {
265
- // Reset window
266
- rapidLog[rel] = { count: 1, firstTs: Date.now(), warned: false };
267
- } else {
268
- fileLog.count += 1;
269
- if (fileLog.count >= 5 && !fileLog.warned) {
270
- fileLog.warned = true;
271
- const shortName = path.basename(rel);
272
- process.stdout.write(`\n⚠ ${fileLog.count} edits to ${shortName} in ${Math.round(elapsed/60000)}min — unclear spec? Consider /blueprint before continuing\n`);
273
- }
274
- rapidLog[rel] = fileLog;
275
- }
276
- try { fs.writeFileSync(rapidPath, JSON.stringify(rapidLog)); } catch (_) {}
277
- }
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * AZCLAUDE — PostToolUse hook
5
+ * Tracks edits to goals.md after every Write/Edit.
6
+ * Captures: timestamp, file path, git diff stat (+N/-N), and change summary.
7
+ * Survives Claude Code context compaction — goals.md is the external memory.
8
+ * No user action required. Silent. Works on Windows/macOS/Linux.
9
+ */
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+ const { spawnSync } = require('child_process');
14
+
15
+ // ── Hook profile gate ───────────────────────────────────────────────────────
16
+ // AZCLAUDE_HOOK_PROFILE=minimal|standard|strict (default: standard)
17
+ // minimal = goals.md tracking only (no observations, no cost tracking)
18
+ // standard = all features (default)
19
+ // strict = all features + extra validation
20
+ const HOOK_PROFILE = process.env.AZCLAUDE_HOOK_PROFILE || 'standard';
21
+
22
+ // Read tool input + response from stdin — Claude Code sends JSON and closes stdin
23
+ let filePath = '';
24
+ let changeSummary = '';
25
+ let toolName = '';
26
+ let toolOutput = '';
27
+ try {
28
+ const raw = fs.readFileSync(0, 'utf8'); // fd 0 = stdin, cross-platform
29
+ const data = JSON.parse(raw);
30
+ toolName = data.tool_name || '';
31
+ filePath = data.tool_input?.file_path || data.tool_input?.path || data.tool_input?.command || '';
32
+ // Extract change summary from old_string/new_string diff hint (Edit tool)
33
+ // MultiEdit: edits[] array — use first edit's new_string
34
+ const oldStr = data.tool_input?.old_string || data.tool_input?.edits?.[0]?.old_string || '';
35
+ const newStr = data.tool_input?.new_string || data.tool_input?.edits?.[0]?.new_string || '';
36
+ // Capture tool result output for Bash secret scanning
37
+ toolOutput = data.tool_result?.output || data.tool_result?.stdout || '';
38
+ if (oldStr && newStr) {
39
+ // Summarize: first non-empty line of new content (what was added)
40
+ const firstNew = newStr.split('\n').find(l => l.trim().length > 0) || '';
41
+ if (firstNew.length > 0 && firstNew.length < 80) {
42
+ changeSummary = ' — ' + firstNew.trim().replace(/^[-*#`]+\s*/, '').slice(0, 60);
43
+ }
44
+ }
45
+ } catch (_) {}
46
+
47
+ // Also accept env var fallback (older Claude Code versions)
48
+ if (!filePath) filePath = process.env.CLAUDE_FILE_PATH || '';
49
+
50
+ const cfg = process.env.AZCLAUDE_CFG || '.claude';
51
+ // Guard: cfg must resolve inside the project root
52
+ if (path.resolve(cfg).indexOf(process.cwd()) !== 0) process.exit(0);
53
+ const goalsPath = path.join(cfg, 'memory', 'goals.md');
54
+ if (!fs.existsSync(goalsPath)) process.exit(0); // not an AZCLAUDE project
55
+
56
+ // For non-file tools (Bash, Grep without file_path), still capture observations but skip goals tracking
57
+ const isFileTool = toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit' || (!toolName && filePath);
58
+ const rel = filePath ? path.relative(process.cwd(), path.resolve(filePath)) : toolName || 'unknown';
59
+
60
+ if (isFileTool) {
61
+ if (!filePath) process.exit(0);
62
+ if (rel.startsWith('..')) process.exit(0); // outside project
63
+ if (/goals\.md$/.test(rel)) process.exit(0); // prevent loop
64
+ if (/node_modules[\\/]|\.git[\\/]/.test(rel)) process.exit(0); // noise
65
+ }
66
+
67
+ // Timestamp HH:MM
68
+ const now = new Date();
69
+ const ts = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
70
+
71
+ // ── Goals.md tracking (Write/Edit only file modifications) ────────────────
72
+ if (isFileTool) {
73
+
74
+ // Git diff stat: "+N/-M" — cached for 5s to avoid repeated git calls on consecutive edits
75
+ let diffStat = '';
76
+ const diffCachePath = path.join(os.tmpdir(), `.azclaude-diff-${process.ppid || process.pid}`);
77
+ let diffCache = {};
78
+ try { diffCache = JSON.parse(fs.readFileSync(diffCachePath, 'utf8')); } catch (_) {}
79
+ const cacheAge = Date.now() - (diffCache._ts || 0);
80
+ const cached = diffCache[rel];
81
+ if (cached && cacheAge < 5000) {
82
+ diffStat = cached;
83
+ } else {
84
+ try {
85
+ const r = spawnSync('git', ['diff', 'HEAD', '--numstat', '--', rel],
86
+ { encoding: 'utf8', cwd: process.cwd(), timeout: 3000 });
87
+ if (r.status === 0 && r.stdout.trim()) {
88
+ const [added, deleted] = r.stdout.trim().split('\t');
89
+ const a = parseInt(added, 10);
90
+ const d = parseInt(deleted, 10);
91
+ if (!isNaN(a) && !isNaN(d) && (a > 0 || d > 0)) {
92
+ diffStat = ` (+${a}/-${d})`;
93
+ }
94
+ }
95
+ } catch (_) {}
96
+ // Update cache
97
+ if (cacheAge >= 5000) diffCache = {};
98
+ diffCache[rel] = diffStat;
99
+ diffCache._ts = Date.now();
100
+ try { fs.writeFileSync(diffCachePath, JSON.stringify(diffCache)); } catch (_) {}
101
+ }
102
+
103
+ const entry = `- ${ts} — ${rel}${diffStat}${changeSummary}`;
104
+
105
+ let content = fs.readFileSync(goalsPath, 'utf8');
106
+
107
+ const HEADING = '## In progress';
108
+
109
+ if (!content.includes(HEADING)) {
110
+ // Add section at end
111
+ content = content.trimEnd() + `\n\n${HEADING}\n${entry}\n`;
112
+ } else {
113
+ const lines = content.split('\n');
114
+ const hIdx = lines.findIndex(l => l.trim() === HEADING);
115
+
116
+ // Remove any existing entry for the same file (dedup — keep latest timestamp)
117
+ const cleaned = lines.filter((l, i) => {
118
+ if (i <= hIdx) return true; // keep heading and everything before
119
+ if (!l.startsWith('- ')) return true; // keep non-entry lines
120
+ return !l.includes(rel); // remove old entry for this file
121
+ });
122
+
123
+ // Insert new entry right after heading
124
+ cleaned.splice(hIdx + 1, 0, entry);
125
+ content = cleaned.join('\n');
126
+ }
127
+
128
+ try { fs.writeFileSync(goalsPath, content); } catch (_) {}
129
+
130
+ // ── Memory rotation — keep ## In progress bounded at 30 entries ──────────────
131
+ const ROTATE_THRESHOLD = 30;
132
+ const KEEP_NEWEST = 15;
133
+ const rotLines = content.split('\n');
134
+ const rotHIdx = rotLines.findIndex(l => l.trim() === HEADING);
135
+ if (rotHIdx !== -1) {
136
+ const ipEntries = [];
137
+ for (let i = rotHIdx + 1; i < rotLines.length; i++) {
138
+ if (rotLines[i].startsWith('## ')) break;
139
+ if (rotLines[i].startsWith('- ')) ipEntries.push({ line: rotLines[i], idx: i });
140
+ }
141
+ if (ipEntries.length >= ROTATE_THRESHOLD) {
142
+ const toArchive = ipEntries.slice(KEEP_NEWEST);
143
+ const archiveTs = new Date().toISOString().slice(0, 16);
144
+ const archiveDate = new Date().toISOString().slice(0, 10);
145
+ const archivePath = path.join(cfg, 'memory', 'sessions', `${archiveDate}-edits.md`);
146
+ try { fs.mkdirSync(path.join(cfg, 'memory', 'sessions'), { recursive: true }); } catch (_) {}
147
+ const header = `\n<!-- archived: ${archiveTs} source: post-tool-use -->\n`;
148
+ const payload = toArchive.map(e => e.line).join('\n') + '\n';
149
+ try { fs.appendFileSync(archivePath, header + payload); } catch (_) {}
150
+ // Rewrite goals.md keeping only newest 15 entries
151
+ const archivedSet = new Set(toArchive.map(e => e.idx));
152
+ const pruned = rotLines.filter((_, i) => !archivedSet.has(i));
153
+ try { fs.writeFileSync(goalsPath, pruned.join('\n')); } catch (_) {}
154
+ }
155
+ }
156
+
157
+ } // end isFileTool goals tracking
158
+
159
+ // ── Reflex observation capture (standard/strict only) ───────────────────────
160
+ // Append tool-use observation to observations.jsonl for pattern detection.
161
+ // Tracks actual tool name + tool sequences (last 3 tools) for pattern detection.
162
+ if (HOOK_PROFILE !== 'minimal') {
163
+ const reflexDir = path.join(cfg, 'memory', 'reflexes');
164
+ const obsTs = now.toISOString().replace(/\.\d{3}Z$/, 'Z');
165
+ const tool = toolName || 'Edit';
166
+ const safeRel = rel.replace(/\.(env|key|pem|secret|credential)/gi, '.[REDACTED]');
167
+
168
+ // ── Reflex observation capture ──
169
+ try {
170
+ fs.mkdirSync(reflexDir, { recursive: true });
171
+ const obsPath = path.join(reflexDir, 'observations.jsonl');
172
+
173
+ // Track tool sequence: last 3 tools for pattern detection (Read→Edit→Bash)
174
+ const seqPath = path.join(os.tmpdir(), `.azclaude-seq-${process.ppid || process.pid}`);
175
+ let seq = [];
176
+ try { seq = JSON.parse(fs.readFileSync(seqPath, 'utf8')); } catch (_) {}
177
+ seq.push(tool);
178
+ if (seq.length > 3) seq = seq.slice(-3);
179
+ try { fs.writeFileSync(seqPath, JSON.stringify(seq)); } catch (_) {}
180
+
181
+ const obs = JSON.stringify({
182
+ ts: obsTs, tool, file: safeRel, session: process.ppid || process.pid,
183
+ event: 'complete', seq: seq.join('→')
184
+ });
185
+ fs.appendFileSync(obsPath, obs + '\n');
186
+
187
+ // Auto-truncate: stat-based size check (avoids reading entire file every call)
188
+ try {
189
+ const obsStat = fs.statSync(obsPath);
190
+ if (obsStat.size > 200000) { // ~200KB ≈ ~2000 lines
191
+ const obsContent = fs.readFileSync(obsPath, 'utf8');
192
+ const obsLines = obsContent.split('\n').filter(Boolean);
193
+ fs.writeFileSync(obsPath, obsLines.slice(-500).join('\n') + '\n');
194
+ }
195
+ } catch (_) {}
196
+ } catch (_) {}
197
+
198
+ // ── Behavioral security: sequence detection ──
199
+ try {
200
+ const secSeqPath = path.join(os.tmpdir(), `.azclaude-secseq-${process.ppid || process.pid}`);
201
+ let secSeq = [];
202
+ try { secSeq = JSON.parse(fs.readFileSync(secSeqPath, 'utf8')); } catch (_) {}
203
+ secSeq.push({ tool, file: rel });
204
+ if (secSeq.length > 5) secSeq = secSeq.slice(-5);
205
+ try { fs.writeFileSync(secSeqPath, JSON.stringify(secSeq)); } catch (_) {}
206
+
207
+ if (secSeq.length >= 2) {
208
+ const prev = secSeq[secSeq.length - 2];
209
+ const curr = secSeq[secSeq.length - 1];
210
+ const CRED = /\.env$|secrets?\.(json|ya?ml)$|credentials?(\.json)?$|id_rsa$|\.pem$/i;
211
+ // Pattern: Read credential file → Bash or WebFetch
212
+ if (prev.tool === 'Read' && CRED.test(prev.file || '')
213
+ && (curr.tool === 'Bash' || curr.tool === 'WebFetch')) {
214
+ const seclogPath = path.join(os.tmpdir(), `.azclaude-seclog-${process.ppid || process.pid}`);
215
+ const entry = JSON.stringify({
216
+ ts: obsTs, hook: 'post-tool-use',
217
+ rule: 'credential-read-then-exec', level: 'warn',
218
+ target: `${path.basename(prev.file || '')} → ${curr.tool}`
219
+ });
220
+ try { fs.appendFileSync(seclogPath, entry + '\n'); } catch (_) {}
221
+ process.stderr.write(
222
+ `\n⚠ SECURITY: Credential file (${path.basename(prev.file || '')}) read then ${curr.tool} — verify no secrets are being transmitted.\n`
223
+ );
224
+ }
225
+ }
226
+
227
+ // ── Reward hack behavioral patterns (Anthropic "Emergent Misalignment" paper) ──
228
+
229
+ // Pattern: Bash(test run) → Edit/Write(test file) = possible reward hacking
230
+ if (secSeq.length >= 2) {
231
+ const prev2 = secSeq[secSeq.length - 2];
232
+ const curr2 = secSeq[secSeq.length - 1];
233
+ if (prev2.tool === 'Bash' && /\b(pytest|jest|mocha|vitest|npm\s+test|npx\s+test)\b/i.test(prev2.file || '')
234
+ && (curr2.tool === 'Edit' || curr2.tool === 'Write' || curr2.tool === 'MultiEdit')
235
+ && /test[_/\\]|_test\.|\.test\.|\.spec\.|conftest/i.test(curr2.file || '')) {
236
+ const seclogPath = path.join(os.tmpdir(), `.azclaude-seclog-${process.ppid || process.pid}`);
237
+ const entry2 = JSON.stringify({
238
+ ts: obsTs, hook: 'post-tool-use',
239
+ rule: 'test-then-test-modify', level: 'warn',
240
+ target: `${prev2.tool}(test) → ${curr2.tool}(${path.basename(curr2.file || '')})`
241
+ });
242
+ try { fs.appendFileSync(seclogPath, entry2 + '\n'); } catch (_) {}
243
+ process.stderr.write(
244
+ `\n⚠ SECURITY: Test run then test file modification — verify edits fix the code, not fake the result.\n`
245
+ );
246
+ }
247
+ }
248
+
249
+ // Pattern: Any Edit/Write to .claude/hooks/ = always warn (hook self-modification)
250
+ {
251
+ const currH = secSeq[secSeq.length - 1];
252
+ if (currH && (currH.tool === 'Edit' || currH.tool === 'Write' || currH.tool === 'MultiEdit')
253
+ && /\.claude[/\\]hooks[/\\]/i.test(currH.file || '')) {
254
+ const seclogPath = path.join(os.tmpdir(), `.azclaude-seclog-${process.ppid || process.pid}`);
255
+ const entryH = JSON.stringify({
256
+ ts: obsTs, hook: 'post-tool-use',
257
+ rule: 'hook-self-modification', level: 'warn',
258
+ target: path.basename(currH.file || '')
259
+ });
260
+ try { fs.appendFileSync(seclogPath, entryH + '\n'); } catch (_) {}
261
+ process.stderr.write(
262
+ `\n⚠ SECURITY: Hook file modified (${path.basename(currH.file || '')}) hooks control all tool execution. Verify this change is intentional.\n`
263
+ );
264
+ }
265
+ }
266
+ } catch (_) {}
267
+ }
268
+
269
+ // ── Cost tracking (standard/strict only) ────────────────────────────────────
270
+ // Append estimated cost per tool call to costs.jsonl for budget awareness.
271
+ if (HOOK_PROFILE !== 'minimal') {
272
+ try {
273
+ const costsDir = path.join(cfg, 'memory', 'metrics');
274
+ fs.mkdirSync(costsDir, { recursive: true });
275
+ const costsPath = path.join(costsDir, 'costs.jsonl');
276
+ const costEntry = JSON.stringify({
277
+ ts: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
278
+ tool: toolName || 'Edit',
279
+ file: rel,
280
+ session: process.ppid || process.pid
281
+ });
282
+ fs.appendFileSync(costsPath, costEntry + '\n');
283
+ // Auto-truncate: stat-based size check
284
+ try {
285
+ const costStat = fs.statSync(costsPath);
286
+ if (costStat.size > 100000) { // ~100KB ≈ ~1000 entries
287
+ const costLines = fs.readFileSync(costsPath, 'utf8').split('\n').filter(Boolean);
288
+ fs.writeFileSync(costsPath, costLines.slice(-500).join('\n') + '\n');
289
+ }
290
+ } catch (_) {}
291
+ } catch (_) {}
292
+ }
293
+
294
+ // ── Bash output secret scanning (standard/strict only) ──────────────────────
295
+ if (HOOK_PROFILE !== 'minimal' && toolName === 'Bash' && toolOutput) {
296
+ const SECRET_RE = /AKIA[A-Z0-9]{16}|sk-[a-zA-Z0-9]{20,}|ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20}|xoxb-[0-9]|xoxp-[0-9]|-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY/;
297
+ if (SECRET_RE.test(toolOutput)) {
298
+ const seclogPath = path.join(os.tmpdir(), `.azclaude-seclog-${process.ppid || process.pid}`);
299
+ const entry = JSON.stringify({
300
+ ts: now.toISOString(), hook: 'post-tool-use',
301
+ rule: 'bash-output-secret-leak', level: 'warn',
302
+ target: (filePath || '').slice(0, 80)
303
+ });
304
+ try { fs.appendFileSync(seclogPath, entry + '\n'); } catch (_) {}
305
+ process.stderr.write(
306
+ `\n⚠ SECURITY: Bash output contains a secret pattern — verify no credentials were leaked to logs or context.\n`
307
+ );
308
+ }
309
+ }
310
+
311
+ // ── Checkpoint reminder every 15 edits ──────────────────────────────────────
312
+ const counterPath = path.join(os.tmpdir(), `.azclaude-edit-count-${process.ppid || process.pid}`);
313
+ let editCount = 1;
314
+ try { editCount = parseInt(fs.readFileSync(counterPath, 'utf8'), 10) + 1; } catch (_) {}
315
+ try { fs.writeFileSync(counterPath, String(editCount)); } catch (_) {}
316
+ if (editCount > 0 && editCount % 15 === 0) {
317
+ process.stderr.write(`\n⚠ ${editCount} edits this session — run /snapshot before context compaction loses your reasoning\n`);
318
+ }
319
+
320
+ // ── Rapid-edit detection — same file edited 5+ times in <5 min ───────────────
321
+ // Signal: unclear spec before coding. Warn once, suggest /blueprint.
322
+ if (isFileTool && rel) {
323
+ const rapidPath = path.join(os.tmpdir(), `.azclaude-rapid-${process.ppid || process.pid}`);
324
+ let rapidLog = {};
325
+ try { rapidLog = JSON.parse(fs.readFileSync(rapidPath, 'utf8')); } catch (_) {}
326
+ const fileLog = rapidLog[rel] || { count: 0, firstTs: Date.now(), warned: false };
327
+ const elapsed = Date.now() - fileLog.firstTs;
328
+ if (elapsed > 5 * 60 * 1000) {
329
+ // Reset window
330
+ rapidLog[rel] = { count: 1, firstTs: Date.now(), warned: false };
331
+ } else {
332
+ fileLog.count += 1;
333
+ if (fileLog.count >= 5 && !fileLog.warned) {
334
+ fileLog.warned = true;
335
+ const shortName = path.basename(rel);
336
+ process.stdout.write(`\n⚠ ${fileLog.count} edits to ${shortName} in ${Math.round(elapsed/60000)}min — unclear spec? Consider /blueprint before continuing\n`);
337
+ }
338
+ rapidLog[rel] = fileLog;
339
+ }
340
+ try { fs.writeFileSync(rapidPath, JSON.stringify(rapidLog)); } catch (_) {}
341
+ }