@howlil/ez-agents 3.4.2 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -2
- package/agents/ez-observer-agent.md +260 -0
- package/agents/ez-release-agent.md +333 -0
- package/agents/ez-requirements-agent.md +377 -0
- package/agents/ez-scrum-master-agent.md +242 -0
- package/agents/ez-tech-lead-agent.md +267 -0
- package/bin/install.js +3221 -3272
- package/commands/ez/arch-review.md +102 -0
- package/commands/ez/execute-phase.md +11 -0
- package/commands/ez/export-session.md +79 -0
- package/commands/ez/gather-requirements.md +117 -0
- package/commands/ez/git-workflow.md +72 -0
- package/commands/ez/hotfix.md +120 -0
- package/commands/ez/import-session.md +82 -0
- package/commands/ez/list-sessions.md +96 -0
- package/commands/ez/package-manager.md +316 -0
- package/commands/ez/plan-phase.md +9 -1
- package/commands/ez/preflight.md +79 -0
- package/commands/ez/progress.md +13 -1
- package/commands/ez/release.md +153 -0
- package/commands/ez/resume.md +107 -0
- package/commands/ez/standup.md +85 -0
- package/ez-agents/bin/ez-tools.cjs +1095 -716
- package/ez-agents/bin/lib/bdd-validator.cjs +622 -0
- package/ez-agents/bin/lib/content-scanner.cjs +238 -0
- package/ez-agents/bin/lib/context-cache.cjs +154 -0
- package/ez-agents/bin/lib/context-errors.cjs +71 -0
- package/ez-agents/bin/lib/context-manager.cjs +220 -0
- package/ez-agents/bin/lib/discussion-synthesizer.cjs +458 -0
- package/ez-agents/bin/lib/file-access.cjs +207 -0
- package/ez-agents/bin/lib/git-errors.cjs +83 -0
- package/ez-agents/bin/lib/git-utils.cjs +321 -203
- package/ez-agents/bin/lib/git-workflow-engine.cjs +1157 -0
- package/ez-agents/bin/lib/index.cjs +46 -2
- package/ez-agents/bin/lib/lockfile-validator.cjs +227 -0
- package/ez-agents/bin/lib/logger.cjs +124 -154
- package/ez-agents/bin/lib/memory-compression.cjs +256 -0
- package/ez-agents/bin/lib/metrics-tracker.cjs +406 -0
- package/ez-agents/bin/lib/package-manager-detector.cjs +203 -0
- package/ez-agents/bin/lib/package-manager-executor.cjs +385 -0
- package/ez-agents/bin/lib/package-manager-service.cjs +216 -0
- package/ez-agents/bin/lib/release-validator.cjs +614 -0
- package/ez-agents/bin/lib/safe-exec.cjs +128 -214
- package/ez-agents/bin/lib/session-chain.cjs +304 -0
- package/ez-agents/bin/lib/session-errors.cjs +81 -0
- package/ez-agents/bin/lib/session-export.cjs +251 -0
- package/ez-agents/bin/lib/session-import.cjs +262 -0
- package/ez-agents/bin/lib/session-manager.cjs +280 -0
- package/ez-agents/bin/lib/tier-manager.cjs +428 -0
- package/ez-agents/bin/lib/url-fetch.cjs +170 -0
- package/ez-agents/references/metrics-schema.md +118 -0
- package/ez-agents/references/planning-config.md +140 -0
- package/ez-agents/references/tier-strategy.md +103 -0
- package/ez-agents/templates/bdd-feature.md +173 -0
- package/ez-agents/templates/discussion.md +68 -0
- package/ez-agents/templates/incident-runbook.md +205 -0
- package/ez-agents/templates/release-checklist.md +133 -0
- package/ez-agents/templates/rollback-plan.md +201 -0
- package/ez-agents/workflows/arch-review.md +54 -0
- package/ez-agents/workflows/autonomous.md +844 -743
- package/ez-agents/workflows/execute-phase.md +45 -0
- package/ez-agents/workflows/export-session.md +255 -0
- package/ez-agents/workflows/gather-requirements.md +206 -0
- package/ez-agents/workflows/help.md +92 -0
- package/ez-agents/workflows/hotfix.md +291 -0
- package/ez-agents/workflows/import-session.md +303 -0
- package/ez-agents/workflows/new-milestone.md +713 -384
- package/ez-agents/workflows/new-project.md +1107 -1113
- package/ez-agents/workflows/plan-phase.md +22 -0
- package/ez-agents/workflows/progress.md +15 -25
- package/ez-agents/workflows/release.md +253 -0
- package/ez-agents/workflows/resume-session.md +215 -0
- package/ez-agents/workflows/standup.md +64 -0
- package/package.json +9 -2
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session Manager — Core session state persistence module
|
|
5
|
+
*
|
|
6
|
+
* Manages session lifecycle: create, load, update, end, list
|
|
7
|
+
* Sessions stored in .planning/sessions/session-{timestamp}.json
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { safePlanningWriteSync } = require('./planning-write.cjs');
|
|
13
|
+
const { defaultLogger: logger } = require('./logger.cjs');
|
|
14
|
+
|
|
15
|
+
class SessionManager {
|
|
16
|
+
/**
|
|
17
|
+
* Create a SessionManager instance
|
|
18
|
+
* @param {string} sessionsDir - Directory for session files (default: .planning/sessions)
|
|
19
|
+
*/
|
|
20
|
+
constructor(sessionsDir = '.planning/sessions') {
|
|
21
|
+
this.sessionsDir = sessionsDir;
|
|
22
|
+
this._ensureDir();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ensure sessions directory exists
|
|
27
|
+
* @private
|
|
28
|
+
*/
|
|
29
|
+
_ensureDir() {
|
|
30
|
+
if (!fs.existsSync(this.sessionsDir)) {
|
|
31
|
+
fs.mkdirSync(this.sessionsDir, { recursive: true });
|
|
32
|
+
logger.info('Sessions directory created', { dir: this.sessionsDir });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate session ID from timestamp
|
|
38
|
+
* Format: session-YYYYMMDD-HHMMSS
|
|
39
|
+
* @private
|
|
40
|
+
* @returns {string} Session ID
|
|
41
|
+
*/
|
|
42
|
+
_generateSessionId() {
|
|
43
|
+
const now = new Date();
|
|
44
|
+
const timestamp = now.toISOString()
|
|
45
|
+
.replace(/[:.]/g, '-')
|
|
46
|
+
.replace('T', '-')
|
|
47
|
+
.slice(0, -5); // Remove milliseconds and Z
|
|
48
|
+
return `session-${timestamp}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deep merge source into target
|
|
53
|
+
* @private
|
|
54
|
+
* @param {Object} target - Target object
|
|
55
|
+
* @param {Object} source - Source object
|
|
56
|
+
* @returns {Object} Merged object
|
|
57
|
+
*/
|
|
58
|
+
_deepMerge(target, source) {
|
|
59
|
+
const result = { ...target };
|
|
60
|
+
for (const key in source) {
|
|
61
|
+
if (source.hasOwnProperty(key)) {
|
|
62
|
+
if (source[key] instanceof Object && key !== null) {
|
|
63
|
+
result[key] = this._deepMerge(result[key] || {}, source[key]);
|
|
64
|
+
} else {
|
|
65
|
+
result[key] = source[key];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a new session
|
|
74
|
+
* @param {Object} options - Session options
|
|
75
|
+
* @param {string} [options.model] - Model identifier
|
|
76
|
+
* @param {number} [options.phase] - Phase number
|
|
77
|
+
* @param {number} [options.plan] - Plan number
|
|
78
|
+
* @param {string} [options.objective] - Session objective
|
|
79
|
+
* @returns {string} Session ID
|
|
80
|
+
*/
|
|
81
|
+
createSession(options = {}) {
|
|
82
|
+
const sessionId = this._generateSessionId();
|
|
83
|
+
const sessionPath = path.join(this.sessionsDir, `${sessionId}.json`);
|
|
84
|
+
|
|
85
|
+
const session = {
|
|
86
|
+
metadata: {
|
|
87
|
+
session_id: sessionId,
|
|
88
|
+
session_version: '1.0',
|
|
89
|
+
started_at: new Date().toISOString(),
|
|
90
|
+
ended_at: null,
|
|
91
|
+
model: options.model || null,
|
|
92
|
+
phase: options.phase || null,
|
|
93
|
+
plan: options.plan || null,
|
|
94
|
+
status: 'active',
|
|
95
|
+
session_chain: [],
|
|
96
|
+
token_usage: {
|
|
97
|
+
input: 0,
|
|
98
|
+
output: 0,
|
|
99
|
+
total: 0
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
context: {
|
|
103
|
+
transcript: '',
|
|
104
|
+
tasks: [],
|
|
105
|
+
decisions: [],
|
|
106
|
+
file_changes: [],
|
|
107
|
+
open_questions: [],
|
|
108
|
+
blockers: []
|
|
109
|
+
},
|
|
110
|
+
state: {
|
|
111
|
+
current_phase: options.phase || null,
|
|
112
|
+
current_plan: options.plan || null,
|
|
113
|
+
incomplete_tasks: [],
|
|
114
|
+
last_action: null,
|
|
115
|
+
next_recommended_action: options.objective || null
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
safePlanningWriteSync(sessionPath, JSON.stringify(session, null, 2));
|
|
121
|
+
logger.info('Session created', { sessionId, sessionPath });
|
|
122
|
+
return sessionId;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
logger.error('Failed to create session', { sessionId, error: err.message });
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Load a session by ID
|
|
131
|
+
* @param {string} sessionId - Session ID
|
|
132
|
+
* @returns {Object|null} Session object or null if not found
|
|
133
|
+
*/
|
|
134
|
+
loadSession(sessionId) {
|
|
135
|
+
const sessionPath = path.join(this.sessionsDir, `${sessionId}.json`);
|
|
136
|
+
|
|
137
|
+
if (!fs.existsSync(sessionPath)) {
|
|
138
|
+
logger.error('Session not found', { sessionId, sessionPath });
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const content = fs.readFileSync(sessionPath, 'utf-8');
|
|
144
|
+
const session = JSON.parse(content);
|
|
145
|
+
logger.info('Session loaded', { sessionId });
|
|
146
|
+
return session;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
logger.error('Failed to load session', { sessionId, error: err.message });
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get the most recent session
|
|
155
|
+
* @returns {Object|null} Last session or null if none exist
|
|
156
|
+
*/
|
|
157
|
+
getLastSession() {
|
|
158
|
+
const sessionFiles = this._getSessionFiles();
|
|
159
|
+
|
|
160
|
+
if (sessionFiles.length === 0) {
|
|
161
|
+
logger.info('No sessions found');
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Files are sorted, newest first
|
|
166
|
+
const lastFile = sessionFiles[0];
|
|
167
|
+
const sessionId = lastFile.replace('.json', '');
|
|
168
|
+
return this.loadSession(sessionId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Update a session with new data
|
|
173
|
+
* @param {string} sessionId - Session ID
|
|
174
|
+
* @param {Object} updates - Updates to merge into session
|
|
175
|
+
* @returns {boolean} True on success, false if session not found
|
|
176
|
+
*/
|
|
177
|
+
updateSession(sessionId, updates) {
|
|
178
|
+
const session = this.loadSession(sessionId);
|
|
179
|
+
if (!session) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const updatedSession = this._deepMerge(session, updates);
|
|
184
|
+
const sessionPath = path.join(this.sessionsDir, `${sessionId}.json`);
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
safePlanningWriteSync(sessionPath, JSON.stringify(updatedSession, null, 2));
|
|
188
|
+
logger.info('Session updated', { sessionId });
|
|
189
|
+
return true;
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger.error('Failed to update session', { sessionId, error: err.message });
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* End a session
|
|
198
|
+
* @param {string} sessionId - Session ID
|
|
199
|
+
* @param {Object} finalState - Final state information
|
|
200
|
+
* @param {string} [finalState.status] - Final status (default: 'completed')
|
|
201
|
+
* @param {Array} [finalState.incomplete_tasks] - Incomplete tasks
|
|
202
|
+
* @param {string} [finalState.next_recommended_action] - Next recommended action
|
|
203
|
+
* @returns {boolean} True on success, false if session not found
|
|
204
|
+
*/
|
|
205
|
+
endSession(sessionId, finalState = {}) {
|
|
206
|
+
const session = this.loadSession(sessionId);
|
|
207
|
+
if (!session) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const updates = {
|
|
212
|
+
metadata: {
|
|
213
|
+
ended_at: new Date().toISOString(),
|
|
214
|
+
status: finalState.status || 'completed'
|
|
215
|
+
},
|
|
216
|
+
state: {}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (finalState.incomplete_tasks) {
|
|
220
|
+
updates.state.incomplete_tasks = finalState.incomplete_tasks;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (finalState.next_recommended_action) {
|
|
224
|
+
updates.state.next_recommended_action = finalState.next_recommended_action;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return this.updateSession(sessionId, updates);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* List all sessions
|
|
232
|
+
* @returns {Array} Array of session metadata sorted by date (newest first)
|
|
233
|
+
*/
|
|
234
|
+
listSessions() {
|
|
235
|
+
const sessionFiles = this._getSessionFiles();
|
|
236
|
+
const sessions = [];
|
|
237
|
+
|
|
238
|
+
for (const file of sessionFiles) {
|
|
239
|
+
const sessionId = file.replace('.json', '');
|
|
240
|
+
const session = this.loadSession(sessionId);
|
|
241
|
+
if (session) {
|
|
242
|
+
sessions.push({
|
|
243
|
+
session_id: session.metadata.session_id,
|
|
244
|
+
started_at: session.metadata.started_at,
|
|
245
|
+
ended_at: session.metadata.ended_at,
|
|
246
|
+
model: session.metadata.model,
|
|
247
|
+
phase: session.metadata.phase,
|
|
248
|
+
plan: session.metadata.plan,
|
|
249
|
+
status: session.metadata.status
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return sessions;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get session files sorted by name (newest first)
|
|
259
|
+
* @private
|
|
260
|
+
* @returns {Array} Array of filenames
|
|
261
|
+
*/
|
|
262
|
+
_getSessionFiles() {
|
|
263
|
+
if (!fs.existsSync(this.sessionsDir)) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const files = fs.readdirSync(this.sessionsDir)
|
|
269
|
+
.filter(file => /^session-.*\.json$/.test(file))
|
|
270
|
+
.sort()
|
|
271
|
+
.reverse();
|
|
272
|
+
return files;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
logger.error('Failed to read sessions directory', { error: err.message });
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = SessionManager;
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tier Manager — Multi-tier release strategy management
|
|
5
|
+
*
|
|
6
|
+
* Manages MVP / Medium / Enterprise tier definitions, validation,
|
|
7
|
+
* and promotion logic for /ez:release workflows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// ─────────────────────────────────────────────
|
|
16
|
+
// Tier Definitions
|
|
17
|
+
// ─────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const TIER_DEFINITIONS = {
|
|
20
|
+
mvp: {
|
|
21
|
+
name: 'MVP',
|
|
22
|
+
label: 'Minimum Viable Product',
|
|
23
|
+
moscow_scope: ['must'],
|
|
24
|
+
coverage_threshold: 60,
|
|
25
|
+
git_strategy: 'trunk',
|
|
26
|
+
checklist_count: 6,
|
|
27
|
+
rollback_window_minutes: 30,
|
|
28
|
+
description: 'Core @must features only. Ship in hours.'
|
|
29
|
+
},
|
|
30
|
+
medium: {
|
|
31
|
+
name: 'Medium',
|
|
32
|
+
label: 'Production Ready',
|
|
33
|
+
moscow_scope: ['must', 'should'],
|
|
34
|
+
coverage_threshold: 80,
|
|
35
|
+
git_strategy: 'github-flow',
|
|
36
|
+
checklist_count: 18,
|
|
37
|
+
rollback_window_minutes: 15,
|
|
38
|
+
description: 'Must + Should features. Real users, proper testing.'
|
|
39
|
+
},
|
|
40
|
+
enterprise: {
|
|
41
|
+
name: 'Enterprise',
|
|
42
|
+
label: 'Compliance Grade',
|
|
43
|
+
moscow_scope: ['must', 'should', 'could'],
|
|
44
|
+
coverage_threshold: 95,
|
|
45
|
+
git_strategy: 'gitflow',
|
|
46
|
+
checklist_count: 30,
|
|
47
|
+
rollback_window_minutes: 5,
|
|
48
|
+
description: 'All MoSCoW priorities. Regulated industries, enterprise customers.'
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const TIER_ORDER = ['mvp', 'medium', 'enterprise'];
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────
|
|
55
|
+
// Tier Accessors
|
|
56
|
+
// ─────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get tier definition
|
|
60
|
+
* @param {string} tier - 'mvp' | 'medium' | 'enterprise'
|
|
61
|
+
* @returns {object}
|
|
62
|
+
*/
|
|
63
|
+
function getTier(tier) {
|
|
64
|
+
const def = TIER_DEFINITIONS[tier.toLowerCase()];
|
|
65
|
+
if (!def) {
|
|
66
|
+
throw new Error(`Unknown tier: ${tier}. Must be one of: ${TIER_ORDER.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
return { ...def, id: tier.toLowerCase() };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get all tier definitions
|
|
73
|
+
* @returns {object[]}
|
|
74
|
+
*/
|
|
75
|
+
function getAllTiers() {
|
|
76
|
+
return TIER_ORDER.map(t => ({ id: t, ...TIER_DEFINITIONS[t] }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if a tier is valid
|
|
81
|
+
* @param {string} tier
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
function isValidTier(tier) {
|
|
85
|
+
return TIER_ORDER.includes(tier.toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the tier index (0=mvp, 1=medium, 2=enterprise)
|
|
90
|
+
* @param {string} tier
|
|
91
|
+
* @returns {number}
|
|
92
|
+
*/
|
|
93
|
+
function getTierIndex(tier) {
|
|
94
|
+
return TIER_ORDER.indexOf(tier.toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if target tier is a promotion from current tier
|
|
99
|
+
* @param {string} current
|
|
100
|
+
* @param {string} target
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
function isPromotion(current, target) {
|
|
104
|
+
return getTierIndex(target) > getTierIndex(current);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get the tier below (for prerequisite checking)
|
|
109
|
+
* @param {string} tier
|
|
110
|
+
* @returns {string|null}
|
|
111
|
+
*/
|
|
112
|
+
function getPreviousTier(tier) {
|
|
113
|
+
const idx = getTierIndex(tier);
|
|
114
|
+
return idx > 0 ? TIER_ORDER[idx - 1] : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─────────────────────────────────────────────
|
|
118
|
+
// Git Strategy
|
|
119
|
+
// ─────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get git strategy for a tier
|
|
123
|
+
* @param {string} tier
|
|
124
|
+
* @returns {{ strategy: string, releaseBranchPrefix: string, targetBranch: string, syncBranch: string|null }}
|
|
125
|
+
*/
|
|
126
|
+
function getGitStrategy(tier) {
|
|
127
|
+
const def = getTier(tier);
|
|
128
|
+
|
|
129
|
+
const strategies = {
|
|
130
|
+
trunk: {
|
|
131
|
+
strategy: 'trunk',
|
|
132
|
+
releaseBranchPrefix: null,
|
|
133
|
+
targetBranch: 'main',
|
|
134
|
+
syncBranch: null,
|
|
135
|
+
description: 'Tag directly on main. No release branch.'
|
|
136
|
+
},
|
|
137
|
+
'github-flow': {
|
|
138
|
+
strategy: 'github-flow',
|
|
139
|
+
releaseBranchPrefix: 'release',
|
|
140
|
+
targetBranch: 'main',
|
|
141
|
+
syncBranch: null,
|
|
142
|
+
description: 'release/vX.Y.Z branch → PR → main'
|
|
143
|
+
},
|
|
144
|
+
gitflow: {
|
|
145
|
+
strategy: 'gitflow',
|
|
146
|
+
releaseBranchPrefix: 'release',
|
|
147
|
+
targetBranch: 'main',
|
|
148
|
+
syncBranch: 'develop',
|
|
149
|
+
description: 'release/vX.Y.Z from develop → main → tag → sync develop'
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return strategies[def.git_strategy];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Generate release branch name for version
|
|
158
|
+
* @param {string} tier
|
|
159
|
+
* @param {string} version - semver without 'v' prefix
|
|
160
|
+
* @returns {string|null}
|
|
161
|
+
*/
|
|
162
|
+
function getReleaseBranchName(tier, version) {
|
|
163
|
+
const strategy = getGitStrategy(tier);
|
|
164
|
+
if (!strategy.releaseBranchPrefix) return null; // trunk-based
|
|
165
|
+
return `${strategy.releaseBranchPrefix}/v${version}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Generate hotfix branch name
|
|
170
|
+
* @param {string} name - slug for the fix
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
function getHotfixBranchName(name) {
|
|
174
|
+
const slug = name.replace(/[^a-zA-Z0-9-_]/g, '-').replace(/-+/g, '-').toLowerCase();
|
|
175
|
+
return `hotfix/${slug}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─────────────────────────────────────────────
|
|
179
|
+
// Coverage Validation
|
|
180
|
+
// ─────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if coverage meets tier threshold
|
|
184
|
+
* @param {string} tier
|
|
185
|
+
* @param {number} coveragePct
|
|
186
|
+
* @returns {{ passes: boolean, threshold: number, actual: number, gap: number }}
|
|
187
|
+
*/
|
|
188
|
+
function checkCoverage(tier, coveragePct) {
|
|
189
|
+
const def = getTier(tier);
|
|
190
|
+
const passes = coveragePct >= def.coverage_threshold;
|
|
191
|
+
return {
|
|
192
|
+
passes,
|
|
193
|
+
threshold: def.coverage_threshold,
|
|
194
|
+
actual: coveragePct,
|
|
195
|
+
gap: passes ? 0 : def.coverage_threshold - coveragePct
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─────────────────────────────────────────────
|
|
200
|
+
// Feature Flag Helpers
|
|
201
|
+
// ─────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get feature flags that should be enabled for a tier
|
|
205
|
+
* MVP: only must features (all other flags = false)
|
|
206
|
+
* Medium: must + should
|
|
207
|
+
* Enterprise: all
|
|
208
|
+
*
|
|
209
|
+
* @param {string} tier
|
|
210
|
+
* @returns {{ enabled_moscow: string[], disabled_moscow: string[] }}
|
|
211
|
+
*/
|
|
212
|
+
function getFeatureFlagConfig(tier) {
|
|
213
|
+
const def = getTier(tier);
|
|
214
|
+
const all = ['must', 'should', 'could'];
|
|
215
|
+
const enabled = def.moscow_scope;
|
|
216
|
+
const disabled = all.filter(m => !enabled.includes(m));
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
enabled_moscow: enabled,
|
|
220
|
+
disabled_moscow: disabled,
|
|
221
|
+
flag_config: {
|
|
222
|
+
ENABLE_SHOULD_FEATURES: enabled.includes('should'),
|
|
223
|
+
ENABLE_COULD_FEATURES: enabled.includes('could')
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─────────────────────────────────────────────
|
|
229
|
+
// Config Integration
|
|
230
|
+
// ─────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Build release config section for .planning/config.json
|
|
234
|
+
* @param {string} currentTier
|
|
235
|
+
* @returns {object}
|
|
236
|
+
*/
|
|
237
|
+
function buildReleaseConfig(currentTier = 'mvp') {
|
|
238
|
+
return {
|
|
239
|
+
tier: currentTier,
|
|
240
|
+
tiers: {
|
|
241
|
+
mvp: {
|
|
242
|
+
moscow_scope: TIER_DEFINITIONS.mvp.moscow_scope,
|
|
243
|
+
coverage: TIER_DEFINITIONS.mvp.coverage_threshold,
|
|
244
|
+
git: TIER_DEFINITIONS.mvp.git_strategy,
|
|
245
|
+
checklist_items: TIER_DEFINITIONS.mvp.checklist_count
|
|
246
|
+
},
|
|
247
|
+
medium: {
|
|
248
|
+
moscow_scope: TIER_DEFINITIONS.medium.moscow_scope,
|
|
249
|
+
coverage: TIER_DEFINITIONS.medium.coverage_threshold,
|
|
250
|
+
git: TIER_DEFINITIONS.medium.git_strategy,
|
|
251
|
+
checklist_items: TIER_DEFINITIONS.medium.checklist_count
|
|
252
|
+
},
|
|
253
|
+
enterprise: {
|
|
254
|
+
moscow_scope: TIER_DEFINITIONS.enterprise.moscow_scope,
|
|
255
|
+
coverage: TIER_DEFINITIONS.enterprise.coverage_threshold,
|
|
256
|
+
git: TIER_DEFINITIONS.enterprise.git_strategy,
|
|
257
|
+
checklist_items: TIER_DEFINITIONS.enterprise.checklist_count
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Load current tier from config.json
|
|
265
|
+
* @param {string} configPath - Path to .planning/config.json
|
|
266
|
+
* @returns {string}
|
|
267
|
+
*/
|
|
268
|
+
function loadCurrentTier(configPath) {
|
|
269
|
+
try {
|
|
270
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
271
|
+
return (config.release && config.release.tier) || 'mvp';
|
|
272
|
+
} catch {
|
|
273
|
+
return 'mvp';
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Save current tier to config.json
|
|
279
|
+
* @param {string} configPath
|
|
280
|
+
* @param {string} tier
|
|
281
|
+
*/
|
|
282
|
+
function saveCurrentTier(configPath, tier) {
|
|
283
|
+
if (!isValidTier(tier)) throw new Error(`Invalid tier: ${tier}`);
|
|
284
|
+
|
|
285
|
+
let config = {};
|
|
286
|
+
try {
|
|
287
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
288
|
+
} catch {
|
|
289
|
+
// file doesn't exist yet
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!config.release) config.release = {};
|
|
293
|
+
config.release.tier = tier.toLowerCase();
|
|
294
|
+
if (!config.release.tiers) {
|
|
295
|
+
config.release = { ...config.release, ...buildReleaseConfig(tier) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ─────────────────────────────────────────────
|
|
302
|
+
// Validation Summary
|
|
303
|
+
// ─────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Generate a tier validation summary
|
|
307
|
+
* @param {string} tier
|
|
308
|
+
* @param {object} checks - { coverage: number, secretsFound: number, auditPassed: boolean }
|
|
309
|
+
* @returns {{ valid: boolean, tier: string, blockers: string[], warnings: string[], summary: string }}
|
|
310
|
+
*/
|
|
311
|
+
function validateRelease(tier, checks = {}) {
|
|
312
|
+
const def = getTier(tier);
|
|
313
|
+
const blockers = [];
|
|
314
|
+
const warnings = [];
|
|
315
|
+
|
|
316
|
+
// Coverage check
|
|
317
|
+
if (checks.coverage !== undefined) {
|
|
318
|
+
const cov = checkCoverage(tier, checks.coverage);
|
|
319
|
+
if (!cov.passes) {
|
|
320
|
+
warnings.push(`Coverage ${checks.coverage}% is below ${tier} threshold (${def.coverage_threshold}%)`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Security checks
|
|
325
|
+
if (checks.secretsFound > 0) {
|
|
326
|
+
blockers.push(`${checks.secretsFound} potential secret(s) found in committed files`);
|
|
327
|
+
}
|
|
328
|
+
if (checks.auditPassed === false) {
|
|
329
|
+
blockers.push('npm audit found critical vulnerabilities');
|
|
330
|
+
}
|
|
331
|
+
if (checks.hasProdTodos) {
|
|
332
|
+
warnings.push('Production TODO/FIXME comments found in src/');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const valid = blockers.length === 0;
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
valid,
|
|
339
|
+
tier,
|
|
340
|
+
tierDef: def,
|
|
341
|
+
blockers,
|
|
342
|
+
warnings,
|
|
343
|
+
summary: valid
|
|
344
|
+
? `${def.name} release validated (${warnings.length} warnings)`
|
|
345
|
+
: `${def.name} release BLOCKED (${blockers.length} blockers)`
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─────────────────────────────────────────────
|
|
350
|
+
// CLI Interface
|
|
351
|
+
// ─────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
if (require.main === module) {
|
|
354
|
+
const args = process.argv.slice(2);
|
|
355
|
+
const cmd = args[0];
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
if (cmd === 'get') {
|
|
359
|
+
const tier = args[1];
|
|
360
|
+
if (!tier) { console.error('Usage: tier-manager.cjs get <tier>'); process.exit(1); }
|
|
361
|
+
console.log(JSON.stringify(getTier(tier), null, 2));
|
|
362
|
+
} else if (cmd === 'all') {
|
|
363
|
+
console.log(JSON.stringify(getAllTiers(), null, 2));
|
|
364
|
+
} else if (cmd === 'git-strategy') {
|
|
365
|
+
const tier = args[1];
|
|
366
|
+
if (!tier) { console.error('Usage: tier-manager.cjs git-strategy <tier>'); process.exit(1); }
|
|
367
|
+
console.log(JSON.stringify(getGitStrategy(tier), null, 2));
|
|
368
|
+
} else if (cmd === 'release-branch') {
|
|
369
|
+
const tier = args[1];
|
|
370
|
+
const version = args[2];
|
|
371
|
+
if (!tier || !version) { console.error('Usage: tier-manager.cjs release-branch <tier> <version>'); process.exit(1); }
|
|
372
|
+
const branch = getReleaseBranchName(tier, version);
|
|
373
|
+
console.log(JSON.stringify({ branch }));
|
|
374
|
+
} else if (cmd === 'check-coverage') {
|
|
375
|
+
const tier = args[1];
|
|
376
|
+
const coverage = parseFloat(args[2]);
|
|
377
|
+
if (!tier || isNaN(coverage)) { console.error('Usage: tier-manager.cjs check-coverage <tier> <pct>'); process.exit(1); }
|
|
378
|
+
console.log(JSON.stringify(checkCoverage(tier, coverage), null, 2));
|
|
379
|
+
} else if (cmd === 'build-config') {
|
|
380
|
+
const tier = args[1] || 'mvp';
|
|
381
|
+
console.log(JSON.stringify(buildReleaseConfig(tier), null, 2));
|
|
382
|
+
} else if (cmd === 'load-tier') {
|
|
383
|
+
const configPath = args[1] || '.planning/config.json';
|
|
384
|
+
console.log(JSON.stringify({ tier: loadCurrentTier(configPath) }));
|
|
385
|
+
} else if (cmd === 'save-tier') {
|
|
386
|
+
const tier = args[1];
|
|
387
|
+
const configPath = args[2] || '.planning/config.json';
|
|
388
|
+
if (!tier) { console.error('Usage: tier-manager.cjs save-tier <tier> [config-path]'); process.exit(1); }
|
|
389
|
+
saveCurrentTier(configPath, tier);
|
|
390
|
+
console.log(JSON.stringify({ saved: true, tier }));
|
|
391
|
+
} else if (cmd === 'validate') {
|
|
392
|
+
const tier = args[1];
|
|
393
|
+
if (!tier) { console.error('Usage: tier-manager.cjs validate <tier> [--coverage N]'); process.exit(1); }
|
|
394
|
+
const coverageIdx = args.indexOf('--coverage');
|
|
395
|
+
const checks = {
|
|
396
|
+
coverage: coverageIdx !== -1 ? parseFloat(args[coverageIdx + 1]) : undefined
|
|
397
|
+
};
|
|
398
|
+
console.log(JSON.stringify(validateRelease(tier, checks), null, 2));
|
|
399
|
+
} else {
|
|
400
|
+
console.error(`Unknown command: ${cmd}`);
|
|
401
|
+
console.error('Commands: get, all, git-strategy, release-branch, check-coverage, build-config, load-tier, save-tier, validate');
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
console.error(`Error: ${err.message}`);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
module.exports = {
|
|
411
|
+
getTier,
|
|
412
|
+
getAllTiers,
|
|
413
|
+
isValidTier,
|
|
414
|
+
getTierIndex,
|
|
415
|
+
isPromotion,
|
|
416
|
+
getPreviousTier,
|
|
417
|
+
getGitStrategy,
|
|
418
|
+
getReleaseBranchName,
|
|
419
|
+
getHotfixBranchName,
|
|
420
|
+
checkCoverage,
|
|
421
|
+
getFeatureFlagConfig,
|
|
422
|
+
buildReleaseConfig,
|
|
423
|
+
loadCurrentTier,
|
|
424
|
+
saveCurrentTier,
|
|
425
|
+
validateRelease,
|
|
426
|
+
TIER_DEFINITIONS,
|
|
427
|
+
TIER_ORDER
|
|
428
|
+
};
|