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.
@@ -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 };