cc4pm 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/README.md +17 -0
- package/.claude-plugin/plugin.json +25 -0
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/README.zh-CN.md +134 -0
- package/contexts/dev.md +20 -0
- package/contexts/research.md +26 -0
- package/contexts/review.md +22 -0
- package/examples/CLAUDE.md +100 -0
- package/examples/statusline.json +19 -0
- package/examples/user-CLAUDE.md +109 -0
- package/install.sh +17 -0
- package/manifests/install-components.json +173 -0
- package/manifests/install-modules.json +335 -0
- package/manifests/install-profiles.json +75 -0
- package/package.json +117 -0
- package/schemas/ecc-install-config.schema.json +58 -0
- package/schemas/hooks.schema.json +197 -0
- package/schemas/install-components.schema.json +56 -0
- package/schemas/install-modules.schema.json +105 -0
- package/schemas/install-profiles.schema.json +45 -0
- package/schemas/install-state.schema.json +210 -0
- package/schemas/package-manager.schema.json +23 -0
- package/schemas/plugin.schema.json +58 -0
- package/scripts/ci/catalog.js +83 -0
- package/scripts/ci/validate-agents.js +81 -0
- package/scripts/ci/validate-commands.js +135 -0
- package/scripts/ci/validate-hooks.js +239 -0
- package/scripts/ci/validate-install-manifests.js +211 -0
- package/scripts/ci/validate-no-personal-paths.js +63 -0
- package/scripts/ci/validate-rules.js +81 -0
- package/scripts/ci/validate-skills.js +54 -0
- package/scripts/claw.js +468 -0
- package/scripts/doctor.js +110 -0
- package/scripts/ecc.js +194 -0
- package/scripts/hooks/auto-tmux-dev.js +88 -0
- package/scripts/hooks/check-console-log.js +71 -0
- package/scripts/hooks/check-hook-enabled.js +12 -0
- package/scripts/hooks/cost-tracker.js +78 -0
- package/scripts/hooks/doc-file-warning.js +63 -0
- package/scripts/hooks/evaluate-session.js +100 -0
- package/scripts/hooks/insaits-security-monitor.py +269 -0
- package/scripts/hooks/insaits-security-wrapper.js +88 -0
- package/scripts/hooks/post-bash-build-complete.js +27 -0
- package/scripts/hooks/post-bash-pr-created.js +36 -0
- package/scripts/hooks/post-edit-console-warn.js +54 -0
- package/scripts/hooks/post-edit-format.js +109 -0
- package/scripts/hooks/post-edit-typecheck.js +96 -0
- package/scripts/hooks/pre-bash-dev-server-block.js +187 -0
- package/scripts/hooks/pre-bash-git-push-reminder.js +28 -0
- package/scripts/hooks/pre-bash-tmux-reminder.js +33 -0
- package/scripts/hooks/pre-compact.js +48 -0
- package/scripts/hooks/pre-write-doc-warn.js +9 -0
- package/scripts/hooks/quality-gate.js +168 -0
- package/scripts/hooks/run-with-flags-shell.sh +32 -0
- package/scripts/hooks/run-with-flags.js +120 -0
- package/scripts/hooks/session-end-marker.js +15 -0
- package/scripts/hooks/session-end.js +299 -0
- package/scripts/hooks/session-start.js +97 -0
- package/scripts/hooks/suggest-compact.js +80 -0
- package/scripts/install-apply.js +137 -0
- package/scripts/install-plan.js +254 -0
- package/scripts/lib/hook-flags.js +74 -0
- package/scripts/lib/install/apply.js +23 -0
- package/scripts/lib/install/config.js +82 -0
- package/scripts/lib/install/request.js +113 -0
- package/scripts/lib/install/runtime.js +42 -0
- package/scripts/lib/install-executor.js +605 -0
- package/scripts/lib/install-lifecycle.js +763 -0
- package/scripts/lib/install-manifests.js +305 -0
- package/scripts/lib/install-state.js +120 -0
- package/scripts/lib/install-targets/antigravity-project.js +9 -0
- package/scripts/lib/install-targets/claude-home.js +10 -0
- package/scripts/lib/install-targets/codex-home.js +10 -0
- package/scripts/lib/install-targets/cursor-project.js +10 -0
- package/scripts/lib/install-targets/helpers.js +89 -0
- package/scripts/lib/install-targets/opencode-home.js +10 -0
- package/scripts/lib/install-targets/registry.js +64 -0
- package/scripts/lib/orchestration-session.js +299 -0
- package/scripts/lib/package-manager.d.ts +119 -0
- package/scripts/lib/package-manager.js +431 -0
- package/scripts/lib/project-detect.js +428 -0
- package/scripts/lib/resolve-formatter.js +185 -0
- package/scripts/lib/session-adapters/canonical-session.js +138 -0
- package/scripts/lib/session-adapters/claude-history.js +149 -0
- package/scripts/lib/session-adapters/dmux-tmux.js +80 -0
- package/scripts/lib/session-adapters/registry.js +111 -0
- package/scripts/lib/session-aliases.d.ts +136 -0
- package/scripts/lib/session-aliases.js +481 -0
- package/scripts/lib/session-manager.d.ts +131 -0
- package/scripts/lib/session-manager.js +464 -0
- package/scripts/lib/shell-split.js +86 -0
- package/scripts/lib/skill-improvement/amendify.js +89 -0
- package/scripts/lib/skill-improvement/evaluate.js +59 -0
- package/scripts/lib/skill-improvement/health.js +118 -0
- package/scripts/lib/skill-improvement/observations.js +108 -0
- package/scripts/lib/tmux-worktree-orchestrator.js +491 -0
- package/scripts/lib/utils.d.ts +183 -0
- package/scripts/lib/utils.js +543 -0
- package/scripts/list-installed.js +90 -0
- package/scripts/orchestrate-codex-worker.sh +92 -0
- package/scripts/orchestrate-worktrees.js +108 -0
- package/scripts/orchestration-status.js +62 -0
- package/scripts/repair.js +97 -0
- package/scripts/session-inspect.js +150 -0
- package/scripts/setup-package-manager.js +204 -0
- package/scripts/skill-create-output.js +244 -0
- package/scripts/uninstall.js +96 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const HEALTH_SCHEMA_VERSION = 'ecc.skill-health.v1';
|
|
4
|
+
|
|
5
|
+
function roundRate(value) {
|
|
6
|
+
return Math.round(value * 1000) / 1000;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function rankCounts(values) {
|
|
10
|
+
return Array.from(values.entries())
|
|
11
|
+
.map(([value, count]) => ({ value, count }))
|
|
12
|
+
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function summarizeVariantRuns(records) {
|
|
16
|
+
return records.reduce((accumulator, record) => {
|
|
17
|
+
const key = record.run && record.run.variant ? record.run.variant : 'baseline';
|
|
18
|
+
if (!accumulator[key]) {
|
|
19
|
+
accumulator[key] = { runs: 0, successes: 0, failures: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
accumulator[key].runs += 1;
|
|
23
|
+
if (record.outcome && record.outcome.success) {
|
|
24
|
+
accumulator[key].successes += 1;
|
|
25
|
+
} else {
|
|
26
|
+
accumulator[key].failures += 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return accumulator;
|
|
30
|
+
}, {});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function deriveSkillStatus(skillSummary, options = {}) {
|
|
34
|
+
const minFailureCount = options.minFailureCount || 2;
|
|
35
|
+
if (skillSummary.failures >= minFailureCount) {
|
|
36
|
+
return 'failing';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (skillSummary.failures > 0) {
|
|
40
|
+
return 'watch';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return 'healthy';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildSkillHealthReport(records, options = {}) {
|
|
47
|
+
const filterSkillId = options.skillId || null;
|
|
48
|
+
const filtered = filterSkillId
|
|
49
|
+
? records.filter(record => record.skill && record.skill.id === filterSkillId)
|
|
50
|
+
: records.slice();
|
|
51
|
+
|
|
52
|
+
const grouped = filtered.reduce((accumulator, record) => {
|
|
53
|
+
const skillId = record.skill.id;
|
|
54
|
+
if (!accumulator.has(skillId)) {
|
|
55
|
+
accumulator.set(skillId, []);
|
|
56
|
+
}
|
|
57
|
+
accumulator.get(skillId).push(record);
|
|
58
|
+
return accumulator;
|
|
59
|
+
}, new Map());
|
|
60
|
+
|
|
61
|
+
const skills = Array.from(grouped.entries())
|
|
62
|
+
.map(([skillId, skillRecords]) => {
|
|
63
|
+
const successes = skillRecords.filter(record => record.outcome && record.outcome.success).length;
|
|
64
|
+
const failures = skillRecords.length - successes;
|
|
65
|
+
const recurringErrors = new Map();
|
|
66
|
+
const recurringTasks = new Map();
|
|
67
|
+
const recurringFeedback = new Map();
|
|
68
|
+
|
|
69
|
+
skillRecords.forEach(record => {
|
|
70
|
+
if (!record.outcome || record.outcome.success) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (record.outcome.error) {
|
|
75
|
+
recurringErrors.set(record.outcome.error, (recurringErrors.get(record.outcome.error) || 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
if (record.task) {
|
|
78
|
+
recurringTasks.set(record.task, (recurringTasks.get(record.task) || 0) + 1);
|
|
79
|
+
}
|
|
80
|
+
if (record.outcome.feedback) {
|
|
81
|
+
recurringFeedback.set(record.outcome.feedback, (recurringFeedback.get(record.outcome.feedback) || 0) + 1);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const summary = {
|
|
86
|
+
skill: {
|
|
87
|
+
id: skillId,
|
|
88
|
+
path: skillRecords[0].skill.path || null
|
|
89
|
+
},
|
|
90
|
+
totalRuns: skillRecords.length,
|
|
91
|
+
successes,
|
|
92
|
+
failures,
|
|
93
|
+
successRate: skillRecords.length > 0 ? roundRate(successes / skillRecords.length) : 0,
|
|
94
|
+
status: 'healthy',
|
|
95
|
+
recurringErrors: rankCounts(recurringErrors).map(entry => ({ error: entry.value, count: entry.count })),
|
|
96
|
+
recurringTasks: rankCounts(recurringTasks).map(entry => ({ task: entry.value, count: entry.count })),
|
|
97
|
+
recurringFeedback: rankCounts(recurringFeedback).map(entry => ({ feedback: entry.value, count: entry.count })),
|
|
98
|
+
variants: summarizeVariantRuns(skillRecords)
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
summary.status = deriveSkillStatus(summary, options);
|
|
102
|
+
return summary;
|
|
103
|
+
})
|
|
104
|
+
.sort((left, right) => right.failures - left.failures || left.skill.id.localeCompare(right.skill.id));
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
schemaVersion: HEALTH_SCHEMA_VERSION,
|
|
108
|
+
generatedAt: new Date().toISOString(),
|
|
109
|
+
totalObservations: filtered.length,
|
|
110
|
+
skillCount: skills.length,
|
|
111
|
+
skills
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
HEALTH_SCHEMA_VERSION,
|
|
117
|
+
buildSkillHealthReport
|
|
118
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const OBSERVATION_SCHEMA_VERSION = 'ecc.skill-observation.v1';
|
|
8
|
+
|
|
9
|
+
function resolveProjectRoot(options = {}) {
|
|
10
|
+
return path.resolve(options.projectRoot || options.cwd || process.cwd());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getSkillTelemetryRoot(options = {}) {
|
|
14
|
+
return path.join(resolveProjectRoot(options), '.claude', 'ecc', 'skills');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getSkillObservationsPath(options = {}) {
|
|
18
|
+
return path.join(getSkillTelemetryRoot(options), 'observations.jsonl');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ensureString(value, label) {
|
|
22
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
23
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return value.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createObservationId() {
|
|
30
|
+
return `obs-${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createSkillObservation(input) {
|
|
34
|
+
const task = ensureString(input.task, 'task');
|
|
35
|
+
const skillId = ensureString(input.skill && input.skill.id, 'skill.id');
|
|
36
|
+
const skillPath = typeof input.skill.path === 'string' && input.skill.path.trim().length > 0
|
|
37
|
+
? input.skill.path.trim()
|
|
38
|
+
: null;
|
|
39
|
+
const success = Boolean(input.success);
|
|
40
|
+
const error = input.error == null ? null : String(input.error);
|
|
41
|
+
const feedback = input.feedback == null ? null : String(input.feedback);
|
|
42
|
+
const variant = typeof input.variant === 'string' && input.variant.trim().length > 0
|
|
43
|
+
? input.variant.trim()
|
|
44
|
+
: 'baseline';
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
schemaVersion: OBSERVATION_SCHEMA_VERSION,
|
|
48
|
+
observationId: typeof input.observationId === 'string' && input.observationId.length > 0
|
|
49
|
+
? input.observationId
|
|
50
|
+
: createObservationId(),
|
|
51
|
+
timestamp: typeof input.timestamp === 'string' && input.timestamp.length > 0
|
|
52
|
+
? input.timestamp
|
|
53
|
+
: new Date().toISOString(),
|
|
54
|
+
task,
|
|
55
|
+
skill: {
|
|
56
|
+
id: skillId,
|
|
57
|
+
path: skillPath
|
|
58
|
+
},
|
|
59
|
+
outcome: {
|
|
60
|
+
success,
|
|
61
|
+
status: success ? 'success' : 'failure',
|
|
62
|
+
error,
|
|
63
|
+
feedback
|
|
64
|
+
},
|
|
65
|
+
run: {
|
|
66
|
+
variant,
|
|
67
|
+
amendmentId: input.amendmentId || null,
|
|
68
|
+
sessionId: input.sessionId || null,
|
|
69
|
+
source: input.source || 'manual'
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function appendSkillObservation(observation, options = {}) {
|
|
75
|
+
const outputPath = getSkillObservationsPath(options);
|
|
76
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
77
|
+
fs.appendFileSync(outputPath, `${JSON.stringify(observation)}${os.EOL}`, 'utf8');
|
|
78
|
+
return outputPath;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readSkillObservations(options = {}) {
|
|
82
|
+
const observationPath = path.resolve(options.observationsPath || getSkillObservationsPath(options));
|
|
83
|
+
if (!fs.existsSync(observationPath)) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return fs.readFileSync(observationPath, 'utf8')
|
|
88
|
+
.split(/\r?\n/)
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map(line => {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(line);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
.filter(record => record && record.schemaVersion === OBSERVATION_SCHEMA_VERSION);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
OBSERVATION_SCHEMA_VERSION,
|
|
102
|
+
appendSkillObservation,
|
|
103
|
+
createSkillObservation,
|
|
104
|
+
getSkillObservationsPath,
|
|
105
|
+
getSkillTelemetryRoot,
|
|
106
|
+
readSkillObservations,
|
|
107
|
+
resolveProjectRoot
|
|
108
|
+
};
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
function slugify(value, fallback = 'worker') {
|
|
8
|
+
const normalized = String(value || '')
|
|
9
|
+
.trim()
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
12
|
+
.replace(/^-+|-+$/g, '');
|
|
13
|
+
return normalized || fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function renderTemplate(template, variables) {
|
|
17
|
+
if (typeof template !== 'string' || template.trim().length === 0) {
|
|
18
|
+
throw new Error('launcherCommand must be a non-empty string');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return template.replace(/\{([a-z_]+)\}/g, (match, key) => {
|
|
22
|
+
if (!(key in variables)) {
|
|
23
|
+
throw new Error(`Unknown template variable: ${key}`);
|
|
24
|
+
}
|
|
25
|
+
return String(variables[key]);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function shellQuote(value) {
|
|
30
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatCommand(program, args) {
|
|
34
|
+
return [program, ...args.map(shellQuote)].join(' ');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeSeedPaths(seedPaths, repoRoot) {
|
|
38
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
39
|
+
const entries = Array.isArray(seedPaths) ? seedPaths : [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const normalized = [];
|
|
42
|
+
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const absolutePath = path.resolve(resolvedRepoRoot, entry);
|
|
49
|
+
const relativePath = path.relative(resolvedRepoRoot, absolutePath);
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
relativePath.startsWith('..') ||
|
|
53
|
+
path.isAbsolute(relativePath)
|
|
54
|
+
) {
|
|
55
|
+
throw new Error(`seedPaths entries must stay inside repoRoot: ${entry}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const normalizedPath = relativePath.split(path.sep).join('/');
|
|
59
|
+
if (seen.has(normalizedPath)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
seen.add(normalizedPath);
|
|
64
|
+
normalized.push(normalizedPath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function overlaySeedPaths({ repoRoot, seedPaths, worktreePath }) {
|
|
71
|
+
const normalizedSeedPaths = normalizeSeedPaths(seedPaths, repoRoot);
|
|
72
|
+
|
|
73
|
+
for (const seedPath of normalizedSeedPaths) {
|
|
74
|
+
const sourcePath = path.join(repoRoot, seedPath);
|
|
75
|
+
const destinationPath = path.join(worktreePath, seedPath);
|
|
76
|
+
|
|
77
|
+
if (!fs.existsSync(sourcePath)) {
|
|
78
|
+
throw new Error(`Seed path does not exist in repoRoot: ${seedPath}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
82
|
+
fs.rmSync(destinationPath, { force: true, recursive: true });
|
|
83
|
+
fs.cpSync(sourcePath, destinationPath, {
|
|
84
|
+
dereference: false,
|
|
85
|
+
force: true,
|
|
86
|
+
preserveTimestamps: true,
|
|
87
|
+
recursive: true
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildWorkerArtifacts(workerPlan) {
|
|
93
|
+
const seededPathsSection = workerPlan.seedPaths.length > 0
|
|
94
|
+
? [
|
|
95
|
+
'',
|
|
96
|
+
'## Seeded Local Overlays',
|
|
97
|
+
...workerPlan.seedPaths.map(seedPath => `- \`${seedPath}\``)
|
|
98
|
+
]
|
|
99
|
+
: [];
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
dir: workerPlan.coordinationDir,
|
|
103
|
+
files: [
|
|
104
|
+
{
|
|
105
|
+
path: workerPlan.taskFilePath,
|
|
106
|
+
content: [
|
|
107
|
+
`# Worker Task: ${workerPlan.workerName}`,
|
|
108
|
+
'',
|
|
109
|
+
`- Session: \`${workerPlan.sessionName}\``,
|
|
110
|
+
`- Repo root: \`${workerPlan.repoRoot}\``,
|
|
111
|
+
`- Worktree: \`${workerPlan.worktreePath}\``,
|
|
112
|
+
`- Branch: \`${workerPlan.branchName}\``,
|
|
113
|
+
`- Launcher status file: \`${workerPlan.statusFilePath}\``,
|
|
114
|
+
`- Launcher handoff file: \`${workerPlan.handoffFilePath}\``,
|
|
115
|
+
...seededPathsSection,
|
|
116
|
+
'',
|
|
117
|
+
'## Objective',
|
|
118
|
+
workerPlan.task,
|
|
119
|
+
'',
|
|
120
|
+
'## Completion',
|
|
121
|
+
'Do not spawn subagents or external agents for this task.',
|
|
122
|
+
'Report results in your final response.',
|
|
123
|
+
`The worker launcher captures your response in \`${workerPlan.handoffFilePath}\` automatically.`,
|
|
124
|
+
`The worker launcher updates \`${workerPlan.statusFilePath}\` automatically.`
|
|
125
|
+
].join('\n')
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
path: workerPlan.handoffFilePath,
|
|
129
|
+
content: [
|
|
130
|
+
`# Handoff: ${workerPlan.workerName}`,
|
|
131
|
+
'',
|
|
132
|
+
'## Summary',
|
|
133
|
+
'- Pending',
|
|
134
|
+
'',
|
|
135
|
+
'## Files Changed',
|
|
136
|
+
'- Pending',
|
|
137
|
+
'',
|
|
138
|
+
'## Tests / Verification',
|
|
139
|
+
'- Pending',
|
|
140
|
+
'',
|
|
141
|
+
'## Follow-ups',
|
|
142
|
+
'- Pending'
|
|
143
|
+
].join('\n')
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
path: workerPlan.statusFilePath,
|
|
147
|
+
content: [
|
|
148
|
+
`# Status: ${workerPlan.workerName}`,
|
|
149
|
+
'',
|
|
150
|
+
'- State: not started',
|
|
151
|
+
`- Worktree: \`${workerPlan.worktreePath}\``,
|
|
152
|
+
`- Branch: \`${workerPlan.branchName}\``
|
|
153
|
+
].join('\n')
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildOrchestrationPlan(config = {}) {
|
|
160
|
+
const repoRoot = path.resolve(config.repoRoot || process.cwd());
|
|
161
|
+
const repoName = path.basename(repoRoot);
|
|
162
|
+
const workers = Array.isArray(config.workers) ? config.workers : [];
|
|
163
|
+
const globalSeedPaths = normalizeSeedPaths(config.seedPaths, repoRoot);
|
|
164
|
+
const sessionName = slugify(config.sessionName || repoName, 'session');
|
|
165
|
+
const worktreeRoot = path.resolve(config.worktreeRoot || path.dirname(repoRoot));
|
|
166
|
+
const coordinationRoot = path.resolve(
|
|
167
|
+
config.coordinationRoot || path.join(repoRoot, '.orchestration')
|
|
168
|
+
);
|
|
169
|
+
const coordinationDir = path.join(coordinationRoot, sessionName);
|
|
170
|
+
const baseRef = config.baseRef || 'HEAD';
|
|
171
|
+
const defaultLauncher = config.launcherCommand || '';
|
|
172
|
+
|
|
173
|
+
if (workers.length === 0) {
|
|
174
|
+
throw new Error('buildOrchestrationPlan requires at least one worker');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const workerPlans = workers.map((worker, index) => {
|
|
178
|
+
if (!worker || typeof worker.task !== 'string' || worker.task.trim().length === 0) {
|
|
179
|
+
throw new Error(`Worker ${index + 1} is missing a task`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const workerName = worker.name || `worker-${index + 1}`;
|
|
183
|
+
const workerSlug = slugify(workerName, `worker-${index + 1}`);
|
|
184
|
+
const branchName = `orchestrator-${sessionName}-${workerSlug}`;
|
|
185
|
+
const worktreePath = path.join(worktreeRoot, `${repoName}-${sessionName}-${workerSlug}`);
|
|
186
|
+
const workerCoordinationDir = path.join(coordinationDir, workerSlug);
|
|
187
|
+
const taskFilePath = path.join(workerCoordinationDir, 'task.md');
|
|
188
|
+
const handoffFilePath = path.join(workerCoordinationDir, 'handoff.md');
|
|
189
|
+
const statusFilePath = path.join(workerCoordinationDir, 'status.md');
|
|
190
|
+
const launcherCommand = worker.launcherCommand || defaultLauncher;
|
|
191
|
+
const workerSeedPaths = normalizeSeedPaths(worker.seedPaths, repoRoot);
|
|
192
|
+
const seedPaths = normalizeSeedPaths([...globalSeedPaths, ...workerSeedPaths], repoRoot);
|
|
193
|
+
const templateVariables = {
|
|
194
|
+
branch_name: branchName,
|
|
195
|
+
handoff_file: handoffFilePath,
|
|
196
|
+
repo_root: repoRoot,
|
|
197
|
+
session_name: sessionName,
|
|
198
|
+
status_file: statusFilePath,
|
|
199
|
+
task_file: taskFilePath,
|
|
200
|
+
worker_name: workerName,
|
|
201
|
+
worker_slug: workerSlug,
|
|
202
|
+
worktree_path: worktreePath
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (!launcherCommand) {
|
|
206
|
+
throw new Error(`Worker ${workerName} is missing a launcherCommand`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const gitArgs = ['worktree', 'add', '-b', branchName, worktreePath, baseRef];
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
branchName,
|
|
213
|
+
coordinationDir: workerCoordinationDir,
|
|
214
|
+
gitArgs,
|
|
215
|
+
gitCommand: formatCommand('git', gitArgs),
|
|
216
|
+
handoffFilePath,
|
|
217
|
+
launchCommand: renderTemplate(launcherCommand, templateVariables),
|
|
218
|
+
repoRoot,
|
|
219
|
+
sessionName,
|
|
220
|
+
seedPaths,
|
|
221
|
+
statusFilePath,
|
|
222
|
+
task: worker.task.trim(),
|
|
223
|
+
taskFilePath,
|
|
224
|
+
workerName,
|
|
225
|
+
workerSlug,
|
|
226
|
+
worktreePath
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const tmuxCommands = [
|
|
231
|
+
{
|
|
232
|
+
cmd: 'tmux',
|
|
233
|
+
args: ['new-session', '-d', '-s', sessionName, '-n', 'orchestrator', '-c', repoRoot],
|
|
234
|
+
description: 'Create detached tmux session'
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
cmd: 'tmux',
|
|
238
|
+
args: [
|
|
239
|
+
'send-keys',
|
|
240
|
+
'-t',
|
|
241
|
+
sessionName,
|
|
242
|
+
`printf '%s\\n' 'Session: ${sessionName}' 'Coordination: ${coordinationDir}'`,
|
|
243
|
+
'C-m'
|
|
244
|
+
],
|
|
245
|
+
description: 'Print orchestrator session details'
|
|
246
|
+
}
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
for (const workerPlan of workerPlans) {
|
|
250
|
+
tmuxCommands.push(
|
|
251
|
+
{
|
|
252
|
+
cmd: 'tmux',
|
|
253
|
+
args: ['split-window', '-d', '-t', sessionName, '-c', workerPlan.worktreePath],
|
|
254
|
+
description: `Create pane for ${workerPlan.workerName}`
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
cmd: 'tmux',
|
|
258
|
+
args: ['select-layout', '-t', sessionName, 'tiled'],
|
|
259
|
+
description: 'Arrange panes in tiled layout'
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
cmd: 'tmux',
|
|
263
|
+
args: ['select-pane', '-t', '<pane-id>', '-T', workerPlan.workerSlug],
|
|
264
|
+
description: `Label pane ${workerPlan.workerSlug}`
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
cmd: 'tmux',
|
|
268
|
+
args: [
|
|
269
|
+
'send-keys',
|
|
270
|
+
'-t',
|
|
271
|
+
'<pane-id>',
|
|
272
|
+
`cd ${shellQuote(workerPlan.worktreePath)} && ${workerPlan.launchCommand}`,
|
|
273
|
+
'C-m'
|
|
274
|
+
],
|
|
275
|
+
description: `Launch worker ${workerPlan.workerName}`
|
|
276
|
+
}
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
baseRef,
|
|
282
|
+
coordinationDir,
|
|
283
|
+
replaceExisting: Boolean(config.replaceExisting),
|
|
284
|
+
repoRoot,
|
|
285
|
+
sessionName,
|
|
286
|
+
tmuxCommands,
|
|
287
|
+
workerPlans
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function materializePlan(plan) {
|
|
292
|
+
for (const workerPlan of plan.workerPlans) {
|
|
293
|
+
const artifacts = buildWorkerArtifacts(workerPlan);
|
|
294
|
+
fs.mkdirSync(artifacts.dir, { recursive: true });
|
|
295
|
+
for (const file of artifacts.files) {
|
|
296
|
+
fs.writeFileSync(file.path, file.content + '\n', 'utf8');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function runCommand(program, args, options = {}) {
|
|
302
|
+
const result = spawnSync(program, args, {
|
|
303
|
+
cwd: options.cwd,
|
|
304
|
+
encoding: 'utf8',
|
|
305
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (result.error) {
|
|
309
|
+
throw result.error;
|
|
310
|
+
}
|
|
311
|
+
if (result.status !== 0) {
|
|
312
|
+
const stderr = (result.stderr || '').trim();
|
|
313
|
+
throw new Error(`${program} ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`);
|
|
314
|
+
}
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function commandSucceeds(program, args, options = {}) {
|
|
319
|
+
const result = spawnSync(program, args, {
|
|
320
|
+
cwd: options.cwd,
|
|
321
|
+
encoding: 'utf8',
|
|
322
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
323
|
+
});
|
|
324
|
+
return result.status === 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function canonicalizePath(targetPath) {
|
|
328
|
+
const resolvedPath = path.resolve(targetPath);
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
return fs.realpathSync.native(resolvedPath);
|
|
332
|
+
} catch (_error) {
|
|
333
|
+
const parentPath = path.dirname(resolvedPath);
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
return path.join(fs.realpathSync.native(parentPath), path.basename(resolvedPath));
|
|
337
|
+
} catch (_parentError) {
|
|
338
|
+
return resolvedPath;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function branchExists(repoRoot, branchName) {
|
|
344
|
+
return commandSucceeds('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], {
|
|
345
|
+
cwd: repoRoot
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function listWorktrees(repoRoot) {
|
|
350
|
+
const listed = runCommand('git', ['worktree', 'list', '--porcelain'], { cwd: repoRoot });
|
|
351
|
+
const lines = (listed.stdout || '').split('\n');
|
|
352
|
+
const worktrees = [];
|
|
353
|
+
|
|
354
|
+
for (const line of lines) {
|
|
355
|
+
if (line.startsWith('worktree ')) {
|
|
356
|
+
const listedPath = line.slice('worktree '.length).trim();
|
|
357
|
+
worktrees.push({
|
|
358
|
+
listedPath,
|
|
359
|
+
canonicalPath: canonicalizePath(listedPath)
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return worktrees;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function cleanupExisting(plan) {
|
|
368
|
+
runCommand('git', ['worktree', 'prune', '--expire', 'now'], { cwd: plan.repoRoot });
|
|
369
|
+
|
|
370
|
+
const hasSession = spawnSync('tmux', ['has-session', '-t', plan.sessionName], {
|
|
371
|
+
encoding: 'utf8',
|
|
372
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (hasSession.status === 0) {
|
|
376
|
+
runCommand('tmux', ['kill-session', '-t', plan.sessionName], { cwd: plan.repoRoot });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const workerPlan of plan.workerPlans) {
|
|
380
|
+
const expectedWorktreePath = canonicalizePath(workerPlan.worktreePath);
|
|
381
|
+
const existingWorktree = listWorktrees(plan.repoRoot).find(
|
|
382
|
+
worktree => worktree.canonicalPath === expectedWorktreePath
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
if (existingWorktree) {
|
|
386
|
+
runCommand('git', ['worktree', 'remove', '--force', existingWorktree.listedPath], {
|
|
387
|
+
cwd: plan.repoRoot
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (fs.existsSync(workerPlan.worktreePath)) {
|
|
392
|
+
fs.rmSync(workerPlan.worktreePath, { force: true, recursive: true });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
runCommand('git', ['worktree', 'prune', '--expire', 'now'], { cwd: plan.repoRoot });
|
|
396
|
+
|
|
397
|
+
if (branchExists(plan.repoRoot, workerPlan.branchName)) {
|
|
398
|
+
runCommand('git', ['branch', '-D', workerPlan.branchName], { cwd: plan.repoRoot });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function executePlan(plan) {
|
|
404
|
+
runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd: plan.repoRoot });
|
|
405
|
+
runCommand('tmux', ['-V']);
|
|
406
|
+
|
|
407
|
+
if (plan.replaceExisting) {
|
|
408
|
+
cleanupExisting(plan);
|
|
409
|
+
} else {
|
|
410
|
+
const hasSession = spawnSync('tmux', ['has-session', '-t', plan.sessionName], {
|
|
411
|
+
encoding: 'utf8',
|
|
412
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
413
|
+
});
|
|
414
|
+
if (hasSession.status === 0) {
|
|
415
|
+
throw new Error(`tmux session already exists: ${plan.sessionName}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
materializePlan(plan);
|
|
420
|
+
|
|
421
|
+
for (const workerPlan of plan.workerPlans) {
|
|
422
|
+
runCommand('git', workerPlan.gitArgs, { cwd: plan.repoRoot });
|
|
423
|
+
overlaySeedPaths({
|
|
424
|
+
repoRoot: plan.repoRoot,
|
|
425
|
+
seedPaths: workerPlan.seedPaths,
|
|
426
|
+
worktreePath: workerPlan.worktreePath
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
runCommand(
|
|
431
|
+
'tmux',
|
|
432
|
+
['new-session', '-d', '-s', plan.sessionName, '-n', 'orchestrator', '-c', plan.repoRoot],
|
|
433
|
+
{ cwd: plan.repoRoot }
|
|
434
|
+
);
|
|
435
|
+
runCommand(
|
|
436
|
+
'tmux',
|
|
437
|
+
[
|
|
438
|
+
'send-keys',
|
|
439
|
+
'-t',
|
|
440
|
+
plan.sessionName,
|
|
441
|
+
`printf '%s\\n' 'Session: ${plan.sessionName}' 'Coordination: ${plan.coordinationDir}'`,
|
|
442
|
+
'C-m'
|
|
443
|
+
],
|
|
444
|
+
{ cwd: plan.repoRoot }
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
for (const workerPlan of plan.workerPlans) {
|
|
448
|
+
const splitResult = runCommand(
|
|
449
|
+
'tmux',
|
|
450
|
+
['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', plan.sessionName, '-c', workerPlan.worktreePath],
|
|
451
|
+
{ cwd: plan.repoRoot }
|
|
452
|
+
);
|
|
453
|
+
const paneId = splitResult.stdout.trim();
|
|
454
|
+
|
|
455
|
+
if (!paneId) {
|
|
456
|
+
throw new Error(`tmux split-window did not return a pane id for ${workerPlan.workerName}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
runCommand('tmux', ['select-layout', '-t', plan.sessionName, 'tiled'], { cwd: plan.repoRoot });
|
|
460
|
+
runCommand('tmux', ['select-pane', '-t', paneId, '-T', workerPlan.workerSlug], {
|
|
461
|
+
cwd: plan.repoRoot
|
|
462
|
+
});
|
|
463
|
+
runCommand(
|
|
464
|
+
'tmux',
|
|
465
|
+
[
|
|
466
|
+
'send-keys',
|
|
467
|
+
'-t',
|
|
468
|
+
paneId,
|
|
469
|
+
`cd ${shellQuote(workerPlan.worktreePath)} && ${workerPlan.launchCommand}`,
|
|
470
|
+
'C-m'
|
|
471
|
+
],
|
|
472
|
+
{ cwd: plan.repoRoot }
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
coordinationDir: plan.coordinationDir,
|
|
478
|
+
sessionName: plan.sessionName,
|
|
479
|
+
workerCount: plan.workerPlans.length
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
module.exports = {
|
|
484
|
+
buildOrchestrationPlan,
|
|
485
|
+
executePlan,
|
|
486
|
+
materializePlan,
|
|
487
|
+
normalizeSeedPaths,
|
|
488
|
+
overlaySeedPaths,
|
|
489
|
+
renderTemplate,
|
|
490
|
+
slugify
|
|
491
|
+
};
|