@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.4
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 +37 -94
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +89 -264
- package/aesop.config.example.json +27 -26
- package/bin/CLAUDE.md +39 -0
- package/bin/cli.js +368 -24
- package/daemons/CLAUDE.md +33 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +90 -10
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/spikes/tiered-cognition/README.md +29 -0
- package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/hooks/CLAUDE.md +38 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +29 -8
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +529 -62
- package/package.json +12 -3
- package/skills/CLAUDE.md +3 -0
- package/skills/power/SKILL.md +161 -0
- package/tools/CLAUDE.md +191 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +204 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +131 -31
- package/tools/stall_check.py +172 -0
- package/tools/verify_dash.py +694 -0
- package/tools/verify_submit_encoding.py +194 -0
- package/ui/CLAUDE.md +109 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +179 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +58 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +586 -0
- package/ui/config.py +114 -0
- package/ui/csrf.py +140 -0
- package/ui/handler.py +426 -0
- package/ui/render.py +29 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +168 -0
- package/ui/templates/dashboard.html +1202 -0
package/bin/cli.js
CHANGED
|
@@ -2,52 +2,124 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
5
6
|
|
|
6
7
|
const args = process.argv.slice(2);
|
|
7
8
|
const helpFlag = args.includes('--help') || args.includes('-h');
|
|
9
|
+
const forceFlag = args.includes('--force');
|
|
10
|
+
|
|
11
|
+
// Parse named flags
|
|
12
|
+
function getFlag(flagName) {
|
|
13
|
+
const idx = args.findIndex(arg => arg === flagName);
|
|
14
|
+
if (idx !== -1 && idx + 1 < args.length) {
|
|
15
|
+
return args[idx + 1];
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
8
19
|
|
|
9
20
|
if (helpFlag) {
|
|
10
21
|
console.log(`
|
|
11
22
|
aesop — Multi-agent orchestration template scaffolder
|
|
12
23
|
|
|
13
24
|
Usage:
|
|
14
|
-
npx @matt82198/aesop [target-dir]
|
|
25
|
+
npx @matt82198/aesop [target-dir] [options]
|
|
15
26
|
|
|
16
27
|
Arguments:
|
|
17
28
|
target-dir Directory to scaffold the template into (default: "aesop-fleet")
|
|
18
29
|
|
|
30
|
+
Options:
|
|
31
|
+
--help, -h Show this help message
|
|
32
|
+
--force Replace any existing .git/hooks/pre-push during scaffold
|
|
33
|
+
--name <name> Project name (for headless scaffolding; generates CLAUDE.md + aesop.config.json)
|
|
34
|
+
--domains <list> Comma-separated domain list (e.g., "api,worker,monitoring")
|
|
35
|
+
--repos <paths> Comma-separated repo paths (e.g., "/path/to/repo1,/path/to/repo2")
|
|
36
|
+
|
|
19
37
|
Examples:
|
|
20
|
-
npx @matt82198/aesop
|
|
21
|
-
npx @matt82198/aesop my-fleet
|
|
22
|
-
npx @matt82198/aesop
|
|
38
|
+
npx @matt82198/aesop # Creates ./aesop-fleet/ with template
|
|
39
|
+
npx @matt82198/aesop my-fleet # Creates ./my-fleet/ with template
|
|
40
|
+
npx @matt82198/aesop my-fleet --force # Re-scaffold and replace hooks
|
|
41
|
+
npx @matt82198/aesop orchestrator --name "my-service" # Headless: generates CLAUDE.md + config
|
|
23
42
|
|
|
24
|
-
|
|
25
|
-
|
|
43
|
+
Full headless with domains and repos (POSIX):
|
|
44
|
+
npx @matt82198/aesop orchestrator --name "api" --domains "server,worker" --repos "/path/to/api"
|
|
45
|
+
|
|
46
|
+
Full headless with domains and repos (PowerShell):
|
|
47
|
+
npx @matt82198/aesop orchestrator --name "api" --domains "server,worker" --repos "C:\path\to\api"
|
|
26
48
|
|
|
27
|
-
After scaffolding, cd into the directory and:
|
|
28
|
-
1.
|
|
29
|
-
2.
|
|
49
|
+
After scaffolding with --name, cd into the directory and:
|
|
50
|
+
1. Review CLAUDE.md (pre-filled with your project info)
|
|
51
|
+
2. Review aesop.config.json (pre-configured for your repos)
|
|
30
52
|
3. Run: bash daemons/run-watchdog.sh --once
|
|
31
53
|
4. Launch dashboard: python ui/serve.py
|
|
32
54
|
`);
|
|
33
55
|
process.exit(0);
|
|
34
56
|
}
|
|
35
57
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
58
|
+
// Extract targetDir (first non-flag argument, excluding flag values)
|
|
59
|
+
// Build set of indices consumed as flag values (tokens after --name/--domains/--repos)
|
|
60
|
+
const consumedIndices = new Set();
|
|
61
|
+
const flagsWithValues = ['--name', '--domains', '--repos'];
|
|
62
|
+
for (let i = 0; i < args.length; i++) {
|
|
63
|
+
if (flagsWithValues.includes(args[i]) && i + 1 < args.length) {
|
|
64
|
+
consumedIndices.add(i + 1);
|
|
65
|
+
}
|
|
41
66
|
}
|
|
67
|
+
// targetDir = first non-flag, non-consumed argument, else default to 'aesop-fleet'
|
|
68
|
+
const targetDir = args.find((arg, idx) =>
|
|
69
|
+
!arg.startsWith('--') &&
|
|
70
|
+
!arg.startsWith('-') &&
|
|
71
|
+
!consumedIndices.has(idx)
|
|
72
|
+
) || 'aesop-fleet';
|
|
42
73
|
|
|
43
|
-
//
|
|
74
|
+
// Parse onboarding flags
|
|
75
|
+
const projectName = getFlag('--name');
|
|
76
|
+
const domainsStr = getFlag('--domains');
|
|
77
|
+
const reposStr = getFlag('--repos');
|
|
78
|
+
|
|
79
|
+
// Validate target directory doesn't exist or is empty (except for .git and aesop files)
|
|
44
80
|
if (fs.existsSync(targetDir)) {
|
|
45
81
|
const contents = fs.readdirSync(targetDir);
|
|
46
|
-
|
|
47
|
-
|
|
82
|
+
// Allow .git and aesop scaffolded files to already exist (for idempotency)
|
|
83
|
+
const aesopDirs = ['daemons', 'dash', 'monitor', 'tools', 'ui', 'docs', '.git', 'state'];
|
|
84
|
+
const aesopFiles = [
|
|
85
|
+
'aesop.config.example.json',
|
|
86
|
+
'aesop.config.json',
|
|
87
|
+
'README.md',
|
|
88
|
+
'LICENSE',
|
|
89
|
+
'CHANGELOG.md',
|
|
90
|
+
'CLAUDE-TEMPLATE.md',
|
|
91
|
+
'CLAUDE.md',
|
|
92
|
+
'MEMORY-SEED.md'
|
|
93
|
+
];
|
|
94
|
+
const allowedItems = new Set([...aesopDirs, ...aesopFiles]);
|
|
95
|
+
|
|
96
|
+
const unexpectedContents = contents.filter(item => !allowedItems.has(item));
|
|
97
|
+
if (unexpectedContents.length > 0) {
|
|
98
|
+
console.error(`Error: Directory "${targetDir}" exists and contains unexpected files.`);
|
|
48
99
|
console.error('Please choose a different target directory or remove the existing one.');
|
|
49
100
|
process.exit(1);
|
|
50
101
|
}
|
|
102
|
+
|
|
103
|
+
// SECURITY: A pre-existing allowlisted entry (e.g. ".git", "state") must be a real
|
|
104
|
+
// directory/file, never a symlink or junction. Following a symlinked entry here would
|
|
105
|
+
// let scaffolding escape targetDir entirely (e.g. a symlinked .git whose "hooks"
|
|
106
|
+
// subdirectory resolves outside targetDir). lstat (not stat) so the link itself is
|
|
107
|
+
// inspected rather than whatever it points to.
|
|
108
|
+
for (const item of contents) {
|
|
109
|
+
const itemPath = path.join(targetDir, item);
|
|
110
|
+
let itemLstat;
|
|
111
|
+
try {
|
|
112
|
+
itemLstat = fs.lstatSync(itemPath);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// lstat failed (race/permissions); nothing to check, move on
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (itemLstat.isSymbolicLink()) {
|
|
118
|
+
console.error(`Error: "${item}" in "${targetDir}" is a symlink (security risk).`);
|
|
119
|
+
console.error('Refusing to scaffold through a symlinked entry. Remove it and re-run.');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
51
123
|
} else {
|
|
52
124
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
53
125
|
}
|
|
@@ -64,7 +136,8 @@ const filesToCopy = [
|
|
|
64
136
|
'aesop.config.example.json',
|
|
65
137
|
'README.md',
|
|
66
138
|
'LICENSE',
|
|
67
|
-
'CHANGELOG.md'
|
|
139
|
+
'CHANGELOG.md',
|
|
140
|
+
'CLAUDE-TEMPLATE.md'
|
|
68
141
|
];
|
|
69
142
|
|
|
70
143
|
let copiedCount = 0;
|
|
@@ -87,6 +160,220 @@ function copyRecursive(src, dest) {
|
|
|
87
160
|
}
|
|
88
161
|
}
|
|
89
162
|
|
|
163
|
+
function generateDomainText(domainsStr) {
|
|
164
|
+
if (!domainsStr) {
|
|
165
|
+
return '- **[Your domains here]** — [Customize per team]';
|
|
166
|
+
}
|
|
167
|
+
const domains = domainsStr.split(',').map(d => d.trim()).filter(d => d);
|
|
168
|
+
if (domains.length === 0) {
|
|
169
|
+
return '- **[Your domains here]** — [Customize per team]';
|
|
170
|
+
}
|
|
171
|
+
return domains
|
|
172
|
+
.map(domain => `- **${domain}/** — [Add description for ${domain}]`)
|
|
173
|
+
.join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function generateRepoText(reposStr) {
|
|
177
|
+
if (!reposStr) {
|
|
178
|
+
return '- **[Your repos here]** — [Customize per team]';
|
|
179
|
+
}
|
|
180
|
+
const repos = reposStr.split(',').map(r => r.trim()).filter(r => r);
|
|
181
|
+
if (repos.length === 0) {
|
|
182
|
+
return '- **[Your repos here]** — [Customize per team]';
|
|
183
|
+
}
|
|
184
|
+
return repos
|
|
185
|
+
.map(repo => {
|
|
186
|
+
const repoName = path.basename(repo);
|
|
187
|
+
return `- **${repo}** — [${repoName} domains and setup]`;
|
|
188
|
+
})
|
|
189
|
+
.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function substituteTemplate(templateContent, projectName, domainsStr, reposStr) {
|
|
193
|
+
let result = templateContent;
|
|
194
|
+
result = result.replace(/{{PROJECT_NAME}}/g, projectName || 'my-fleet');
|
|
195
|
+
const domainList = domainsStr ? domainsStr.split(',').map(d => d.trim()).join(', ') : 'multi-domain';
|
|
196
|
+
result = result.replace(/{{DOMAIN_LIST}}/g, domainList);
|
|
197
|
+
result = result.replace(/{{DOMAINS}}/g, generateDomainText(domainsStr));
|
|
198
|
+
result = result.replace(/{{REPO_LIST}}/g, generateRepoText(reposStr));
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function generateConfigJson(targetDir, templateRoot, projectName, reposStr) {
|
|
203
|
+
// Read example config
|
|
204
|
+
const exampleConfigPath = path.join(templateRoot, 'aesop.config.example.json');
|
|
205
|
+
let exampleConfig;
|
|
206
|
+
try {
|
|
207
|
+
const content = fs.readFileSync(exampleConfigPath, 'utf8');
|
|
208
|
+
exampleConfig = JSON.parse(content);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
// Fallback config if example doesn't parse
|
|
211
|
+
exampleConfig = {
|
|
212
|
+
description: 'Aesop configuration',
|
|
213
|
+
aesop_root: path.dirname(targetDir),
|
|
214
|
+
brain_root: path.join(os.homedir(), '.claude'),
|
|
215
|
+
scripts_root: path.join(os.homedir(), 'scripts'),
|
|
216
|
+
temp_root: path.join(os.tmpdir()),
|
|
217
|
+
repos: [],
|
|
218
|
+
watchdog: {
|
|
219
|
+
cycle_seconds: 150,
|
|
220
|
+
heartbeat_threshold_seconds: 200,
|
|
221
|
+
enable_secret_scan: true,
|
|
222
|
+
secret_scan_script: 'tools/secret_scan.py'
|
|
223
|
+
},
|
|
224
|
+
monitor: {
|
|
225
|
+
enable: true,
|
|
226
|
+
cycle_seconds: 3600,
|
|
227
|
+
heartbeat_threshold_seconds: 3600,
|
|
228
|
+
memory_staleness_days: 30,
|
|
229
|
+
log_max_lines: 500,
|
|
230
|
+
log_max_kb: 40
|
|
231
|
+
},
|
|
232
|
+
dashboard: {
|
|
233
|
+
refresh_seconds: 1,
|
|
234
|
+
enable_jq_parsing: true,
|
|
235
|
+
theme: 'dark'
|
|
236
|
+
},
|
|
237
|
+
cardinal_rules: {
|
|
238
|
+
subagent_model: 'haiku',
|
|
239
|
+
orchestrator_model: 'opus',
|
|
240
|
+
tdd_first: true,
|
|
241
|
+
never_push_main: true,
|
|
242
|
+
secret_scan_gates_push: true
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Update with provided values
|
|
248
|
+
exampleConfig.aesop_root = targetDir;
|
|
249
|
+
exampleConfig.brain_root = '~/.claude';
|
|
250
|
+
exampleConfig.scripts_root = '~/scripts';
|
|
251
|
+
exampleConfig.temp_root = '~/.aesop-temp';
|
|
252
|
+
exampleConfig._generated_note = 'This config uses portable ~ paths which expand at runtime on all platforms. Config loaders in ui/config.py (Python) and monitor/collect-signals.mjs (Node.js) automatically expand ~ paths to your home directory.';
|
|
253
|
+
|
|
254
|
+
// Parse repos if provided
|
|
255
|
+
if (reposStr) {
|
|
256
|
+
const repos = reposStr.split(',').map(r => r.trim()).filter(r => r);
|
|
257
|
+
exampleConfig.repos = repos.map((repoPath, idx) => ({
|
|
258
|
+
path: repoPath,
|
|
259
|
+
name: path.basename(repoPath),
|
|
260
|
+
url: `https://github.com/user/${path.basename(repoPath)}.git`,
|
|
261
|
+
primary_branch: 'main',
|
|
262
|
+
backup_branch: 'backup/wip'
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return exampleConfig;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function installPrePushHook(targetDir, templateRoot) {
|
|
270
|
+
// Try to locate .git directory
|
|
271
|
+
const gitDir = path.join(targetDir, '.git');
|
|
272
|
+
if (!fs.existsSync(gitDir)) {
|
|
273
|
+
// No git repo, skip hook installation silently
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// SECURITY: Check if .git itself is a symlink/junction (refuse to follow it outside
|
|
278
|
+
// targetDir). This mirrors the gitHooksDir/hookDest checks below — .git is one path
|
|
279
|
+
// component higher and must be validated before anything derived from it is trusted.
|
|
280
|
+
try {
|
|
281
|
+
const gitDirLstat = fs.lstatSync(gitDir);
|
|
282
|
+
if (gitDirLstat.isSymbolicLink()) {
|
|
283
|
+
console.warn('⚠ Warning: .git is a symlink (security risk)');
|
|
284
|
+
console.warn(' Skipping hook installation. Please remove the symlink and re-run scaffold.');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// lstat failed; proceed (may be a permission issue)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Ensure hooks directory exists
|
|
292
|
+
const gitHooksDir = path.join(gitDir, 'hooks');
|
|
293
|
+
if (!fs.existsSync(gitHooksDir)) {
|
|
294
|
+
fs.mkdirSync(gitHooksDir, { recursive: true });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// SECURITY: Check if gitHooksDir is a symlink (refuse to install through symlinked dir)
|
|
298
|
+
try {
|
|
299
|
+
const hooksLstat = fs.lstatSync(gitHooksDir);
|
|
300
|
+
if (hooksLstat.isSymbolicLink()) {
|
|
301
|
+
console.warn('⚠ Warning: .git/hooks directory is a symlink (security risk)');
|
|
302
|
+
console.warn(' Skipping hook installation. Please remove the symlink and re-run scaffold.');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
} catch (e) {
|
|
306
|
+
// lstat failed; proceed (may be a permission issue)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const hookSource = path.join(templateRoot, 'hooks', 'pre-push-policy.sh');
|
|
310
|
+
const hookDest = path.join(gitHooksDir, 'pre-push');
|
|
311
|
+
|
|
312
|
+
if (!fs.existsSync(hookSource)) {
|
|
313
|
+
// Hook source doesn't exist, skip
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const hookSourceContent = fs.readFileSync(hookSource, 'utf8');
|
|
318
|
+
|
|
319
|
+
// Check if hook already exists
|
|
320
|
+
if (fs.existsSync(hookDest)) {
|
|
321
|
+
// SECURITY: Check if existing hookDest is a symlink (refuse to follow it)
|
|
322
|
+
try {
|
|
323
|
+
const destLstat = fs.lstatSync(hookDest);
|
|
324
|
+
if (destLstat.isSymbolicLink()) {
|
|
325
|
+
console.warn('⚠ Warning: Existing .git/hooks/pre-push is a symlink (security risk)');
|
|
326
|
+
console.warn(' Skipping hook installation. Please remove the symlink and re-run scaffold.');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
// lstat failed; proceed
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const existingContent = fs.readFileSync(hookDest, 'utf8');
|
|
334
|
+
|
|
335
|
+
// If content matches, it's idempotent, do nothing
|
|
336
|
+
if (existingContent === hookSourceContent) {
|
|
337
|
+
console.log('✓ Pre-push hook already installed (no changes)');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Different hook exists
|
|
342
|
+
if (!forceFlag) {
|
|
343
|
+
console.warn('⚠ Warning: A different pre-push hook already exists at ' + hookDest);
|
|
344
|
+
console.warn(' Use --force to replace it, or customize manually.');
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --force: replace the existing hook
|
|
349
|
+
console.log('✓ Replacing existing pre-push hook with --force');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Install the hook
|
|
353
|
+
if (process.platform === 'win32') {
|
|
354
|
+
// Windows: copy the file
|
|
355
|
+
fs.copyFileSync(hookSource, hookDest);
|
|
356
|
+
console.log('✓ Copied pre-push policy hook to .git/hooks/pre-push');
|
|
357
|
+
} else {
|
|
358
|
+
// Unix: symlink for easy updates
|
|
359
|
+
// First remove if exists
|
|
360
|
+
if (fs.existsSync(hookDest)) {
|
|
361
|
+
fs.unlinkSync(hookDest);
|
|
362
|
+
}
|
|
363
|
+
// Create symlink relative to .git/hooks/
|
|
364
|
+
const relPath = path.relative(gitHooksDir, hookSource);
|
|
365
|
+
fs.symlinkSync(relPath, hookDest);
|
|
366
|
+
console.log('✓ Symlinked pre-push policy hook to .git/hooks/pre-push');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Ensure hook is executable
|
|
370
|
+
try {
|
|
371
|
+
fs.chmodSync(hookDest, 0o755);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
// On Windows, chmod may fail; that's okay
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
90
377
|
try {
|
|
91
378
|
filesToCopy.forEach(item => {
|
|
92
379
|
const src = path.join(templateRoot, item);
|
|
@@ -97,13 +384,70 @@ try {
|
|
|
97
384
|
}
|
|
98
385
|
});
|
|
99
386
|
|
|
387
|
+
// Create state/ directory
|
|
388
|
+
const stateDir = path.join(targetDir, 'state');
|
|
389
|
+
if (!fs.existsSync(stateDir)) {
|
|
390
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
391
|
+
console.log('✓ Created state/ directory');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// If --name provided, generate CLAUDE.md and aesop.config.json
|
|
395
|
+
if (projectName) {
|
|
396
|
+
// Generate CLAUDE.md from template
|
|
397
|
+
const templatePath = path.join(targetDir, 'CLAUDE-TEMPLATE.md');
|
|
398
|
+
const claudeMdPath = path.join(targetDir, 'CLAUDE.md');
|
|
399
|
+
if (fs.existsSync(templatePath)) {
|
|
400
|
+
const templateContent = fs.readFileSync(templatePath, 'utf8');
|
|
401
|
+
const claudeContent = substituteTemplate(templateContent, projectName, domainsStr, reposStr);
|
|
402
|
+
fs.writeFileSync(claudeMdPath, claudeContent);
|
|
403
|
+
console.log('✓ Generated CLAUDE.md (from template with substitutions)');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Generate aesop.config.json
|
|
407
|
+
const configPath = path.join(targetDir, 'aesop.config.json');
|
|
408
|
+
const config = generateConfigJson(targetDir, templateRoot, projectName, reposStr);
|
|
409
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
410
|
+
console.log('✓ Generated aesop.config.json (configured for your repos)');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Copy MEMORY-TEMPLATE.md as MEMORY-SEED.md
|
|
414
|
+
const memoryTemplatePath = path.join(templateRoot, 'docs', 'MEMORY-TEMPLATE.md');
|
|
415
|
+
const memorySeedPath = path.join(targetDir, 'MEMORY-SEED.md');
|
|
416
|
+
if (fs.existsSync(memoryTemplatePath) && !fs.existsSync(memorySeedPath)) {
|
|
417
|
+
fs.copyFileSync(memoryTemplatePath, memorySeedPath);
|
|
418
|
+
console.log('✓ Copied MEMORY-SEED.md (template for your facts)');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Install the pre-push hook
|
|
422
|
+
installPrePushHook(targetDir, templateRoot);
|
|
423
|
+
|
|
100
424
|
console.log(`\n✅ Scaffolded aesop template into "${targetDir}" (${copiedCount} files)`);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
425
|
+
|
|
426
|
+
if (projectName) {
|
|
427
|
+
console.log('\nHeadless scaffolding complete! Next steps:');
|
|
428
|
+
console.log(` 1. cd ${targetDir}`);
|
|
429
|
+
console.log(' 2. Review CLAUDE.md (pre-filled with your project info)');
|
|
430
|
+
console.log(' 3. Review aesop.config.json (pre-configured for your repos)');
|
|
431
|
+
console.log('\nInitialize your brain (Claude Code team memory):');
|
|
432
|
+
console.log(' 4. mkdir -p ~/.claude/memory');
|
|
433
|
+
console.log(` 5. cp ${targetDir}/CLAUDE.md ~/.claude/CLAUDE.md (or review existing ~/.claude/CLAUDE.md)`);
|
|
434
|
+
console.log(` 6. cp ${targetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
|
|
435
|
+
console.log('\nRun the daemon and dashboard:');
|
|
436
|
+
console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
|
|
437
|
+
console.log(' 8. python ui/serve.py (launch dashboard on localhost:8770)');
|
|
438
|
+
} else {
|
|
439
|
+
console.log('\nConfiguration steps:');
|
|
440
|
+
console.log(` 1. cd ${targetDir}`);
|
|
441
|
+
console.log(' 2. cp aesop.config.example.json aesop.config.json');
|
|
442
|
+
console.log(' 3. Edit aesop.config.json with your configuration');
|
|
443
|
+
console.log('\nInitialize your brain (Claude Code team memory):');
|
|
444
|
+
console.log(' 4. mkdir -p ~/.claude/memory');
|
|
445
|
+
console.log(` 5. cp ${targetDir}/CLAUDE-TEMPLATE.md ~/.claude/CLAUDE.md (then edit domains/team info)`);
|
|
446
|
+
console.log(` 6. cp ${targetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
|
|
447
|
+
console.log('\nRun the daemon and dashboard:');
|
|
448
|
+
console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
|
|
449
|
+
console.log(' 8. python ui/serve.py (launch dashboard on localhost:8770)');
|
|
450
|
+
}
|
|
107
451
|
console.log('\nFor full documentation, see the README.md in the scaffolded directory.');
|
|
108
452
|
process.exit(0);
|
|
109
453
|
} catch (err) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# daemons/ — Watchdog daemon
|
|
2
|
+
|
|
3
|
+
**Purpose**: Long-running backup and secret-scan daemon for fleet-wide repo safety.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
- **run-watchdog.sh** (1.5K): Interactive daemon supervisor; spawns backup-fleet.sh every 150s, maintains heartbeat guard (200s dedupe window), logs to FLEET-BACKUP.log. Traps INT/TERM cleanly.
|
|
8
|
+
- **backup-fleet.sh** (5K): Core backup worker; discovers repos (~/.*, ~/*, ~/dev/*), stashes uncommitted work to backup/* branches, pushes unpushed commits, scans tracked files for secrets. Blocks push if secret-scan fails (marks BLOCKED state).
|
|
9
|
+
|
|
10
|
+
## Contracts
|
|
11
|
+
|
|
12
|
+
**Env vars consumed**: `AESOP_ROOT` (defaults to `.`) — root of project; prepends to all state/ and tools/ paths.
|
|
13
|
+
|
|
14
|
+
**State files written** (git-ignored):
|
|
15
|
+
- `$AESOP_ROOT/state/.watchdog-heartbeat` — epoch seconds; dedupe guard (200s window).
|
|
16
|
+
- `$AESOP_ROOT/state/.watchdog-repos.json` — per-cycle snapshot: [{repo, state: CLEAN|PUSHED|SNAPSHOTTED|BLOCKED, age}].
|
|
17
|
+
- `$AESOP_ROOT/state/FLEET-BACKUP.log` — append-only; cycle start/end, repo statuses, secret-scan blocks.
|
|
18
|
+
|
|
19
|
+
**Exit behaviors**: run-watchdog exits 0 always (trap handles clean shutdown). backup-fleet exits 0 even on secret-scan block (marks repo BLOCKED, continues).
|
|
20
|
+
|
|
21
|
+
**Cadence**: 150s cycle; heartbeat dedupe within 200s.
|
|
22
|
+
|
|
23
|
+
## Invariants & Gotchas
|
|
24
|
+
|
|
25
|
+
1. **Single-instance guard (atomic lock)**: run-watchdog.sh uses atomic mkdir-based lockfile (`.watchdog-lock/`) to prevent concurrent daemons.
|
|
26
|
+
- Atomic acquire: `mkdir $LOCK_DIR` is guaranteed atomic on POSIX systems (returns 0 only to first caller).
|
|
27
|
+
- Stale-lock recovery: Crashed holder won't wedge daemon forever; lock older than 300s is reclaimed atomically.
|
|
28
|
+
- Guards both daemon mode and `--once` mode (no bypass).
|
|
29
|
+
2. **Testing override**: Set `AESOP_WATCHDOG_CYCLE_CMD` env var to replace backup-fleet.sh invocation (allows tests to use mock cycle without running real backup).
|
|
30
|
+
3. **CRLF-safe, no line continuations**: Use POSIX-safe heredocs; never add `\` for line wrap.
|
|
31
|
+
4. **Secret-scan gate**: `scan_tracked_files()` calls `$AESOP_ROOT/tools/secret_scan.py` on staged/modified files; non-0 exit blocks push, marks repo BLOCKED.
|
|
32
|
+
5. **Append-only logs**: FLEET-BACKUP.log only grows; rotate via tools/rotate_logs.py.
|
|
33
|
+
6. **Path dedup via realpath**: Avoids processing symlinked repos or dot-dir aliases twice.
|
package/daemons/backup-fleet.sh
CHANGED
|
@@ -3,16 +3,49 @@ set -uo pipefail
|
|
|
3
3
|
# Backup fleet repos: stash uncommitted work, push unpushed commits to backup branches.
|
|
4
4
|
# Runs every 150s from run-watchdog.sh. Abort on secret-scan failures.
|
|
5
5
|
# Improvements: dot-directory discovery, path dedup, tracked-files-only secret scanning.
|
|
6
|
+
# P2 FIX: JSON escaping for repo names and NUL-delimited internal protocol.
|
|
6
7
|
|
|
7
8
|
AESOP_ROOT="${AESOP_ROOT:-.}"
|
|
8
9
|
HEARTBEAT="$AESOP_ROOT/state/.watchdog-heartbeat"
|
|
9
10
|
LOG="$AESOP_ROOT/state/FLEET-BACKUP.log"
|
|
10
11
|
REPOS_STATUS="$AESOP_ROOT/state/.watchdog-repos.json"
|
|
11
12
|
|
|
13
|
+
# P2 FIX: script-global temp file handles (cycle-status JSON build + per-repo
|
|
14
|
+
# NUL-protocol scratch file), cleaned up unconditionally via trap below so an
|
|
15
|
+
# interrupted cycle (INT/TERM) or unexpected error never leaks mktemp files
|
|
16
|
+
# over the daemon's long-running lifecycle. Initialized empty so `rm -f`
|
|
17
|
+
# under `set -u` is always safe even if the trap fires before mktemp runs.
|
|
18
|
+
temp_json=""
|
|
19
|
+
temp_result=""
|
|
20
|
+
cleanup_temp_files() {
|
|
21
|
+
rm -f "$temp_json" "$temp_result"
|
|
22
|
+
}
|
|
23
|
+
trap cleanup_temp_files EXIT INT TERM
|
|
24
|
+
|
|
12
25
|
date +%s > "$HEARTBEAT" 2>/dev/null
|
|
13
26
|
ts() { date '+%Y-%m-%d %H:%M:%S'; }
|
|
14
27
|
log() { echo "[$(ts)] $*" | tee -a "$LOG"; }
|
|
15
28
|
|
|
29
|
+
# P2 FIX: JSON escape function for safe string interpolation in JSON
|
|
30
|
+
# Escapes backslash, quote, and control characters (newline, tab, carriage return, etc.)
|
|
31
|
+
json_escape() {
|
|
32
|
+
local s="$1"
|
|
33
|
+
# Escape backslash first (must be first to avoid double-escaping)
|
|
34
|
+
s="${s//\\/\\\\}"
|
|
35
|
+
# Escape double quote
|
|
36
|
+
s="${s//\"/\\\"}"
|
|
37
|
+
# Escape newline
|
|
38
|
+
s="${s//$'\n'/\\n}"
|
|
39
|
+
# Escape carriage return
|
|
40
|
+
s="${s//$'\r'/\\r}"
|
|
41
|
+
# Escape tab
|
|
42
|
+
s="${s//$'\t'/\\t}"
|
|
43
|
+
# Escape backspace, form feed, and other C0 controls
|
|
44
|
+
s="${s//$'\b'/\\b}"
|
|
45
|
+
s="${s//$'\f'/\\f}"
|
|
46
|
+
printf '%s' "$s"
|
|
47
|
+
}
|
|
48
|
+
|
|
16
49
|
is_touched() {
|
|
17
50
|
local repo="$1"
|
|
18
51
|
[ ! -d "$repo/.git" ] && return 1
|
|
@@ -33,31 +66,60 @@ get_tracked_modifications() {
|
|
|
33
66
|
) | sort -u
|
|
34
67
|
}
|
|
35
68
|
|
|
69
|
+
get_untracked_files() {
|
|
70
|
+
local repo="$1"
|
|
71
|
+
(
|
|
72
|
+
cd "$repo" || return 1
|
|
73
|
+
git ls-files --others --exclude-standard 2>/dev/null
|
|
74
|
+
) | sort -u
|
|
75
|
+
}
|
|
76
|
+
|
|
36
77
|
scan_tracked_files() {
|
|
37
78
|
local repo="$1"
|
|
38
79
|
local tracked_files
|
|
39
|
-
local
|
|
80
|
+
local untracked_files
|
|
81
|
+
local -a file_paths=()
|
|
82
|
+
local repo_win
|
|
83
|
+
|
|
84
|
+
# Convert repo path to Windows format for Python compatibility (Git Bash on Windows)
|
|
85
|
+
repo_win=$(cd "$repo" 2>/dev/null && pwd -W 2>/dev/null || echo "$repo")
|
|
86
|
+
|
|
40
87
|
tracked_files=$(get_tracked_modifications "$repo")
|
|
88
|
+
untracked_files=$(get_untracked_files "$repo")
|
|
41
89
|
|
|
42
|
-
|
|
43
|
-
|
|
90
|
+
# Collect tracked modifications
|
|
91
|
+
if [ -n "$tracked_files" ]; then
|
|
92
|
+
while IFS= read -r file; do
|
|
93
|
+
if [ -n "$file" ] && [ -f "$repo/$file" ]; then
|
|
94
|
+
file_paths+=("$repo_win/$file")
|
|
95
|
+
fi
|
|
96
|
+
done <<EOF
|
|
97
|
+
$tracked_files
|
|
98
|
+
EOF
|
|
44
99
|
fi
|
|
45
100
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
101
|
+
# Collect untracked files (ITEM 1 fix)
|
|
102
|
+
if [ -n "$untracked_files" ]; then
|
|
103
|
+
while IFS= read -r file; do
|
|
104
|
+
if [ -n "$file" ] && [ -f "$repo/$file" ]; then
|
|
105
|
+
file_paths+=("$repo_win/$file")
|
|
106
|
+
fi
|
|
107
|
+
done <<EOF
|
|
108
|
+
$untracked_files
|
|
52
109
|
EOF
|
|
110
|
+
fi
|
|
53
111
|
|
|
54
|
-
if [
|
|
112
|
+
if [ ${#file_paths[@]} -eq 0 ]; then
|
|
55
113
|
return 0
|
|
56
114
|
fi
|
|
57
115
|
|
|
58
116
|
if [ -f "$AESOP_ROOT/tools/secret_scan.py" ]; then
|
|
59
|
-
|
|
117
|
+
# ITEM 2 fix: use "${file_paths[@]}" to properly quote array elements
|
|
118
|
+
python "$AESOP_ROOT/tools/secret_scan.py" "${file_paths[@]}" >/dev/null 2>&1
|
|
119
|
+
return $?
|
|
60
120
|
fi
|
|
121
|
+
|
|
122
|
+
return 0
|
|
61
123
|
}
|
|
62
124
|
|
|
63
125
|
get_default_branch() {
|
|
@@ -88,11 +150,11 @@ process_repo() {
|
|
|
88
150
|
WIPREF="backup/wip-$(date +%Y%m%d)"
|
|
89
151
|
if scan_tracked_files "$repo"; then
|
|
90
152
|
if git push -qf origin "$COMMIT:refs/heads/$WIPREF" 2>/dev/null; then
|
|
91
|
-
printf 'SNAPSHOTTED
|
|
153
|
+
printf 'SNAPSHOTTED\0%s\n' "$name"
|
|
92
154
|
exit 0
|
|
93
155
|
fi
|
|
94
156
|
else
|
|
95
|
-
printf 'BLOCKED
|
|
157
|
+
printf 'BLOCKED\0%s\n' "$name"
|
|
96
158
|
exit 0
|
|
97
159
|
fi
|
|
98
160
|
fi
|
|
@@ -107,27 +169,27 @@ process_repo() {
|
|
|
107
169
|
WIPREF="backup/master-wip-$(date +%Y%m%d)"
|
|
108
170
|
if scan_tracked_files "$repo"; then
|
|
109
171
|
if git push -qf origin "HEAD:refs/heads/$WIPREF" 2>/dev/null; then
|
|
110
|
-
printf 'SNAPSHOTTED
|
|
172
|
+
printf 'SNAPSHOTTED\0%s\n' "$name"
|
|
111
173
|
exit 0
|
|
112
174
|
fi
|
|
113
175
|
else
|
|
114
|
-
printf 'BLOCKED
|
|
176
|
+
printf 'BLOCKED\0%s\n' "$name"
|
|
115
177
|
exit 0
|
|
116
178
|
fi
|
|
117
179
|
else
|
|
118
180
|
if scan_tracked_files "$repo"; then
|
|
119
181
|
if git push -q origin "$branch" 2>/dev/null; then
|
|
120
|
-
printf 'PUSHED
|
|
182
|
+
printf 'PUSHED\0%s\n' "$name"
|
|
121
183
|
exit 0
|
|
122
184
|
fi
|
|
123
185
|
else
|
|
124
|
-
printf 'BLOCKED
|
|
186
|
+
printf 'BLOCKED\0%s\n' "$name"
|
|
125
187
|
exit 0
|
|
126
188
|
fi
|
|
127
189
|
fi
|
|
128
190
|
fi
|
|
129
191
|
|
|
130
|
-
printf 'CLEAN
|
|
192
|
+
printf 'CLEAN\0%s\n' "$name"
|
|
131
193
|
)
|
|
132
194
|
}
|
|
133
195
|
|
|
@@ -154,15 +216,24 @@ for dir in ~/.* ~/* ~/dev/*; do
|
|
|
154
216
|
$real_dir"
|
|
155
217
|
|
|
156
218
|
if is_touched "$dir"; then
|
|
157
|
-
|
|
158
|
-
|
|
219
|
+
# P0 FIX: Eliminate $() command substitution to preserve NUL bytes in protocol
|
|
220
|
+
# Direct redirect maintains NUL delimiters (process_repo emits STATE\0name format)
|
|
221
|
+
temp_result=$(mktemp)
|
|
222
|
+
process_repo "$dir" > "$temp_result"
|
|
223
|
+
# Read first field (state) and second field (name) using NUL delimiter
|
|
224
|
+
# Portable solution: use awk with RS="\0" instead of GNU sed \x0 (BSD/macOS compatible)
|
|
225
|
+
state=$(awk 'BEGIN{RS="\0"} NR==1 {print}' "$temp_result")
|
|
226
|
+
name=$(awk 'BEGIN{RS="\0"} NR==2 {print}' "$temp_result" | tr -d '\n')
|
|
227
|
+
rm -f "$temp_result"
|
|
159
228
|
[ -z "$state" ] && continue
|
|
160
229
|
if [ "$first" = 1 ]; then
|
|
161
230
|
first=0
|
|
162
231
|
else
|
|
163
232
|
echo "," >> "$temp_json"
|
|
164
233
|
fi
|
|
165
|
-
|
|
234
|
+
# P2 FIX: JSON-escape all interpolated values before emitting
|
|
235
|
+
# P2 FIX: Portable date format (not GNU-only date -Iseconds)
|
|
236
|
+
printf '{"repo":"%s","state":"%s","age":"%s"}' "$(json_escape "$name")" "$(json_escape "$state")" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$temp_json"
|
|
166
237
|
log "$state: $name"
|
|
167
238
|
fi
|
|
168
239
|
done
|