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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +269 -0
  3. package/VERSION +1 -0
  4. package/agents/legion-orchestrator.md +95 -0
  5. package/bin/install.js +898 -0
  6. package/bin/legion-tools.cjs +421 -0
  7. package/bin/lib/config.cjs +141 -0
  8. package/bin/lib/core.cjs +216 -0
  9. package/bin/lib/domain.cjs +107 -0
  10. package/bin/lib/init.cjs +184 -0
  11. package/bin/lib/session.cjs +140 -0
  12. package/bin/lib/state.cjs +280 -0
  13. package/commands/legion/devops/architect.md +44 -0
  14. package/commands/legion/devops/build.md +52 -0
  15. package/commands/legion/devops/cycle.md +52 -0
  16. package/commands/legion/devops/execute.md +52 -0
  17. package/commands/legion/devops/plan.md +51 -0
  18. package/commands/legion/devops/quick.md +45 -0
  19. package/commands/legion/devops/review.md +52 -0
  20. package/commands/legion/resume.md +52 -0
  21. package/commands/legion/status.md +53 -0
  22. package/hooks/legion-context-monitor.js +180 -0
  23. package/hooks/legion-statusline.js +191 -0
  24. package/package.json +48 -0
  25. package/references/agent-routing.md +64 -0
  26. package/references/devops/agent-map.md +61 -0
  27. package/references/devops/pipeline-patterns.md +87 -0
  28. package/references/domain-registry.md +63 -0
  29. package/references/ui-brand.md +102 -0
  30. package/templates/config.json +25 -0
  31. package/templates/devops/architect-output.md +28 -0
  32. package/templates/devops/execution-report.md +23 -0
  33. package/templates/devops/plan-output.md +33 -0
  34. package/templates/devops/review-checklist.md +35 -0
  35. package/templates/session.md +17 -0
  36. package/templates/state.md +17 -0
  37. package/templates/task-record.md +19 -0
  38. package/workflows/core/completion.md +70 -0
  39. package/workflows/core/context-load.md +57 -0
  40. package/workflows/core/init.md +52 -0
  41. package/workflows/devops/architect.md +91 -0
  42. package/workflows/devops/build.md +92 -0
  43. package/workflows/devops/cycle.md +237 -0
  44. package/workflows/devops/execute.md +118 -0
  45. package/workflows/devops/plan.md +108 -0
  46. package/workflows/devops/quick.md +107 -0
  47. package/workflows/devops/review.md +112 -0
  48. package/workflows/resume.md +88 -0
  49. package/workflows/status.md +72 -0
@@ -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
+ };
@@ -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
+ };