legion-cc 0.1.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/LICENSE +21 -0
- package/README.md +269 -0
- package/VERSION +1 -0
- package/agents/legion-orchestrator.md +95 -0
- package/bin/install.js +898 -0
- package/bin/legion-tools.cjs +421 -0
- package/bin/lib/config.cjs +141 -0
- package/bin/lib/core.cjs +216 -0
- package/bin/lib/domain.cjs +107 -0
- package/bin/lib/init.cjs +184 -0
- package/bin/lib/session.cjs +140 -0
- package/bin/lib/state.cjs +280 -0
- package/commands/legion/devops/architect.md +44 -0
- package/commands/legion/devops/build.md +52 -0
- package/commands/legion/devops/cycle.md +52 -0
- package/commands/legion/devops/execute.md +52 -0
- package/commands/legion/devops/plan.md +51 -0
- package/commands/legion/devops/quick.md +45 -0
- package/commands/legion/devops/review.md +52 -0
- package/commands/legion/resume.md +52 -0
- package/commands/legion/status.md +53 -0
- package/hooks/legion-context-monitor.js +180 -0
- package/hooks/legion-statusline.js +191 -0
- package/package.json +48 -0
- package/references/agent-routing.md +64 -0
- package/references/devops/agent-map.md +61 -0
- package/references/devops/pipeline-patterns.md +87 -0
- package/references/domain-registry.md +63 -0
- package/references/ui-brand.md +102 -0
- package/templates/config.json +25 -0
- package/templates/devops/architect-output.md +28 -0
- package/templates/devops/execution-report.md +23 -0
- package/templates/devops/plan-output.md +33 -0
- package/templates/devops/review-checklist.md +35 -0
- package/templates/session.md +17 -0
- package/templates/state.md +17 -0
- package/templates/task-record.md +19 -0
- package/workflows/core/completion.md +70 -0
- package/workflows/core/context-load.md +57 -0
- package/workflows/core/init.md +52 -0
- package/workflows/devops/architect.md +91 -0
- package/workflows/devops/build.md +92 -0
- package/workflows/devops/cycle.md +237 -0
- package/workflows/devops/execute.md +118 -0
- package/workflows/devops/plan.md +108 -0
- package/workflows/devops/quick.md +107 -0
- package/workflows/devops/review.md +112 -0
- package/workflows/resume.md +88 -0
- package/workflows/status.md +72 -0
package/bin/lib/core.cjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const LEGION_HOME = path.join(os.homedir(), '.claude', 'legion');
|
|
11
|
+
const AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
|
|
12
|
+
|
|
13
|
+
const UI = {
|
|
14
|
+
check: '\u2713',
|
|
15
|
+
cross: '\u2717',
|
|
16
|
+
diamond: '\u25C6',
|
|
17
|
+
circle: '\u25CB',
|
|
18
|
+
warn: '\u26A0',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const BANNER_WIDTH = 39;
|
|
22
|
+
const BANNER_LINE = '\u2501'.repeat(BANNER_WIDTH);
|
|
23
|
+
|
|
24
|
+
// ─── Slug Generation ─────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert arbitrary text to a URL/file-safe slug.
|
|
28
|
+
* Lowercase, hyphens for separators, max 40 chars, no trailing hyphens.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} text - Input text
|
|
31
|
+
* @returns {string} Slug
|
|
32
|
+
*/
|
|
33
|
+
function generateSlug(text) {
|
|
34
|
+
if (!text || typeof text !== 'string') return '';
|
|
35
|
+
|
|
36
|
+
return text
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.trim()
|
|
39
|
+
.replace(/[^a-z0-9\s-]/g, '') // strip non-alphanumeric (keep spaces & hyphens)
|
|
40
|
+
.replace(/[\s-]+/g, '-') // collapse whitespace/hyphens into single hyphen
|
|
41
|
+
.replace(/^-+|-+$/g, '') // trim leading/trailing hyphens
|
|
42
|
+
.slice(0, 40)
|
|
43
|
+
.replace(/-+$/, ''); // re-trim if slice cut mid-word leaving trailing hyphen
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Timestamp ───────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Return the current timestamp in the requested format.
|
|
50
|
+
*
|
|
51
|
+
* @param {'iso'|'date'|'datetime'} [format='iso'] - Output format
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function currentTimestamp(format) {
|
|
55
|
+
const now = new Date();
|
|
56
|
+
switch ((format || 'iso').toLowerCase()) {
|
|
57
|
+
case 'date':
|
|
58
|
+
return now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
59
|
+
case 'datetime':
|
|
60
|
+
return now.toISOString().slice(0, 19).replace('T', ' ');
|
|
61
|
+
case 'iso':
|
|
62
|
+
default:
|
|
63
|
+
return now.toISOString();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Directory Resolution ────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Walk up from `startDir` looking for a directory that matches `target`.
|
|
71
|
+
* Returns the absolute path to the found directory, or null.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} target - Relative directory name to locate (e.g. '.planning/legion')
|
|
74
|
+
* @param {string} [startDir=process.cwd()] - Where to start searching
|
|
75
|
+
* @returns {string|null}
|
|
76
|
+
*/
|
|
77
|
+
function _walkUp(target, startDir) {
|
|
78
|
+
let dir = path.resolve(startDir || process.cwd());
|
|
79
|
+
const root = path.parse(dir).root;
|
|
80
|
+
|
|
81
|
+
while (true) {
|
|
82
|
+
const candidate = path.join(dir, target);
|
|
83
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
if (dir === root) return null;
|
|
87
|
+
dir = path.dirname(dir);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find `.planning/legion/` by walking up from cwd.
|
|
93
|
+
* @param {string} [startDir]
|
|
94
|
+
* @returns {string|null}
|
|
95
|
+
*/
|
|
96
|
+
function resolvePlanningDir(startDir) {
|
|
97
|
+
return _walkUp(path.join('.planning', 'legion'), startDir);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Find `.codebase/` by walking up from cwd.
|
|
102
|
+
* @param {string} [startDir]
|
|
103
|
+
* @returns {string|null}
|
|
104
|
+
*/
|
|
105
|
+
function resolveCodebaseDir(startDir) {
|
|
106
|
+
return _walkUp('.codebase', startDir);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Task Numbering ──────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Scan existing artifact files in `planningDir/artifacts/` and return the next
|
|
113
|
+
* zero-padded three-digit number (e.g. "002").
|
|
114
|
+
*
|
|
115
|
+
* @param {string} planningDir - Absolute path to `.planning/legion/`
|
|
116
|
+
* @returns {string} Next task number, zero-padded to 3 digits
|
|
117
|
+
*/
|
|
118
|
+
function nextTaskNum(planningDir) {
|
|
119
|
+
const artifactsDir = path.join(planningDir, 'artifacts');
|
|
120
|
+
if (!fs.existsSync(artifactsDir)) {
|
|
121
|
+
return '001';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let max = 0;
|
|
125
|
+
const entries = fs.readdirSync(artifactsDir);
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
// Match patterns like "001-architect-plan.md", "012-review.json", etc.
|
|
128
|
+
const match = entry.match(/^(\d{3})-/);
|
|
129
|
+
if (match) {
|
|
130
|
+
const num = parseInt(match[1], 10);
|
|
131
|
+
if (num > max) max = num;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const next = max + 1;
|
|
136
|
+
return String(next).padStart(3, '0');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── JSON Helpers ────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Safely read and parse a JSON file. Returns `fallback` on any error.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} filePath - Absolute path to JSON file
|
|
145
|
+
* @param {*} [fallback=null] - Value to return on failure
|
|
146
|
+
* @returns {*}
|
|
147
|
+
*/
|
|
148
|
+
function readJsonSafe(filePath, fallback) {
|
|
149
|
+
if (fallback === undefined) fallback = null;
|
|
150
|
+
try {
|
|
151
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
152
|
+
return JSON.parse(raw);
|
|
153
|
+
} catch (_err) {
|
|
154
|
+
return fallback;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Write data as JSON atomically (write to tmp then rename).
|
|
160
|
+
*
|
|
161
|
+
* @param {string} filePath - Destination path
|
|
162
|
+
* @param {*} data - JSON-serializable data
|
|
163
|
+
*/
|
|
164
|
+
function writeJsonSafe(filePath, data) {
|
|
165
|
+
const dir = path.dirname(filePath);
|
|
166
|
+
if (!fs.existsSync(dir)) {
|
|
167
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const tmpSuffix = crypto.randomBytes(6).toString('hex');
|
|
171
|
+
const tmpPath = filePath + '.tmp.' + tmpSuffix;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
175
|
+
fs.renameSync(tmpPath, filePath);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// Clean up temp file on failure
|
|
178
|
+
try { fs.unlinkSync(tmpPath); } catch (_e) { /* ignore */ }
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Banner ──────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Print a formatted Legion banner to stdout.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} domain - Domain name (e.g. "devops")
|
|
189
|
+
* @param {string} action - Current action (e.g. "architect")
|
|
190
|
+
*/
|
|
191
|
+
function banner(domain, action) {
|
|
192
|
+
const parts = ['LEGION'];
|
|
193
|
+
if (domain) parts.push(domain.toUpperCase());
|
|
194
|
+
if (action) parts.push(action.toUpperCase());
|
|
195
|
+
const title = ' ' + parts.join(' \u25B6 ');
|
|
196
|
+
|
|
197
|
+
console.log(BANNER_LINE);
|
|
198
|
+
console.log(title);
|
|
199
|
+
console.log(BANNER_LINE);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
LEGION_HOME,
|
|
206
|
+
AGENTS_DIR,
|
|
207
|
+
UI,
|
|
208
|
+
generateSlug,
|
|
209
|
+
currentTimestamp,
|
|
210
|
+
resolvePlanningDir,
|
|
211
|
+
resolveCodebaseDir,
|
|
212
|
+
nextTaskNum,
|
|
213
|
+
readJsonSafe,
|
|
214
|
+
writeJsonSafe,
|
|
215
|
+
banner,
|
|
216
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { LEGION_HOME, readJsonSafe } = require('./core.cjs');
|
|
6
|
+
const { DOMAIN_DEFAULTS, knownDomains } = require('./config.cjs');
|
|
7
|
+
|
|
8
|
+
// ─── Domain Registry ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Scan for installed domains.
|
|
12
|
+
*
|
|
13
|
+
* Checks two sources:
|
|
14
|
+
* 1. Built-in domain defaults (always available)
|
|
15
|
+
* 2. Custom domain definitions in `~/.claude/legion/domains/`
|
|
16
|
+
*
|
|
17
|
+
* @returns {object[]} Array of { name, source, hasConfig }
|
|
18
|
+
*/
|
|
19
|
+
function listDomains() {
|
|
20
|
+
const domains = [];
|
|
21
|
+
|
|
22
|
+
// Built-in domains
|
|
23
|
+
const builtIn = knownDomains();
|
|
24
|
+
for (const name of builtIn) {
|
|
25
|
+
domains.push({
|
|
26
|
+
name: name,
|
|
27
|
+
source: 'built-in',
|
|
28
|
+
hasConfig: true,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Custom domains from LEGION_HOME/domains/
|
|
33
|
+
const customDir = path.join(LEGION_HOME, 'domains');
|
|
34
|
+
if (fs.existsSync(customDir)) {
|
|
35
|
+
try {
|
|
36
|
+
const entries = fs.readdirSync(customDir, { withFileTypes: true });
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
const domainName = entry.name;
|
|
40
|
+
// Skip if already registered as built-in
|
|
41
|
+
if (builtIn.includes(domainName)) continue;
|
|
42
|
+
|
|
43
|
+
const configPath = path.join(customDir, domainName, 'config.json');
|
|
44
|
+
const hasConfig = fs.existsSync(configPath);
|
|
45
|
+
|
|
46
|
+
domains.push({
|
|
47
|
+
name: domainName,
|
|
48
|
+
source: 'custom',
|
|
49
|
+
hasConfig: hasConfig,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (_err) {
|
|
54
|
+
// Silently skip if can't read directory
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return domains;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get detailed information about a specific domain.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} domain - Domain name
|
|
65
|
+
* @returns {object|null} Domain info including agents, pipeline, models, etc.
|
|
66
|
+
*/
|
|
67
|
+
function getDomainInfo(domain) {
|
|
68
|
+
const name = (domain || '').toLowerCase();
|
|
69
|
+
|
|
70
|
+
// Check built-in first
|
|
71
|
+
if (DOMAIN_DEFAULTS[name]) {
|
|
72
|
+
const config = JSON.parse(JSON.stringify(DOMAIN_DEFAULTS[name]));
|
|
73
|
+
return {
|
|
74
|
+
name: name,
|
|
75
|
+
source: 'built-in',
|
|
76
|
+
version: config.version,
|
|
77
|
+
agents: config.agents,
|
|
78
|
+
models: config.models,
|
|
79
|
+
pipeline: config.pipeline,
|
|
80
|
+
checkpoints: config.checkpoints,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check custom domains
|
|
85
|
+
const customConfigPath = path.join(LEGION_HOME, 'domains', name, 'config.json');
|
|
86
|
+
const customConfig = readJsonSafe(customConfigPath, null);
|
|
87
|
+
if (customConfig) {
|
|
88
|
+
return {
|
|
89
|
+
name: name,
|
|
90
|
+
source: 'custom',
|
|
91
|
+
version: customConfig.version || '0.0.0',
|
|
92
|
+
agents: customConfig.agents || {},
|
|
93
|
+
models: customConfig.models || {},
|
|
94
|
+
pipeline: customConfig.pipeline || [],
|
|
95
|
+
checkpoints: customConfig.checkpoints || {},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
listDomains,
|
|
106
|
+
getDomainInfo,
|
|
107
|
+
};
|
package/bin/lib/init.cjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
resolvePlanningDir,
|
|
7
|
+
resolveCodebaseDir,
|
|
8
|
+
currentTimestamp,
|
|
9
|
+
nextTaskNum,
|
|
10
|
+
banner,
|
|
11
|
+
} = require('./core.cjs');
|
|
12
|
+
const { loadConfig, defaultConfig, saveConfig } = require('./config.cjs');
|
|
13
|
+
const { loadState, initState } = require('./state.cjs');
|
|
14
|
+
|
|
15
|
+
// ─── Helper: Scan Directory Recursively ──────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Recursively list files under `dir`, returning paths relative to `base`.
|
|
19
|
+
* Limits depth to 4 and total files to 200 for safety.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} dir - Directory to scan
|
|
22
|
+
* @param {string} base - Base for relative paths
|
|
23
|
+
* @param {number} [depth=0]
|
|
24
|
+
* @param {string[]} [results=[]]
|
|
25
|
+
* @returns {string[]}
|
|
26
|
+
*/
|
|
27
|
+
function _scanDir(dir, base, depth, results) {
|
|
28
|
+
if (depth === undefined) depth = 0;
|
|
29
|
+
if (results === undefined) results = [];
|
|
30
|
+
if (depth > 4 || results.length >= 200) return results;
|
|
31
|
+
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
35
|
+
} catch (_err) {
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (results.length >= 200) break;
|
|
41
|
+
|
|
42
|
+
const fullPath = path.join(dir, entry.name);
|
|
43
|
+
const relPath = path.relative(base, fullPath);
|
|
44
|
+
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
// Skip hidden dirs and node_modules
|
|
47
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
48
|
+
_scanDir(fullPath, base, depth + 1, results);
|
|
49
|
+
} else {
|
|
50
|
+
results.push(relPath);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Init Workflow ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Initialize a workflow and return a full context JSON blob.
|
|
61
|
+
*
|
|
62
|
+
* - Detects `.codebase/` and its contents
|
|
63
|
+
* - Detects `.planning/codebase/`
|
|
64
|
+
* - Ensures `.planning/legion/` exists
|
|
65
|
+
* - Loads or creates STATE.md
|
|
66
|
+
* - Loads or creates config.json
|
|
67
|
+
*
|
|
68
|
+
* @param {string} type - Workflow type / domain (e.g. "devops", "backend")
|
|
69
|
+
* @param {string[]} [args=[]] - Additional arguments (unused for now, reserved)
|
|
70
|
+
* @returns {object} Context blob
|
|
71
|
+
*/
|
|
72
|
+
function initWorkflow(type, args) {
|
|
73
|
+
const domain = (type || 'devops').toLowerCase();
|
|
74
|
+
const cwd = process.cwd();
|
|
75
|
+
|
|
76
|
+
// ── Detect .codebase/ ──────────────────────────────────────────────────
|
|
77
|
+
const codebaseDir = resolveCodebaseDir(cwd);
|
|
78
|
+
const codebaseExists = codebaseDir !== null;
|
|
79
|
+
let codebaseFiles = [];
|
|
80
|
+
if (codebaseExists) {
|
|
81
|
+
codebaseFiles = _scanDir(codebaseDir, codebaseDir);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Detect project root (the parent of .codebase or .planning) ─────────
|
|
85
|
+
let projectRoot = cwd;
|
|
86
|
+
if (codebaseDir) {
|
|
87
|
+
projectRoot = path.dirname(codebaseDir);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Detect .planning/codebase/ ─────────────────────────────────────────
|
|
91
|
+
const planningCodebaseDir = path.join(projectRoot, '.planning', 'codebase');
|
|
92
|
+
const planningCodebaseExists = fs.existsSync(planningCodebaseDir) &&
|
|
93
|
+
fs.statSync(planningCodebaseDir).isDirectory();
|
|
94
|
+
let planningCodebaseFiles = [];
|
|
95
|
+
if (planningCodebaseExists) {
|
|
96
|
+
planningCodebaseFiles = _scanDir(planningCodebaseDir, planningCodebaseDir);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Ensure .planning/legion/ ───────────────────────────────────────────
|
|
100
|
+
let planningDir = resolvePlanningDir(cwd);
|
|
101
|
+
const planningDirExisted = planningDir !== null;
|
|
102
|
+
|
|
103
|
+
if (!planningDir) {
|
|
104
|
+
planningDir = path.join(projectRoot, '.planning', 'legion');
|
|
105
|
+
fs.mkdirSync(planningDir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Ensure artifacts subdirectory
|
|
109
|
+
const artifactsDir = path.join(planningDir, 'artifacts');
|
|
110
|
+
if (!fs.existsSync(artifactsDir)) {
|
|
111
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Ensure sessions subdirectory
|
|
115
|
+
const sessionsDir = path.join(planningDir, 'sessions');
|
|
116
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
117
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Config ─────────────────────────────────────────────────────────────
|
|
121
|
+
let config = loadConfig(planningDir);
|
|
122
|
+
const configExisted = config !== null;
|
|
123
|
+
if (!config) {
|
|
124
|
+
config = defaultConfig(domain);
|
|
125
|
+
saveConfig(planningDir, config);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
129
|
+
let state = loadState(planningDir);
|
|
130
|
+
const stateExisted = state !== null;
|
|
131
|
+
if (!state) {
|
|
132
|
+
state = initState(planningDir, domain);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Next task number ───────────────────────────────────────────────────
|
|
136
|
+
const taskNum = nextTaskNum(planningDir);
|
|
137
|
+
|
|
138
|
+
// ── Build context blob ─────────────────────────────────────────────────
|
|
139
|
+
const context = {
|
|
140
|
+
timestamp: currentTimestamp('iso'),
|
|
141
|
+
domain: domain,
|
|
142
|
+
projectRoot: projectRoot,
|
|
143
|
+
|
|
144
|
+
codebase: {
|
|
145
|
+
exists: codebaseExists,
|
|
146
|
+
path: codebaseDir,
|
|
147
|
+
files: codebaseFiles,
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
planningCodebase: {
|
|
151
|
+
exists: planningCodebaseExists,
|
|
152
|
+
path: planningCodebaseExists ? planningCodebaseDir : null,
|
|
153
|
+
files: planningCodebaseFiles,
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
planning: {
|
|
157
|
+
path: planningDir,
|
|
158
|
+
existed: planningDirExisted,
|
|
159
|
+
artifactsDir: artifactsDir,
|
|
160
|
+
sessionsDir: sessionsDir,
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
config: {
|
|
164
|
+
existed: configExisted,
|
|
165
|
+
data: config,
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
state: {
|
|
169
|
+
existed: stateExisted,
|
|
170
|
+
data: state,
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
nextTaskNum: taskNum,
|
|
174
|
+
args: args || [],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return context;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
initWorkflow,
|
|
184
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { currentTimestamp } = require('./core.cjs');
|
|
6
|
+
|
|
7
|
+
// ─── Session Management ─────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a new session record in `.planning/legion/sessions/`.
|
|
11
|
+
*
|
|
12
|
+
* Session file: `{YYYY-MM-DD}-{NNN}.md`
|
|
13
|
+
* NNN is a zero-padded counter within the same day.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} planningDir - Absolute path to `.planning/legion/`
|
|
16
|
+
* @param {object} data - Session data
|
|
17
|
+
* @param {string} [data.domain] - Domain name
|
|
18
|
+
* @param {string} [data.stage] - Pipeline stage
|
|
19
|
+
* @param {string} [data.agent] - Agent name
|
|
20
|
+
* @param {string} [data.description] - Session description
|
|
21
|
+
* @param {object} [data.context] - Arbitrary context data
|
|
22
|
+
* @returns {string} Absolute path to the created session file
|
|
23
|
+
*/
|
|
24
|
+
function createSession(planningDir, data) {
|
|
25
|
+
const sessionsDir = path.join(planningDir, 'sessions');
|
|
26
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
27
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const dateStr = currentTimestamp('date');
|
|
31
|
+
|
|
32
|
+
// Find next session number for today
|
|
33
|
+
let maxNum = 0;
|
|
34
|
+
try {
|
|
35
|
+
const entries = fs.readdirSync(sessionsDir);
|
|
36
|
+
const prefix = dateStr + '-';
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (entry.startsWith(prefix) && entry.endsWith('.md')) {
|
|
39
|
+
const numPart = entry.slice(prefix.length, entry.length - 3);
|
|
40
|
+
const num = parseInt(numPart, 10);
|
|
41
|
+
if (!isNaN(num) && num > maxNum) maxNum = num;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (_err) {
|
|
45
|
+
// If directory doesn't exist or can't be read, start at 001
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const nextNum = String(maxNum + 1).padStart(3, '0');
|
|
49
|
+
const fileName = `${dateStr}-${nextNum}.md`;
|
|
50
|
+
const filePath = path.join(sessionsDir, fileName);
|
|
51
|
+
|
|
52
|
+
// Build session markdown
|
|
53
|
+
const lines = [];
|
|
54
|
+
lines.push(`# Session ${dateStr}-${nextNum}`);
|
|
55
|
+
lines.push('');
|
|
56
|
+
lines.push(`**Date:** ${currentTimestamp('iso')}`);
|
|
57
|
+
|
|
58
|
+
if (data.domain) {
|
|
59
|
+
lines.push(`**Domain:** ${data.domain}`);
|
|
60
|
+
}
|
|
61
|
+
if (data.stage) {
|
|
62
|
+
lines.push(`**Stage:** ${data.stage}`);
|
|
63
|
+
}
|
|
64
|
+
if (data.agent) {
|
|
65
|
+
lines.push(`**Agent:** ${data.agent}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push('');
|
|
68
|
+
|
|
69
|
+
if (data.description) {
|
|
70
|
+
lines.push('## Description');
|
|
71
|
+
lines.push('');
|
|
72
|
+
lines.push(data.description);
|
|
73
|
+
lines.push('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (data.context) {
|
|
77
|
+
lines.push('## Context');
|
|
78
|
+
lines.push('');
|
|
79
|
+
lines.push('```json');
|
|
80
|
+
lines.push(JSON.stringify(data.context, null, 2));
|
|
81
|
+
lines.push('```');
|
|
82
|
+
lines.push('');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
lines.push('## Log');
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push(`- ${currentTimestamp('datetime')} — Session created`);
|
|
88
|
+
lines.push('');
|
|
89
|
+
|
|
90
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
91
|
+
|
|
92
|
+
return filePath;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get the most recent session file from `.planning/legion/sessions/`.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} planningDir - Absolute path to `.planning/legion/`
|
|
99
|
+
* @returns {object|null} Session info: { path, name, content } or null
|
|
100
|
+
*/
|
|
101
|
+
function getLatestSession(planningDir) {
|
|
102
|
+
const sessionsDir = path.join(planningDir, 'sessions');
|
|
103
|
+
if (!fs.existsSync(sessionsDir)) return null;
|
|
104
|
+
|
|
105
|
+
let entries;
|
|
106
|
+
try {
|
|
107
|
+
entries = fs.readdirSync(sessionsDir)
|
|
108
|
+
.filter(f => f.endsWith('.md'))
|
|
109
|
+
.sort();
|
|
110
|
+
} catch (_err) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (entries.length === 0) return null;
|
|
115
|
+
|
|
116
|
+
// The last entry alphabetically is the most recent
|
|
117
|
+
// (since names are YYYY-MM-DD-NNN.md, alphabetical = chronological)
|
|
118
|
+
const latest = entries[entries.length - 1];
|
|
119
|
+
const latestPath = path.join(sessionsDir, latest);
|
|
120
|
+
|
|
121
|
+
let content;
|
|
122
|
+
try {
|
|
123
|
+
content = fs.readFileSync(latestPath, 'utf8');
|
|
124
|
+
} catch (_err) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
path: latestPath,
|
|
130
|
+
name: latest,
|
|
131
|
+
content: content,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
createSession,
|
|
139
|
+
getLatestSession,
|
|
140
|
+
};
|