convoke-agents 3.0.4 → 3.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/CHANGELOG.md +29 -0
- package/README.md +14 -13
- package/_bmad/bme/_gyre/guides/GYRE-TEAM-GUIDE.md +506 -0
- package/_bmad/bme/_vortex/config.yaml +1 -1
- package/_bmad/bme/_vortex/guides/VORTEX-TEAM-GUIDE.md +441 -0
- package/package.json +7 -3
- package/scripts/archive.js +26 -45
- package/scripts/convoke-check.js +88 -0
- package/scripts/convoke-doctor.js +132 -4
- package/scripts/lib/artifact-utils.js +1674 -0
- package/scripts/lib/portfolio/formatters/markdown-formatter.js +40 -0
- package/scripts/lib/portfolio/formatters/terminal-formatter.js +56 -0
- package/scripts/lib/portfolio/portfolio-engine.js +305 -0
- package/scripts/lib/portfolio/rules/artifact-chain-rule.js +126 -0
- package/scripts/lib/portfolio/rules/conflict-resolver.js +77 -0
- package/scripts/lib/portfolio/rules/frontmatter-rule.js +42 -0
- package/scripts/lib/portfolio/rules/git-recency-rule.js +69 -0
- package/scripts/lib/types.js +122 -0
- package/scripts/migrate-artifacts.js +380 -0
- package/scripts/update/lib/migration-runner.js +1 -1
- package/scripts/update/lib/taxonomy-merger.js +138 -0
- package/scripts/update/migrations/2.0.x-to-3.1.0.js +50 -0
- package/scripts/update/migrations/3.0.x-to-3.1.0.js +41 -0
- package/scripts/update/migrations/registry.js +14 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown formatter — standard markdown table output with confidence markers.
|
|
3
|
+
*
|
|
4
|
+
* @module markdown-formatter
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format InitiativeState array as markdown table.
|
|
9
|
+
*
|
|
10
|
+
* @param {import('../../types').InitiativeState[]} initiatives
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function formatMarkdown(initiatives) {
|
|
14
|
+
if (initiatives.length === 0) {
|
|
15
|
+
return 'No initiatives found.\n';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const lines = [];
|
|
19
|
+
lines.push('| Initiative | Phase | Status | Next Action / Context |');
|
|
20
|
+
lines.push('|------------|-------|--------|----------------------|');
|
|
21
|
+
|
|
22
|
+
for (const s of initiatives) {
|
|
23
|
+
const phase = s.phase.value || 'unknown';
|
|
24
|
+
const statusVal = s.status.value || 'unknown';
|
|
25
|
+
const conf = s.status.confidence === 'explicit' ? '(explicit)' : '(inferred)';
|
|
26
|
+
const status = `${statusVal} ${conf}`;
|
|
27
|
+
|
|
28
|
+
const context = s.nextAction.value
|
|
29
|
+
? s.nextAction.value
|
|
30
|
+
: s.lastArtifact.file
|
|
31
|
+
? `Last: ${s.lastArtifact.file} (${s.lastArtifact.date || '?'})`
|
|
32
|
+
: 'No artifacts';
|
|
33
|
+
|
|
34
|
+
lines.push(`| ${s.initiative} | ${phase} | ${status} | ${context} |`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return lines.join('\n') + '\n';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { formatMarkdown };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal formatter — aligned column output with confidence markers.
|
|
3
|
+
* No library used — padEnd() for alignment.
|
|
4
|
+
*
|
|
5
|
+
* @module terminal-formatter
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Format InitiativeState array as aligned terminal table.
|
|
10
|
+
*
|
|
11
|
+
* @param {import('../../types').InitiativeState[]} initiatives
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function formatTerminal(initiatives) {
|
|
15
|
+
if (initiatives.length === 0) {
|
|
16
|
+
return 'No initiatives found.';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Dynamic init column width: at least 14, grows for long names
|
|
20
|
+
const maxInitLen = Math.max(14, ...initiatives.map(s => s.initiative.length + 2));
|
|
21
|
+
const COL = { init: maxInitLen, phase: 12, status: 24, action: 50 };
|
|
22
|
+
const lines = [];
|
|
23
|
+
|
|
24
|
+
// Header
|
|
25
|
+
lines.push(
|
|
26
|
+
'Initiative'.padEnd(COL.init) +
|
|
27
|
+
'Phase'.padEnd(COL.phase) +
|
|
28
|
+
'Status'.padEnd(COL.status) +
|
|
29
|
+
'Next Action / Context'
|
|
30
|
+
);
|
|
31
|
+
lines.push('-'.repeat(COL.init + COL.phase + COL.status + COL.action));
|
|
32
|
+
|
|
33
|
+
for (const s of initiatives) {
|
|
34
|
+
const phase = s.phase.value || 'unknown';
|
|
35
|
+
const statusVal = s.status.value || 'unknown';
|
|
36
|
+
const conf = s.status.confidence === 'explicit' ? '(explicit)' : '(inferred)';
|
|
37
|
+
const status = `${statusVal} ${conf}`;
|
|
38
|
+
|
|
39
|
+
const context = s.nextAction.value
|
|
40
|
+
? s.nextAction.value
|
|
41
|
+
: s.lastArtifact.file
|
|
42
|
+
? `Last: ${s.lastArtifact.file} (${s.lastArtifact.date || '?'})`
|
|
43
|
+
: 'No artifacts';
|
|
44
|
+
|
|
45
|
+
lines.push(
|
|
46
|
+
s.initiative.padEnd(COL.init) +
|
|
47
|
+
phase.padEnd(COL.phase) +
|
|
48
|
+
status.padEnd(COL.status) +
|
|
49
|
+
context
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { formatTerminal };
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convoke Portfolio Engine — scan → parse → infer → sort → format → output.
|
|
5
|
+
* Read-only: no git writes, no file modifications.
|
|
6
|
+
*
|
|
7
|
+
* @module portfolio-engine
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs-extra');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const {
|
|
13
|
+
readTaxonomy,
|
|
14
|
+
scanArtifactDirs,
|
|
15
|
+
parseFrontmatter,
|
|
16
|
+
inferArtifactType,
|
|
17
|
+
inferInitiative
|
|
18
|
+
} = require('../artifact-utils');
|
|
19
|
+
const { findProjectRoot } = require('../../update/lib/utils');
|
|
20
|
+
const { applyFrontmatterRule } = require('./rules/frontmatter-rule');
|
|
21
|
+
const { applyArtifactChainRule } = require('./rules/artifact-chain-rule');
|
|
22
|
+
const { applyGitRecencyRule } = require('./rules/git-recency-rule');
|
|
23
|
+
const { applyConflictResolver } = require('./rules/conflict-resolver');
|
|
24
|
+
const { formatTerminal } = require('./formatters/terminal-formatter');
|
|
25
|
+
const { formatMarkdown } = require('./formatters/markdown-formatter');
|
|
26
|
+
|
|
27
|
+
/** Directories to exclude from portfolio scan */
|
|
28
|
+
const EXCLUDE_DIRS = ['_archive', 'brainstorming', 'design-artifacts', 'journey-examples', 'project-documentation', 'test-artifacts', 'drafts'];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create an empty InitiativeState for a given initiative.
|
|
32
|
+
* @param {string} initiative - Initiative ID
|
|
33
|
+
* @returns {import('../types').InitiativeState}
|
|
34
|
+
*/
|
|
35
|
+
function makeEmptyState(initiative) {
|
|
36
|
+
return {
|
|
37
|
+
initiative,
|
|
38
|
+
phase: { value: null, source: null, confidence: null },
|
|
39
|
+
status: { value: null, source: null, confidence: null },
|
|
40
|
+
lastArtifact: { file: null, date: null },
|
|
41
|
+
nextAction: { value: null, source: null }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generate portfolio view of all initiatives.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
49
|
+
* @param {Object} [options={}]
|
|
50
|
+
* @param {string} [options.sort='alpha'] - Sort mode: 'alpha' or 'last-activity'
|
|
51
|
+
* @param {number} [options.staleDays=30] - Days threshold for stale detection
|
|
52
|
+
* @returns {Promise<{initiatives: import('../types').InitiativeState[], summary: {total: number, governed: number, ungoverned: number}}>}
|
|
53
|
+
*/
|
|
54
|
+
async function generatePortfolio(projectRoot, options = {}) {
|
|
55
|
+
const { sort = 'alpha', staleDays = 30, wipThreshold = 4, filter = null } = options;
|
|
56
|
+
|
|
57
|
+
// Pre-flight: read taxonomy (FR39 — error if absent)
|
|
58
|
+
const taxonomy = readTaxonomy(projectRoot);
|
|
59
|
+
|
|
60
|
+
// Scan: discover subdirectories dynamically
|
|
61
|
+
const outputDir = path.join(projectRoot, '_bmad-output');
|
|
62
|
+
if (!fs.existsSync(outputDir)) {
|
|
63
|
+
console.warn('Warning: _bmad-output/ directory not found.');
|
|
64
|
+
return { initiatives: [], summary: { total: 0, governed: 0, ungoverned: 0 } };
|
|
65
|
+
}
|
|
66
|
+
const allDirs = fs.readdirSync(outputDir, { withFileTypes: true })
|
|
67
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.') && !EXCLUDE_DIRS.includes(e.name))
|
|
68
|
+
.map(e => e.name);
|
|
69
|
+
|
|
70
|
+
const allFiles = await scanArtifactDirs(projectRoot, allDirs, ['_archive']);
|
|
71
|
+
|
|
72
|
+
// Parse: index files by initiative
|
|
73
|
+
const registry = new Map();
|
|
74
|
+
let governed = 0;
|
|
75
|
+
let ungoverned = 0;
|
|
76
|
+
let unattributed = 0;
|
|
77
|
+
|
|
78
|
+
const mdFiles = allFiles.filter(f => f.filename.endsWith('.md'));
|
|
79
|
+
|
|
80
|
+
for (const file of mdFiles) {
|
|
81
|
+
|
|
82
|
+
const typeResult = inferArtifactType(file.filename, taxonomy);
|
|
83
|
+
const initResult = typeResult.type
|
|
84
|
+
? inferInitiative(typeResult.remainder, taxonomy)
|
|
85
|
+
: { initiative: null, confidence: 'low', source: 'no-type', candidates: [] };
|
|
86
|
+
|
|
87
|
+
// Files with no resolved initiative cannot be attributed — skip
|
|
88
|
+
if (!initResult.initiative) {
|
|
89
|
+
unattributed++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Read frontmatter to classify governed vs ungoverned
|
|
94
|
+
let frontmatter = null;
|
|
95
|
+
let content = '';
|
|
96
|
+
try {
|
|
97
|
+
content = fs.readFileSync(file.fullPath, 'utf8');
|
|
98
|
+
frontmatter = parseFrontmatter(content).data;
|
|
99
|
+
} catch {
|
|
100
|
+
// Unreadable — treat as no frontmatter
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Governed = has frontmatter with matching initiative field
|
|
104
|
+
const isGoverned = !!(frontmatter && frontmatter.initiative && frontmatter.initiative === initResult.initiative);
|
|
105
|
+
if (isGoverned) {
|
|
106
|
+
governed++;
|
|
107
|
+
} else {
|
|
108
|
+
ungoverned++;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const enriched = {
|
|
112
|
+
filename: file.filename,
|
|
113
|
+
dir: file.dir,
|
|
114
|
+
fullPath: file.fullPath,
|
|
115
|
+
type: typeResult.type,
|
|
116
|
+
hcPrefix: typeResult.hcPrefix,
|
|
117
|
+
date: typeResult.date,
|
|
118
|
+
initiative: initResult.initiative,
|
|
119
|
+
frontmatter,
|
|
120
|
+
content,
|
|
121
|
+
isGoverned,
|
|
122
|
+
degradedMode: !isGoverned
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (!registry.has(initResult.initiative)) {
|
|
126
|
+
registry.set(initResult.initiative, []);
|
|
127
|
+
}
|
|
128
|
+
registry.get(initResult.initiative).push(enriched);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// FR39: warn if no governed artifacts
|
|
132
|
+
if (governed === 0 && mdFiles.length > 0) {
|
|
133
|
+
console.warn('Warning: No governed artifacts found. Run migration to populate.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Infer: run rule chain for each initiative in taxonomy
|
|
137
|
+
const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
|
|
138
|
+
let results = [];
|
|
139
|
+
|
|
140
|
+
for (const initiative of allInitiatives) {
|
|
141
|
+
const artifacts = registry.get(initiative) || [];
|
|
142
|
+
let state = makeEmptyState(initiative);
|
|
143
|
+
state = applyFrontmatterRule(state, artifacts, { projectRoot });
|
|
144
|
+
state = applyArtifactChainRule(state, artifacts, { projectRoot });
|
|
145
|
+
state = applyGitRecencyRule(state, artifacts, { projectRoot, staleDays });
|
|
146
|
+
state = applyConflictResolver(state, artifacts, { projectRoot });
|
|
147
|
+
results.push(state);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Sort
|
|
151
|
+
if (sort === 'last-activity') {
|
|
152
|
+
results.sort((a, b) => (b.lastArtifact.date || '').localeCompare(a.lastArtifact.date || ''));
|
|
153
|
+
} else {
|
|
154
|
+
results.sort((a, b) => a.initiative.localeCompare(b.initiative));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Filter by initiative prefix (before WIP count)
|
|
158
|
+
if (filter) {
|
|
159
|
+
const prefix = filter.replace(/\*$/, '');
|
|
160
|
+
results = results.filter(s => s.initiative.startsWith(prefix));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// WIP radar: count active initiatives (ongoing, blocked, or stale)
|
|
164
|
+
const activeStatuses = ['ongoing', 'stale', 'blocked'];
|
|
165
|
+
const activeInitiatives = results.filter(s => activeStatuses.includes(s.status.value));
|
|
166
|
+
const wipRadar = activeInitiatives.length > wipThreshold
|
|
167
|
+
? {
|
|
168
|
+
active: activeInitiatives.length,
|
|
169
|
+
threshold: wipThreshold,
|
|
170
|
+
initiatives: activeInitiatives
|
|
171
|
+
.sort((a, b) => (b.lastArtifact.date || '').localeCompare(a.lastArtifact.date || ''))
|
|
172
|
+
.map(s => s.initiative)
|
|
173
|
+
}
|
|
174
|
+
: null;
|
|
175
|
+
|
|
176
|
+
// Calculate governance health score (of attributable files only — excludes unattributed)
|
|
177
|
+
const attributable = governed + ungoverned;
|
|
178
|
+
const healthPercentage = attributable > 0 ? Math.round((governed / attributable) * 100) : 0;
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
initiatives: results,
|
|
182
|
+
wipRadar,
|
|
183
|
+
summary: {
|
|
184
|
+
total: mdFiles.length,
|
|
185
|
+
governed,
|
|
186
|
+
ungoverned,
|
|
187
|
+
unattributed,
|
|
188
|
+
healthScore: { governed, total: attributable, percentage: healthPercentage }
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- CLI ---
|
|
194
|
+
|
|
195
|
+
function printHelp() {
|
|
196
|
+
console.log(`
|
|
197
|
+
Usage: convoke-portfolio [options]
|
|
198
|
+
|
|
199
|
+
Generate a portfolio view of all initiatives from artifact analysis.
|
|
200
|
+
|
|
201
|
+
Options:
|
|
202
|
+
--terminal Terminal table output (default)
|
|
203
|
+
--markdown Markdown table output
|
|
204
|
+
--sort <mode> Sort: alpha (default), last-activity
|
|
205
|
+
--filter <prefix> Filter initiatives by prefix (e.g., --filter gyre)
|
|
206
|
+
--verbose Show inference trace per initiative (source + confidence)
|
|
207
|
+
--help, -h Show this help
|
|
208
|
+
|
|
209
|
+
Examples:
|
|
210
|
+
convoke-portfolio Default terminal view
|
|
211
|
+
convoke-portfolio --markdown Markdown output for chat/docs
|
|
212
|
+
convoke-portfolio --sort last-activity Sort by most recent activity
|
|
213
|
+
`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function main() {
|
|
217
|
+
const args = process.argv.slice(2);
|
|
218
|
+
|
|
219
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
220
|
+
printHelp();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const projectRoot = findProjectRoot();
|
|
225
|
+
if (!projectRoot) {
|
|
226
|
+
console.error('Error: Not in a Convoke project. Could not find _bmad/ directory.');
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const useMarkdown = args.includes('--markdown');
|
|
231
|
+
const useVerbose = args.includes('--verbose');
|
|
232
|
+
const sortMode = args.includes('--sort') && args[args.indexOf('--sort') + 1] === 'last-activity'
|
|
233
|
+
? 'last-activity'
|
|
234
|
+
: 'alpha';
|
|
235
|
+
const filterIdx = args.indexOf('--filter');
|
|
236
|
+
const filterPattern = (filterIdx !== -1 && args[filterIdx + 1] && !args[filterIdx + 1].startsWith('--'))
|
|
237
|
+
? args[filterIdx + 1]
|
|
238
|
+
: null;
|
|
239
|
+
|
|
240
|
+
// Read portfolio config from _bmad/bmm/config.yaml (optional)
|
|
241
|
+
let wipThreshold = 4;
|
|
242
|
+
let staleDays = 30;
|
|
243
|
+
try {
|
|
244
|
+
const yaml = require('js-yaml');
|
|
245
|
+
const configPath = path.join(projectRoot, '_bmad', 'bmm', 'config.yaml');
|
|
246
|
+
if (fs.existsSync(configPath)) {
|
|
247
|
+
const config = yaml.load(fs.readFileSync(configPath, 'utf8'));
|
|
248
|
+
if (config && config.portfolio) {
|
|
249
|
+
const wt = Number(config.portfolio.wip_threshold);
|
|
250
|
+
if (!isNaN(wt)) wipThreshold = wt;
|
|
251
|
+
const sd = Number(config.portfolio.stale_days);
|
|
252
|
+
if (!isNaN(sd)) staleDays = sd;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// Config read failed — use defaults
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const result = await generatePortfolio(projectRoot, {
|
|
261
|
+
sort: sortMode,
|
|
262
|
+
filter: filterPattern,
|
|
263
|
+
wipThreshold,
|
|
264
|
+
staleDays
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const output = useMarkdown
|
|
268
|
+
? formatMarkdown(result.initiatives)
|
|
269
|
+
: formatTerminal(result.initiatives);
|
|
270
|
+
|
|
271
|
+
console.log(output);
|
|
272
|
+
|
|
273
|
+
// WIP radar (only when threshold exceeded)
|
|
274
|
+
if (result.wipRadar) {
|
|
275
|
+
console.log(`\nWIP: ${result.wipRadar.active} active (threshold: ${result.wipRadar.threshold}) -- sorted by last activity`);
|
|
276
|
+
console.log(` ${result.wipRadar.initiatives.join(', ')}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log(`\nTotal: ${result.summary.total} artifacts | Governed: ${result.summary.governed} | Ungoverned: ${result.summary.ungoverned} | Unattributed: ${result.summary.unattributed}`);
|
|
280
|
+
const hs = result.summary.healthScore;
|
|
281
|
+
console.log(`Governance: ${hs.governed}/${hs.total} artifacts governed (${hs.percentage}%)`);
|
|
282
|
+
|
|
283
|
+
// Verbose: inference trace per initiative
|
|
284
|
+
if (useVerbose) {
|
|
285
|
+
console.log('\n--- Inference Trace ---');
|
|
286
|
+
for (const s of result.initiatives) {
|
|
287
|
+
const p = s.phase;
|
|
288
|
+
const st = s.status;
|
|
289
|
+
console.log(` [${s.initiative}] phase: ${p.value} (${p.source}, ${p.confidence}) | status: ${st.value} (${st.source}, ${st.confidence})`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.error(`Error: ${err.message}`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (require.main === module) {
|
|
299
|
+
main().catch(err => {
|
|
300
|
+
console.error(`Error: ${err.message}`);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = { generatePortfolio, makeEmptyState, EXCLUDE_DIRS };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule 2: Infer phase from artifact chain analysis.
|
|
3
|
+
*
|
|
4
|
+
* Priority order (highest first):
|
|
5
|
+
* 1. Epic with all stories done/complete/✅/[x]/strikethrough → complete
|
|
6
|
+
* 2. Epic + sprint artifact → build
|
|
7
|
+
* 3. Architecture doc → planning
|
|
8
|
+
* 4. HC artifacts (HC2-HC6) → discovery
|
|
9
|
+
* 5. PRD or brief only → planning
|
|
10
|
+
* 6. No recognized artifacts → unknown
|
|
11
|
+
*
|
|
12
|
+
* Also detects Vortex HC chain completeness (FR34).
|
|
13
|
+
*
|
|
14
|
+
* @param {import('../../types').InitiativeState} state - Current initiative state
|
|
15
|
+
* @param {Array<{filename: string, dir: string, fullPath: string, type?: string, hcPrefix?: string, date?: string, content?: string}>} artifacts
|
|
16
|
+
* @param {Object} _options - Reserved
|
|
17
|
+
* @returns {import('../../types').InitiativeState} Enriched state
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Patterns that indicate epic completion — require status context to avoid false positives.
|
|
21
|
+
* Matches: "status: done", "epic-1: done", "Status:** done", "- [x]", "✅" */
|
|
22
|
+
const DONE_PATTERNS = [
|
|
23
|
+
/(?:status|epic)[^:]*:\s*done\b/i,
|
|
24
|
+
/(?:status|epic)[^:]*:\s*complete\b/i,
|
|
25
|
+
/\*\*\s*done\b/i, // bold marker: **done**
|
|
26
|
+
/✅/,
|
|
27
|
+
/\[x\]/i,
|
|
28
|
+
/~~[^~]{3,}~~/ // strikethrough (min 3 chars to avoid false matches)
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function applyArtifactChainRule(state, artifacts, _options = {}) {
|
|
32
|
+
// Don't override explicit frontmatter phase
|
|
33
|
+
if (state.phase.confidence === 'explicit') return state;
|
|
34
|
+
|
|
35
|
+
const types = new Set(artifacts.map(a => a.type).filter(Boolean));
|
|
36
|
+
const hcPrefixes = new Set(artifacts.map(a => a.hcPrefix).filter(Boolean));
|
|
37
|
+
|
|
38
|
+
// Track last artifact for this initiative
|
|
39
|
+
let latestArtifact = null;
|
|
40
|
+
let latestDate = '';
|
|
41
|
+
for (const a of artifacts) {
|
|
42
|
+
const d = a.date || '';
|
|
43
|
+
if (d >= latestDate) {
|
|
44
|
+
latestDate = d;
|
|
45
|
+
latestArtifact = a;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (latestArtifact) {
|
|
49
|
+
state.lastArtifact = { file: latestArtifact.filename, date: latestDate || 'unknown' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check for epic completion
|
|
53
|
+
const epicArtifacts = artifacts.filter(a => a.type === 'epic');
|
|
54
|
+
if (epicArtifacts.length > 0) {
|
|
55
|
+
// Use most recent epic (by date, fallback to last in array)
|
|
56
|
+
const epic = epicArtifacts.reduce((best, a) => {
|
|
57
|
+
return (a.date || '') >= (best.date || '') ? a : best;
|
|
58
|
+
}, epicArtifacts[0]);
|
|
59
|
+
|
|
60
|
+
if (epic.content && isEpicDone(epic.content)) {
|
|
61
|
+
state.phase = { value: 'complete', source: 'artifact-chain', confidence: 'inferred' };
|
|
62
|
+
return state;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Epic exists + sprint artifact → build
|
|
66
|
+
if (types.has('sprint')) {
|
|
67
|
+
state.phase = { value: 'build', source: 'artifact-chain', confidence: 'inferred' };
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Architecture doc → planning
|
|
73
|
+
if (types.has('arch')) {
|
|
74
|
+
state.phase = { value: 'planning', source: 'artifact-chain', confidence: 'inferred' };
|
|
75
|
+
// Detect HC chain for nextAction even in planning phase
|
|
76
|
+
detectHCChain(state, hcPrefixes);
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// HC artifacts → discovery
|
|
81
|
+
if (hcPrefixes.size > 0) {
|
|
82
|
+
state.phase = { value: 'discovery', source: 'artifact-chain', confidence: 'inferred' };
|
|
83
|
+
detectHCChain(state, hcPrefixes);
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// PRD or brief → planning
|
|
88
|
+
if (types.has('prd') || types.has('brief')) {
|
|
89
|
+
state.phase = { value: 'planning', source: 'artifact-chain', confidence: 'inferred' };
|
|
90
|
+
return state;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// No recognized pattern
|
|
94
|
+
state.phase = { value: 'unknown', source: 'artifact-chain', confidence: 'inferred' };
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if epic content indicates completion via flexible markers.
|
|
100
|
+
* @param {string} content - Epic file content
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
function isEpicDone(content) {
|
|
104
|
+
return DONE_PATTERNS.some(pattern => pattern.test(content));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detect Vortex HC chain completeness and set nextAction if gaps found.
|
|
109
|
+
* HC chain: HC2 (Problem Definition) → HC3 (Hypothesis) → HC4 (Experiment) → HC5 (Signal) → HC6 (Decision)
|
|
110
|
+
* @param {import('../../types').InitiativeState} state
|
|
111
|
+
* @param {Set<string>} hcPrefixes - Set of HC prefixes present (e.g., 'hc2', 'hc3')
|
|
112
|
+
*/
|
|
113
|
+
function detectHCChain(state, hcPrefixes) {
|
|
114
|
+
const expectedHCs = ['hc2', 'hc3', 'hc4', 'hc5', 'hc6'];
|
|
115
|
+
const hcNames = { hc2: 'Problem Definition', hc3: 'Hypothesis', hc4: 'Experiment', hc5: 'Signal', hc6: 'Decision' };
|
|
116
|
+
const missing = expectedHCs.filter(hc => !hcPrefixes.has(hc));
|
|
117
|
+
|
|
118
|
+
if (missing.length === 0) {
|
|
119
|
+
state.nextAction = { value: 'HC chain complete — ready for learning decision', source: 'chain-gap' };
|
|
120
|
+
} else if (missing.length < expectedHCs.length) {
|
|
121
|
+
const nextMissing = missing[0];
|
|
122
|
+
state.nextAction = { value: `Next: ${hcNames[nextMissing]} (${nextMissing.toUpperCase()})`, source: 'chain-gap' };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { applyArtifactChainRule, isEpicDone, detectHCChain, DONE_PATTERNS };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule 4: Resolve conflicts between inference signals.
|
|
3
|
+
*
|
|
4
|
+
* - Explicit confidence always wins over inferred
|
|
5
|
+
* - For same confidence: later phase overrides earlier
|
|
6
|
+
* - Ensures lastArtifact and nextAction are populated
|
|
7
|
+
*
|
|
8
|
+
* @param {import('../../types').InitiativeState} state - Current initiative state
|
|
9
|
+
* @param {Array<{filename: string, dir: string, fullPath: string}>} artifacts - Artifacts for this initiative
|
|
10
|
+
* @param {Object} _options - Reserved
|
|
11
|
+
* @returns {import('../../types').InitiativeState} Enriched state
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Phase priority order (higher index = later phase) */
|
|
15
|
+
const PHASE_PRIORITY = ['unknown', 'discovery', 'planning', 'build', 'complete'];
|
|
16
|
+
|
|
17
|
+
function applyConflictResolver(state, artifacts, _options = {}) {
|
|
18
|
+
// Ensure phase has a value
|
|
19
|
+
if (!state.phase.value) {
|
|
20
|
+
state.phase = { value: 'unknown', source: 'conflict-resolver', confidence: 'inferred' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Ensure status has a value
|
|
24
|
+
if (!state.status.value) {
|
|
25
|
+
state.status = { value: 'unknown', source: 'conflict-resolver', confidence: 'inferred' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Ensure lastArtifact is populated
|
|
29
|
+
if (!state.lastArtifact.file && artifacts.length > 0) {
|
|
30
|
+
// Fallback to last artifact in array
|
|
31
|
+
const last = artifacts[artifacts.length - 1];
|
|
32
|
+
state.lastArtifact = { file: last.filename, date: last.date || 'unknown' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Derive nextAction from phase if not already set by chain-gap analysis
|
|
36
|
+
if (!state.nextAction.value) {
|
|
37
|
+
state.nextAction = deriveNextAction(state);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return state;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Derive a suggested next action based on current phase.
|
|
45
|
+
* @param {import('../../types').InitiativeState} state
|
|
46
|
+
* @returns {{value: string, source: string}}
|
|
47
|
+
*/
|
|
48
|
+
function deriveNextAction(state) {
|
|
49
|
+
switch (state.phase.value) {
|
|
50
|
+
case 'unknown':
|
|
51
|
+
return { value: 'Create PRD or brief to start planning', source: 'conflict-resolver' };
|
|
52
|
+
case 'discovery':
|
|
53
|
+
return { value: 'Continue discovery — check HC chain progress', source: 'conflict-resolver' };
|
|
54
|
+
case 'planning':
|
|
55
|
+
return { value: 'Create architecture or epics to advance to build', source: 'conflict-resolver' };
|
|
56
|
+
case 'build':
|
|
57
|
+
return { value: 'Continue story execution', source: 'conflict-resolver' };
|
|
58
|
+
case 'complete':
|
|
59
|
+
return { value: 'Initiative complete — consider retrospective', source: 'conflict-resolver' };
|
|
60
|
+
default:
|
|
61
|
+
return { value: 'Review initiative status', source: 'conflict-resolver' };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compare two phases by priority.
|
|
67
|
+
* @param {string} a - Phase name
|
|
68
|
+
* @param {string} b - Phase name
|
|
69
|
+
* @returns {number} Negative if a < b, positive if a > b, 0 if equal
|
|
70
|
+
*/
|
|
71
|
+
function comparePhasePriority(a, b) {
|
|
72
|
+
const idxA = PHASE_PRIORITY.indexOf(a);
|
|
73
|
+
const idxB = PHASE_PRIORITY.indexOf(b);
|
|
74
|
+
return (idxA === -1 ? -1 : idxA) - (idxB === -1 ? -1 : idxB);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { applyConflictResolver, deriveNextAction, comparePhasePriority, PHASE_PRIORITY };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule 1: Read explicit status/phase from frontmatter (highest priority).
|
|
3
|
+
*
|
|
4
|
+
* Reads `status` (standard schema field) and `phase` (operator-override, not in standard schema).
|
|
5
|
+
* Explicit frontmatter signals have highest priority — later rules should not override them.
|
|
6
|
+
*
|
|
7
|
+
* @param {import('../../types').InitiativeState} state - Current initiative state
|
|
8
|
+
* @param {Array<{filename: string, dir: string, fullPath: string, frontmatter?: Object}>} artifacts - Artifacts for this initiative
|
|
9
|
+
* @param {Object} _options - Reserved
|
|
10
|
+
* @returns {import('../../types').InitiativeState} Enriched state
|
|
11
|
+
*/
|
|
12
|
+
function applyFrontmatterRule(state, artifacts, _options = {}) {
|
|
13
|
+
// Process artifacts most-recent-first (by date suffix or array order)
|
|
14
|
+
// First explicit value found wins
|
|
15
|
+
for (const artifact of artifacts) {
|
|
16
|
+
if (!artifact.frontmatter) continue;
|
|
17
|
+
|
|
18
|
+
if (!state.status.value || state.status.confidence !== 'explicit') {
|
|
19
|
+
if (artifact.frontmatter.status != null && artifact.frontmatter.status !== '') {
|
|
20
|
+
state.status = {
|
|
21
|
+
value: artifact.frontmatter.status,
|
|
22
|
+
source: 'frontmatter',
|
|
23
|
+
confidence: 'explicit'
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!state.phase.value || state.phase.confidence !== 'explicit') {
|
|
29
|
+
if (artifact.frontmatter.phase != null && artifact.frontmatter.phase !== '') {
|
|
30
|
+
state.phase = {
|
|
31
|
+
value: artifact.frontmatter.phase,
|
|
32
|
+
source: 'frontmatter',
|
|
33
|
+
confidence: 'explicit'
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { applyFrontmatterRule };
|