@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,458 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discussion Synthesizer — Reads DISCUSSION.md, extracts consensus and blockers
|
|
5
|
+
*
|
|
6
|
+
* Parses the multi-agent DISCUSSION.md format to extract:
|
|
7
|
+
* - Hard blockers from any agent
|
|
8
|
+
* - Warnings and advisory notes
|
|
9
|
+
* - Consensus status (open | consensus-reached | needs-human)
|
|
10
|
+
* - Go/No-Go recommendation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { withLock } = require('./file-lock.cjs');
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────
|
|
20
|
+
// Parser
|
|
21
|
+
// ─────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse DISCUSSION.md file
|
|
25
|
+
* @param {string} filePath
|
|
26
|
+
* @returns {{ frontmatter: object, sections: object[], consensus: object, blockers: string[], warnings: string[] }}
|
|
27
|
+
*/
|
|
28
|
+
function parseDiscussion(filePath) {
|
|
29
|
+
if (!fs.existsSync(filePath)) {
|
|
30
|
+
return {
|
|
31
|
+
found: false,
|
|
32
|
+
filePath,
|
|
33
|
+
frontmatter: {},
|
|
34
|
+
sections: [],
|
|
35
|
+
consensus: { status: 'open', goNoGo: 'GO', rationale: 'No discussion file — proceeding' },
|
|
36
|
+
blockers: [],
|
|
37
|
+
warnings: []
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
42
|
+
const lines = content.split('\n');
|
|
43
|
+
|
|
44
|
+
// Parse YAML frontmatter
|
|
45
|
+
const frontmatter = parseFrontmatter(content);
|
|
46
|
+
|
|
47
|
+
// Parse agent sections
|
|
48
|
+
const sections = parseAgentSections(lines);
|
|
49
|
+
|
|
50
|
+
// Extract blockers and warnings from all sections
|
|
51
|
+
const blockers = [];
|
|
52
|
+
const warnings = [];
|
|
53
|
+
|
|
54
|
+
for (const section of sections) {
|
|
55
|
+
const sectionBlockers = extractBlockers(section.content);
|
|
56
|
+
const sectionWarnings = extractWarnings(section.content);
|
|
57
|
+
blockers.push(...sectionBlockers.map(b => ({ agent: section.agent, text: b })));
|
|
58
|
+
warnings.push(...sectionWarnings.map(w => ({ agent: section.agent, text: w })));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Parse consensus section
|
|
62
|
+
const consensus = parseConsensus(sections);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
found: true,
|
|
66
|
+
filePath,
|
|
67
|
+
frontmatter,
|
|
68
|
+
sections,
|
|
69
|
+
consensus,
|
|
70
|
+
blockers,
|
|
71
|
+
warnings,
|
|
72
|
+
hasBlockers: blockers.length > 0,
|
|
73
|
+
hasWarnings: warnings.length > 0
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse YAML frontmatter from discussion file
|
|
79
|
+
*/
|
|
80
|
+
function parseFrontmatter(content) {
|
|
81
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
82
|
+
if (!match) return {};
|
|
83
|
+
|
|
84
|
+
const fm = {};
|
|
85
|
+
const lines = match[1].split('\n');
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const colonIdx = line.indexOf(':');
|
|
88
|
+
if (colonIdx === -1) continue;
|
|
89
|
+
const key = line.slice(0, colonIdx).trim();
|
|
90
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
91
|
+
// Remove quotes
|
|
92
|
+
fm[key] = value.replace(/^['"]|['"]$/g, '');
|
|
93
|
+
}
|
|
94
|
+
return fm;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse agent sections from DISCUSSION.md
|
|
99
|
+
* Returns array of { agent, heading, content }
|
|
100
|
+
*/
|
|
101
|
+
function parseAgentSections(lines) {
|
|
102
|
+
const sections = [];
|
|
103
|
+
let currentSection = null;
|
|
104
|
+
let currentContent = [];
|
|
105
|
+
|
|
106
|
+
const agentHeadings = [
|
|
107
|
+
{ pattern: /## Requirements Perspective/, agent: 'requirements' },
|
|
108
|
+
{ pattern: /## Tech Lead Perspective/, agent: 'tech-lead' },
|
|
109
|
+
{ pattern: /## Observer Perspective/, agent: 'observer' },
|
|
110
|
+
{ pattern: /## Scrum Master Perspective/, agent: 'scrum-master' },
|
|
111
|
+
{ pattern: /## Consensus/, agent: 'consensus' }
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const line = lines[i];
|
|
116
|
+
|
|
117
|
+
// Check if line starts a known section
|
|
118
|
+
const matchedHeading = agentHeadings.find(h => h.pattern.test(line));
|
|
119
|
+
|
|
120
|
+
if (matchedHeading) {
|
|
121
|
+
// Save previous section
|
|
122
|
+
if (currentSection) {
|
|
123
|
+
sections.push({
|
|
124
|
+
agent: currentSection.agent,
|
|
125
|
+
heading: currentSection.heading,
|
|
126
|
+
content: currentContent.join('\n').trim()
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
currentSection = { agent: matchedHeading.agent, heading: line };
|
|
130
|
+
currentContent = [];
|
|
131
|
+
} else if (currentSection) {
|
|
132
|
+
currentContent.push(line);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Push last section
|
|
137
|
+
if (currentSection) {
|
|
138
|
+
sections.push({
|
|
139
|
+
agent: currentSection.agent,
|
|
140
|
+
heading: currentSection.heading,
|
|
141
|
+
content: currentContent.join('\n').trim()
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return sections;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Extract blocker statements from section content
|
|
150
|
+
* Looks for BLOCKER markers used by agents
|
|
151
|
+
*/
|
|
152
|
+
function extractBlockers(content) {
|
|
153
|
+
const blockers = [];
|
|
154
|
+
const lines = content.split('\n');
|
|
155
|
+
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
if (line.includes('🛑') || line.includes('BLOCKER') || line.match(/\*\*BLOCKER/i)) {
|
|
158
|
+
// Extract the description after the marker
|
|
159
|
+
const cleaned = line
|
|
160
|
+
.replace(/[🛑*]/g, '')
|
|
161
|
+
.replace(/BLOCKER\s*—?\s*/i, '')
|
|
162
|
+
.trim();
|
|
163
|
+
if (cleaned && cleaned.length > 3) {
|
|
164
|
+
blockers.push(cleaned);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return blockers;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Extract warning statements from section content
|
|
174
|
+
*/
|
|
175
|
+
function extractWarnings(content) {
|
|
176
|
+
const warnings = [];
|
|
177
|
+
const lines = content.split('\n');
|
|
178
|
+
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
if (line.includes('⚠️') || line.match(/\*\*WARNING/i)) {
|
|
181
|
+
const cleaned = line
|
|
182
|
+
.replace(/[⚠️*]/g, '')
|
|
183
|
+
.replace(/WARNING\s*—?\s*/i, '')
|
|
184
|
+
.trim();
|
|
185
|
+
if (cleaned && cleaned.length > 3) {
|
|
186
|
+
warnings.push(cleaned);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return warnings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse consensus section for Go/No-Go status
|
|
196
|
+
*/
|
|
197
|
+
function parseConsensus(sections) {
|
|
198
|
+
const consensusSection = sections.find(s => s.agent === 'consensus');
|
|
199
|
+
|
|
200
|
+
if (!consensusSection) {
|
|
201
|
+
return { status: 'open', goNoGo: 'GO', rationale: 'No consensus section yet' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const content = consensusSection.content;
|
|
205
|
+
|
|
206
|
+
// Extract Go/No-Go
|
|
207
|
+
let goNoGo = 'GO';
|
|
208
|
+
if (content.match(/NO-GO/i)) goNoGo = 'NO-GO';
|
|
209
|
+
else if (content.match(/HUMAN-NEEDED/i)) goNoGo = 'HUMAN-NEEDED';
|
|
210
|
+
else if (content.match(/^.*GO.*$/m)) goNoGo = 'GO';
|
|
211
|
+
|
|
212
|
+
// Extract status
|
|
213
|
+
let status = 'open';
|
|
214
|
+
if (content.match(/consensus-reached/i) || goNoGo !== 'open') status = 'consensus-reached';
|
|
215
|
+
if (goNoGo === 'HUMAN-NEEDED') status = 'needs-human';
|
|
216
|
+
|
|
217
|
+
// Extract rationale
|
|
218
|
+
const rationaleMatch = content.match(/### Rationale\n([^\n]+)/);
|
|
219
|
+
const rationale = rationaleMatch ? rationaleMatch[1].trim() : '';
|
|
220
|
+
|
|
221
|
+
return { status, goNoGo, rationale };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─────────────────────────────────────────────
|
|
225
|
+
// Synthesis
|
|
226
|
+
// ─────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Synthesize discussion into orchestrator-ready decision
|
|
230
|
+
* @param {string} discussionPath - Path to DISCUSSION.md
|
|
231
|
+
* @returns {{ proceed: boolean, reason: string, blockers: object[], warnings: object[], score: object }}
|
|
232
|
+
*/
|
|
233
|
+
function synthesize(discussionPath) {
|
|
234
|
+
const discussion = parseDiscussion(discussionPath);
|
|
235
|
+
|
|
236
|
+
if (!discussion.found) {
|
|
237
|
+
return {
|
|
238
|
+
proceed: true,
|
|
239
|
+
reason: 'No discussion file — no pre-flight concerns',
|
|
240
|
+
blockers: [],
|
|
241
|
+
warnings: [],
|
|
242
|
+
agentsParticipated: []
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const hasBlockers = discussion.blockers.length > 0;
|
|
247
|
+
const consensusGoNoGo = discussion.consensus.goNoGo;
|
|
248
|
+
|
|
249
|
+
// Determine whether to proceed
|
|
250
|
+
let proceed = true;
|
|
251
|
+
let reason = '';
|
|
252
|
+
|
|
253
|
+
if (hasBlockers || consensusGoNoGo === 'NO-GO') {
|
|
254
|
+
proceed = false;
|
|
255
|
+
reason = hasBlockers
|
|
256
|
+
? `${discussion.blockers.length} blocker(s) must be resolved before execution`
|
|
257
|
+
: 'Consensus is NO-GO';
|
|
258
|
+
} else if (consensusGoNoGo === 'HUMAN-NEEDED') {
|
|
259
|
+
proceed = false;
|
|
260
|
+
reason = 'Human input required before proceeding';
|
|
261
|
+
} else {
|
|
262
|
+
proceed = true;
|
|
263
|
+
reason = discussion.warnings.length > 0
|
|
264
|
+
? `${discussion.warnings.length} warning(s) — proceeding with awareness`
|
|
265
|
+
: 'No blockers found — proceeding';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const agentsParticipated = discussion.sections
|
|
269
|
+
.filter(s => s.agent !== 'consensus')
|
|
270
|
+
.filter(s => !s.content.includes('{Populated') && s.content.trim().length > 20)
|
|
271
|
+
.map(s => s.agent);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
proceed,
|
|
275
|
+
reason,
|
|
276
|
+
blockers: discussion.blockers,
|
|
277
|
+
warnings: discussion.warnings,
|
|
278
|
+
consensus: discussion.consensus,
|
|
279
|
+
agentsParticipated,
|
|
280
|
+
frontmatter: discussion.frontmatter
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Check if a discussion file needs updating (agents haven't written yet)
|
|
286
|
+
* @param {string} discussionPath
|
|
287
|
+
* @returns {{ needsObserver: boolean, needsTechLead: boolean, needsScrumMaster: boolean }}
|
|
288
|
+
*/
|
|
289
|
+
function checkParticipation(discussionPath) {
|
|
290
|
+
const discussion = parseDiscussion(discussionPath);
|
|
291
|
+
|
|
292
|
+
if (!discussion.found) {
|
|
293
|
+
return { needsObserver: true, needsTechLead: true, needsScrumMaster: true };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const populated = (agent) => {
|
|
297
|
+
const section = discussion.sections.find(s => s.agent === agent);
|
|
298
|
+
return section && !section.content.includes('{Populated') && section.content.trim().length > 20;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
needsObserver: !populated('observer'),
|
|
303
|
+
needsTechLead: !populated('tech-lead'),
|
|
304
|
+
needsScrumMaster: !populated('scrum-master'),
|
|
305
|
+
needsRequirements: !populated('requirements')
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Update consensus section in DISCUSSION.md
|
|
311
|
+
* @param {string} discussionPath
|
|
312
|
+
* @param {object} consensusData - { goNoGo, blockers, warnings, rationale }
|
|
313
|
+
*/
|
|
314
|
+
async function updateConsensus(discussionPath, consensusData) {
|
|
315
|
+
if (!fs.existsSync(discussionPath)) return false;
|
|
316
|
+
return withLock(discussionPath, async () => {
|
|
317
|
+
const content = fs.readFileSync(discussionPath, 'utf8');
|
|
318
|
+
|
|
319
|
+
const blockerList = consensusData.blockers.length > 0
|
|
320
|
+
? consensusData.blockers.map(b => `- 🛑 ${b.agent}: ${b.text}`).join('\n')
|
|
321
|
+
: 'None';
|
|
322
|
+
|
|
323
|
+
const warningList = consensusData.warnings.length > 0
|
|
324
|
+
? consensusData.warnings.slice(0, 5).map(w => `- ⚠️ ${w.agent}: ${w.text}`).join('\n')
|
|
325
|
+
: 'None';
|
|
326
|
+
|
|
327
|
+
const now = new Date().toISOString();
|
|
328
|
+
const status = consensusData.goNoGo === 'GO'
|
|
329
|
+
? 'consensus-reached'
|
|
330
|
+
: consensusData.goNoGo === 'HUMAN-NEEDED' ? 'needs-human' : 'consensus-reached';
|
|
331
|
+
|
|
332
|
+
const consensusSection = `## Consensus
|
|
333
|
+
|
|
334
|
+
> *Synthesized by orchestrator from above perspectives*
|
|
335
|
+
|
|
336
|
+
**Status:** ${status}
|
|
337
|
+
|
|
338
|
+
### Blockers
|
|
339
|
+
${blockerList}
|
|
340
|
+
|
|
341
|
+
### Key Warnings
|
|
342
|
+
${warningList}
|
|
343
|
+
|
|
344
|
+
### Go / No-Go
|
|
345
|
+
${consensusData.goNoGo} — ${consensusData.rationale}
|
|
346
|
+
|
|
347
|
+
### Rationale
|
|
348
|
+
${consensusData.rationale}
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
*Discussion opened: {timestamp}*
|
|
353
|
+
*Last updated: ${now}*`;
|
|
354
|
+
|
|
355
|
+
// Replace existing consensus section or append
|
|
356
|
+
let updated;
|
|
357
|
+
if (content.includes('## Consensus')) {
|
|
358
|
+
updated = content.replace(/## Consensus[\s\S]*$/, consensusSection);
|
|
359
|
+
} else {
|
|
360
|
+
updated = content + '\n\n' + consensusSection;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Also update frontmatter status
|
|
364
|
+
updated = updated.replace(/^status: .*$/m, `status: ${status}`);
|
|
365
|
+
|
|
366
|
+
fs.writeFileSync(discussionPath, updated, 'utf8');
|
|
367
|
+
return true;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Format synthesis result as human-readable text
|
|
373
|
+
* @param {object} result - From synthesize()
|
|
374
|
+
* @returns {string}
|
|
375
|
+
*/
|
|
376
|
+
function formatSynthesis(result) {
|
|
377
|
+
const lines = [];
|
|
378
|
+
|
|
379
|
+
lines.push(`## Pre-Flight Discussion Summary`);
|
|
380
|
+
lines.push(`**Decision:** ${result.proceed ? '✓ GO — proceed to execution' : '✗ ' + (result.consensus && result.consensus.goNoGo === 'HUMAN-NEEDED' ? 'HUMAN-NEEDED' : 'NO-GO')}`);
|
|
381
|
+
lines.push(`**Reason:** ${result.reason}`);
|
|
382
|
+
lines.push(`**Agents participated:** ${result.agentsParticipated.join(', ') || 'none'}`);
|
|
383
|
+
|
|
384
|
+
if (result.blockers.length > 0) {
|
|
385
|
+
lines.push('');
|
|
386
|
+
lines.push('### Blockers (must resolve)');
|
|
387
|
+
for (const b of result.blockers) {
|
|
388
|
+
lines.push(`- 🛑 **${b.agent}:** ${b.text}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (result.warnings.length > 0) {
|
|
393
|
+
lines.push('');
|
|
394
|
+
lines.push('### Warnings (advisory)');
|
|
395
|
+
for (const w of result.warnings.slice(0, 5)) {
|
|
396
|
+
lines.push(`- ⚠️ **${w.agent}:** ${w.text}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return lines.join('\n');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─────────────────────────────────────────────
|
|
404
|
+
// CLI Interface
|
|
405
|
+
// ─────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
if (require.main === module) {
|
|
408
|
+
const args = process.argv.slice(2);
|
|
409
|
+
const cmd = args[0];
|
|
410
|
+
const discussionPath = args[1];
|
|
411
|
+
|
|
412
|
+
if (!cmd) {
|
|
413
|
+
console.error('Usage: discussion-synthesizer.cjs <synthesize|check-participation|update-consensus> <path> [options]');
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
(async () => {
|
|
418
|
+
try {
|
|
419
|
+
if (cmd === 'synthesize') {
|
|
420
|
+
if (!discussionPath) { console.error('Path required'); process.exit(1); }
|
|
421
|
+
const result = synthesize(discussionPath);
|
|
422
|
+
if (args.includes('--json')) {
|
|
423
|
+
console.log(JSON.stringify(result, null, 2));
|
|
424
|
+
} else {
|
|
425
|
+
console.log(formatSynthesis(result));
|
|
426
|
+
process.exit(result.proceed ? 0 : 1);
|
|
427
|
+
}
|
|
428
|
+
} else if (cmd === 'check-participation') {
|
|
429
|
+
if (!discussionPath) { console.error('Path required'); process.exit(1); }
|
|
430
|
+
const result = checkParticipation(discussionPath);
|
|
431
|
+
console.log(JSON.stringify(result, null, 2));
|
|
432
|
+
} else if (cmd === 'update-consensus') {
|
|
433
|
+
if (!discussionPath) { console.error('Path required'); process.exit(1); }
|
|
434
|
+
const dataArg = args[2];
|
|
435
|
+
if (!dataArg) { console.error('Consensus data JSON required'); process.exit(1); }
|
|
436
|
+
const data = JSON.parse(dataArg);
|
|
437
|
+
const ok = await updateConsensus(discussionPath, data);
|
|
438
|
+
console.log(JSON.stringify({ updated: ok }));
|
|
439
|
+
} else {
|
|
440
|
+
console.error(`Unknown command: ${cmd}`);
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
} catch (err) {
|
|
444
|
+
console.error(`Error: ${err.message}`);
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
447
|
+
})();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
module.exports = {
|
|
451
|
+
parseDiscussion,
|
|
452
|
+
synthesize,
|
|
453
|
+
checkParticipation,
|
|
454
|
+
updateConsensus,
|
|
455
|
+
formatSynthesis,
|
|
456
|
+
extractBlockers,
|
|
457
|
+
extractWarnings
|
|
458
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* File Access Service
|
|
5
|
+
*
|
|
6
|
+
* Provides file reading capabilities with glob pattern support.
|
|
7
|
+
* Uses micromatch for glob matching with support for negation and brace expansion.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const micromatch = require('micromatch');
|
|
13
|
+
const { FileAccessError } = require('./context-errors.cjs');
|
|
14
|
+
|
|
15
|
+
const MAX_FILE_COUNT = 1000;
|
|
16
|
+
|
|
17
|
+
class FileAccessService {
|
|
18
|
+
/**
|
|
19
|
+
* Create a new FileAccessService instance
|
|
20
|
+
* @param {string} cwd - Current working directory (defaults to process.cwd())
|
|
21
|
+
*/
|
|
22
|
+
constructor(cwd) {
|
|
23
|
+
this.cwd = cwd || process.cwd();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read files matching patterns
|
|
28
|
+
* @param {string|string[]} patterns - File patterns (glob or single path)
|
|
29
|
+
* @returns {Array<{path: string, content: string}>} - Array of file objects
|
|
30
|
+
* @throws {FileAccessError} - On file access errors
|
|
31
|
+
*/
|
|
32
|
+
readFiles(patterns) {
|
|
33
|
+
const patternArray = Array.isArray(patterns) ? patterns : [patterns];
|
|
34
|
+
|
|
35
|
+
// Get all files recursively
|
|
36
|
+
const allFiles = this._getAllFiles(this.cwd);
|
|
37
|
+
|
|
38
|
+
// Convert paths to relative paths from cwd
|
|
39
|
+
const relativeFiles = allFiles.map(f => {
|
|
40
|
+
const relPath = path.relative(this.cwd, f);
|
|
41
|
+
// Convert to POSIX style paths for glob matching
|
|
42
|
+
return relPath.replace(/\\/g, '/');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Filter with micromatch
|
|
46
|
+
const matchedFiles = micromatch.match(relativeFiles, patternArray, {
|
|
47
|
+
dot: false, // Don't match hidden files/directories by default
|
|
48
|
+
nocase: false
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Check max file count
|
|
52
|
+
if (matchedFiles.length > MAX_FILE_COUNT) {
|
|
53
|
+
throw new FileAccessError(
|
|
54
|
+
matchedFiles[0],
|
|
55
|
+
`Max file count exceeded: ${matchedFiles.length} > ${MAX_FILE_COUNT}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Read file contents
|
|
60
|
+
const results = matchedFiles.map(filePath => {
|
|
61
|
+
const fullPath = path.join(this.cwd, filePath);
|
|
62
|
+
|
|
63
|
+
if (!fs.existsSync(fullPath)) {
|
|
64
|
+
throw new FileAccessError(filePath, 'File not found');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
69
|
+
return {
|
|
70
|
+
path: filePath,
|
|
71
|
+
content
|
|
72
|
+
};
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err.code === 'EACCES') {
|
|
75
|
+
throw new FileAccessError(filePath, 'Permission denied');
|
|
76
|
+
}
|
|
77
|
+
throw new FileAccessError(filePath, err.message);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get all files recursively from a directory
|
|
86
|
+
* @param {string} dir - Directory to scan
|
|
87
|
+
* @returns {string[]} - Array of file paths
|
|
88
|
+
* @private
|
|
89
|
+
*/
|
|
90
|
+
_getAllFiles(dir) {
|
|
91
|
+
const files = [];
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
95
|
+
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const fullPath = path.join(dir, entry.name);
|
|
98
|
+
|
|
99
|
+
// Skip hidden directories (starting with .)
|
|
100
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
101
|
+
const subFiles = this._getAllFiles(fullPath);
|
|
102
|
+
files.push(...subFiles);
|
|
103
|
+
} else if (entry.isFile()) {
|
|
104
|
+
files.push(fullPath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// Ignore permission errors during directory traversal
|
|
109
|
+
if (err.code !== 'EACCES') {
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return files;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalize a file path (convert Windows paths to Unix style)
|
|
119
|
+
* @param {string} filePath - The path to normalize
|
|
120
|
+
* @returns {string} - Normalized path
|
|
121
|
+
* @throws {FileAccessError} - On path traversal attempts
|
|
122
|
+
*/
|
|
123
|
+
normalizePath(filePath) {
|
|
124
|
+
// Convert Windows backslashes to forward slashes
|
|
125
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
126
|
+
|
|
127
|
+
// Check for path traversal attempts
|
|
128
|
+
if (normalized.includes('..')) {
|
|
129
|
+
throw new FileAccessError(filePath, 'Path traversal not allowed');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return normalized;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validate a file path
|
|
137
|
+
* @param {string} filePath - The path to validate
|
|
138
|
+
* @returns {boolean} - True if valid
|
|
139
|
+
*/
|
|
140
|
+
validatePath(filePath) {
|
|
141
|
+
// Reject paths with null bytes
|
|
142
|
+
if (filePath.includes('\x00')) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Resolve the path
|
|
147
|
+
const resolvedPath = path.resolve(this.cwd, filePath);
|
|
148
|
+
|
|
149
|
+
// Check if path is within cwd (prevent access outside project)
|
|
150
|
+
const normalizedCwd = path.resolve(this.cwd).replace(/\\/g, '/');
|
|
151
|
+
const normalizedResolved = resolvedPath.replace(/\\/g, '/');
|
|
152
|
+
|
|
153
|
+
// Path must be within or equal to cwd
|
|
154
|
+
if (!normalizedResolved.startsWith(normalizedCwd)) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check if a file exists
|
|
163
|
+
* @param {string} filePath - Path to check
|
|
164
|
+
* @returns {boolean} - True if file exists
|
|
165
|
+
*/
|
|
166
|
+
fileExists(filePath) {
|
|
167
|
+
const fullPath = path.join(this.cwd, filePath);
|
|
168
|
+
return fs.existsSync(fullPath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Read a single file
|
|
173
|
+
* @param {string} filePath - Path to the file
|
|
174
|
+
* @returns {{path: string, content: string}} - File object
|
|
175
|
+
* @throws {FileAccessError} - On file access errors
|
|
176
|
+
*/
|
|
177
|
+
readFile(filePath) {
|
|
178
|
+
const results = this.readFiles([filePath]);
|
|
179
|
+
return results[0] || null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get file info (size, modified time, etc.)
|
|
184
|
+
* @param {string} filePath - Path to the file
|
|
185
|
+
* @returns {{path: string, size: number, mtime: Date}} - File info
|
|
186
|
+
* @throws {FileAccessError} - On file access errors
|
|
187
|
+
*/
|
|
188
|
+
getFileInfo(filePath) {
|
|
189
|
+
const fullPath = path.join(this.cwd, filePath);
|
|
190
|
+
|
|
191
|
+
if (!fs.existsSync(fullPath)) {
|
|
192
|
+
throw new FileAccessError(filePath, 'File not found');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const stats = fs.statSync(fullPath);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
path: filePath,
|
|
199
|
+
size: stats.size,
|
|
200
|
+
mtime: stats.mtime,
|
|
201
|
+
isDirectory: stats.isDirectory(),
|
|
202
|
+
isFile: stats.isFile()
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = FileAccessService;
|