convoke-agents 3.0.4 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +14 -13
  3. package/_bmad/bme/_artifacts/config.yaml +15 -0
  4. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/SKILL.md +6 -0
  5. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-01-scope.md +138 -0
  6. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-02-dryrun.md +199 -0
  7. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-03-resolve.md +174 -0
  8. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-04-execute.md +213 -0
  9. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/workflow.md +85 -0
  10. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/SKILL.md +6 -0
  11. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-01-scan.md +131 -0
  12. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-02-explore.md +131 -0
  13. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-03-recommend.md +149 -0
  14. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/workflow.md +78 -0
  15. package/_bmad/bme/_gyre/guides/GYRE-TEAM-GUIDE.md +506 -0
  16. package/_bmad/bme/_portability/skills/bmad-export-skill/SKILL.md +6 -0
  17. package/_bmad/bme/_portability/skills/bmad-export-skill/workflow.md +74 -0
  18. package/_bmad/bme/_portability/skills/bmad-generate-catalog/SKILL.md +6 -0
  19. package/_bmad/bme/_portability/skills/bmad-generate-catalog/workflow.md +42 -0
  20. package/_bmad/bme/_portability/skills/bmad-seed-catalog/SKILL.md +6 -0
  21. package/_bmad/bme/_portability/skills/bmad-seed-catalog/workflow.md +61 -0
  22. package/_bmad/bme/_portability/skills/bmad-validate-exports/SKILL.md +6 -0
  23. package/_bmad/bme/_portability/skills/bmad-validate-exports/workflow.md +43 -0
  24. package/_bmad/bme/_team-factory/agents/team-factory.md +128 -0
  25. package/_bmad/bme/_team-factory/config.yaml +13 -0
  26. package/_bmad/bme/_team-factory/lib/cascade-logic.js +184 -0
  27. package/_bmad/bme/_team-factory/lib/collision-detector.js +228 -0
  28. package/_bmad/bme/_team-factory/lib/manifest-tracker.js +214 -0
  29. package/_bmad/bme/_team-factory/lib/spec-differ.js +176 -0
  30. package/_bmad/bme/_team-factory/lib/spec-parser.js +201 -0
  31. package/_bmad/bme/_team-factory/lib/spec-writer.js +128 -0
  32. package/_bmad/bme/_team-factory/lib/types/factory-types.js +193 -0
  33. package/_bmad/bme/_team-factory/lib/utils/csv-utils.js +62 -0
  34. package/_bmad/bme/_team-factory/lib/utils/naming-utils.js +45 -0
  35. package/_bmad/bme/_team-factory/lib/validators/end-to-end-validator.js +898 -0
  36. package/_bmad/bme/_team-factory/lib/writers/activation-validator.js +175 -0
  37. package/_bmad/bme/_team-factory/lib/writers/config-appender.js +192 -0
  38. package/_bmad/bme/_team-factory/lib/writers/config-creator.js +215 -0
  39. package/_bmad/bme/_team-factory/lib/writers/csv-appender.js +118 -0
  40. package/_bmad/bme/_team-factory/lib/writers/csv-creator.js +190 -0
  41. package/_bmad/bme/_team-factory/lib/writers/registry-appender.js +372 -0
  42. package/_bmad/bme/_team-factory/lib/writers/registry-writer.js +409 -0
  43. package/_bmad/bme/_team-factory/module-help.csv +3 -0
  44. package/_bmad/bme/_team-factory/schemas/schema-independent.json +147 -0
  45. package/_bmad/bme/_team-factory/schemas/schema-sequential.json +242 -0
  46. package/_bmad/bme/_team-factory/templates/team-spec-template.yaml +86 -0
  47. package/_bmad/bme/_team-factory/workflows/add-team/step-01-scope.md +105 -0
  48. package/_bmad/bme/_team-factory/workflows/add-team/step-02-connect.md +110 -0
  49. package/_bmad/bme/_team-factory/workflows/add-team/step-03-review.md +116 -0
  50. package/_bmad/bme/_team-factory/workflows/add-team/step-04-generate.md +160 -0
  51. package/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md +146 -0
  52. package/_bmad/bme/_team-factory/workflows/step-00-route.md +76 -0
  53. package/_bmad/bme/_vortex/config.yaml +4 -4
  54. package/_bmad/bme/_vortex/guides/VORTEX-TEAM-GUIDE.md +441 -0
  55. package/package.json +17 -8
  56. package/scripts/archive.js +26 -45
  57. package/scripts/convoke-check.js +88 -0
  58. package/scripts/convoke-doctor.js +303 -4
  59. package/scripts/install-gyre-agents.js +0 -0
  60. package/scripts/lib/artifact-utils.js +2182 -0
  61. package/scripts/lib/portfolio/formatters/markdown-formatter.js +40 -0
  62. package/scripts/lib/portfolio/formatters/terminal-formatter.js +56 -0
  63. package/scripts/lib/portfolio/portfolio-engine.js +572 -0
  64. package/scripts/lib/portfolio/rules/artifact-chain-rule.js +156 -0
  65. package/scripts/lib/portfolio/rules/conflict-resolver.js +99 -0
  66. package/scripts/lib/portfolio/rules/frontmatter-rule.js +42 -0
  67. package/scripts/lib/portfolio/rules/git-recency-rule.js +69 -0
  68. package/scripts/lib/types.js +122 -0
  69. package/scripts/migrate-artifacts.js +439 -0
  70. package/scripts/portability/catalog-generator.js +353 -0
  71. package/scripts/portability/classify-skills.js +646 -0
  72. package/scripts/portability/convoke-export.js +522 -0
  73. package/scripts/portability/export-engine.js +1133 -0
  74. package/scripts/portability/generate-adapters.js +79 -0
  75. package/scripts/portability/manifest-csv.js +147 -0
  76. package/scripts/portability/seed-catalog-repo.js +427 -0
  77. package/scripts/portability/templates/canonical-example.md +102 -0
  78. package/scripts/portability/templates/canonical-format.md +218 -0
  79. package/scripts/portability/templates/readme-template.md +72 -0
  80. package/scripts/portability/test-constants.js +42 -0
  81. package/scripts/portability/validate-classification.js +529 -0
  82. package/scripts/portability/validate-exports.js +348 -0
  83. package/scripts/update/lib/agent-registry.js +35 -0
  84. package/scripts/update/lib/config-merger.js +140 -10
  85. package/scripts/update/lib/migration-runner.js +1 -1
  86. package/scripts/update/lib/refresh-installation.js +293 -8
  87. package/scripts/update/lib/taxonomy-merger.js +138 -0
  88. package/scripts/update/lib/utils.js +27 -1
  89. package/scripts/update/lib/validator.js +114 -4
  90. package/scripts/update/migrations/2.0.x-to-3.1.0.js +50 -0
  91. package/scripts/update/migrations/3.0.x-to-3.1.0.js +41 -0
  92. 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,572 @@
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
+ // --- Story 6.3: Attribution helpers ---
31
+
32
+ /**
33
+ * Scan a corpus for any taxonomy initiative ID or alias as a whole word.
34
+ * Mirrors `_scanCorpusForInitiative` in artifact-utils.js — kept local to avoid
35
+ * a cross-module dependency from portfolio into the migration suggester. The
36
+ * `[a-z0-9-]` boundary class keeps kebab-case identifiers atomic so `pre-gyre`
37
+ * does not match `gyre`.
38
+ *
39
+ * @param {string} corpus - Lowercased text to scan
40
+ * @param {import('../types').TaxonomyConfig} taxonomy
41
+ * @returns {string|null} Resolved canonical initiative ID, or null
42
+ */
43
+ function _scanCorpus(corpus, taxonomy) {
44
+ if (!corpus || !taxonomy || !taxonomy.initiatives) return null;
45
+ const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : [];
46
+ const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : [];
47
+ const allInitiatives = [...platform, ...user];
48
+ const aliasKeys = taxonomy.aliases ? Object.keys(taxonomy.aliases) : [];
49
+ const candidates = [...allInitiatives, ...aliasKeys].sort((a, b) => b.length - a.length);
50
+
51
+ for (const candidate of candidates) {
52
+ const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
+ const re = new RegExp(`(?:^|[^a-z0-9-])${escaped}(?:$|[^a-z0-9-])`, 'i');
54
+ if (re.test(corpus)) {
55
+ if (allInitiatives.includes(candidate)) return candidate;
56
+ if (taxonomy.aliases && taxonomy.aliases[candidate]) return taxonomy.aliases[candidate];
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Map of story-key filename prefixes (that are NOT taxonomy initiatives) to
64
+ * canonical initiative IDs. Story files (e.g. `tf-2-10-...`, `p3-1-1-...`) live
65
+ * in `implementation-artifacts/` and use compact prefixes that aren't real
66
+ * initiative IDs. Real-initiative prefixes (`gyre`, `forge`, `helm`, etc.) are
67
+ * resolved separately by checking taxonomy membership directly.
68
+ *
69
+ * Add new entries here when a new initiative starts producing stories under a
70
+ * non-initiative prefix.
71
+ */
72
+ const STORY_PREFIX_MAP = Object.freeze({
73
+ ag: 'convoke', // Artifact Governance — platform-level work
74
+ tf: 'loom', // Team Factory → Loom
75
+ p2: 'convoke', // Phase 2 — platform stabilization
76
+ p3: 'convoke', // Phase 3 — Convoke rename
77
+ enh: 'enhance', // Enhance module
78
+ sp: 'convoke', // Skill Portability — platform-level
79
+ });
80
+
81
+ /**
82
+ * Folder defaults for the portfolio engine. Mirrors the migration suggester:
83
+ * `planning-artifacts` is for cross-cutting convoke platform artifacts.
84
+ * Other dirs (`vortex-artifacts`, `implementation-artifacts`) are heterogeneous
85
+ * — no safe default, so they fall through to subsequent inference layers.
86
+ */
87
+ const PORTFOLIO_FOLDER_DEFAULT_MAP = Object.freeze({
88
+ 'planning-artifacts': 'convoke'
89
+ });
90
+
91
+ /**
92
+ * Attribute a file to an initiative when filename inference fails.
93
+ * Six-step priority chain (highest first):
94
+ * 1. frontmatter.title keyword scan → 'frontmatter-title'
95
+ * 2. first 5 lines of content keyword scan → 'content-fallback'
96
+ * 3. filename first-segment matches a real taxonomy initiative (e.g. `gyre-1-1-...`)
97
+ * → 'filename-prefix'
98
+ * 4. parent directory name (e.g. `gyre-artifacts/`) → 'parent-dir'
99
+ * 5. story-key prefix mapping (e.g. `tf-2-10-...` → loom) → 'story-prefix'
100
+ * 6. folder default (e.g. `planning-artifacts/` → convoke) → 'folder-default'
101
+ *
102
+ * @param {Object} fileInfo - File descriptor with filename and dir
103
+ * @param {string} content - Already-loaded file content
104
+ * @param {Object|null} frontmatter - Parsed frontmatter or null
105
+ * @param {import('../types').TaxonomyConfig} taxonomy
106
+ * @returns {{initiative: string|null, source: string|null}}
107
+ */
108
+ function attributeFile(fileInfo, content, frontmatter, taxonomy) {
109
+ // Step 1: Frontmatter title scan (highest priority)
110
+ if (frontmatter && typeof frontmatter.title === 'string' && frontmatter.title.length > 0) {
111
+ const match = _scanCorpus(frontmatter.title.toLowerCase(), taxonomy);
112
+ if (match) return { initiative: match, source: 'frontmatter-title' };
113
+ }
114
+
115
+ // Step 2: First-5-lines content scan
116
+ // Strip frontmatter before scanning so non-title fields (e.g. `tags: [gyre]`)
117
+ // don't false-trigger content-fallback. The migration suggester does the same.
118
+ if (content && content.length > 0) {
119
+ let body = content;
120
+ try {
121
+ const parsed = parseFrontmatter(content);
122
+ if (parsed && typeof parsed.content === 'string') {
123
+ body = parsed.content;
124
+ }
125
+ } catch {
126
+ // Frontmatter parse failed — fall back to raw content
127
+ }
128
+ const corpus = body.split('\n').slice(0, 5).join(' ').toLowerCase();
129
+ const match = _scanCorpus(corpus, taxonomy);
130
+ if (match) return { initiative: match, source: 'content-fallback' };
131
+ }
132
+
133
+ // Step 3: Filename first-segment matches a real taxonomy initiative.
134
+ // Catches patterns like `gyre-1-1-...`, `forge-epic-1-...`, `helm-prd.md`,
135
+ // and also single-word filenames like `gyre.md` (extension stripped first).
136
+ if (fileInfo.filename && taxonomy && taxonomy.initiatives) {
137
+ const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : [];
138
+ const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : [];
139
+ const allInitiatives = new Set([...platform, ...user]);
140
+ const stem = fileInfo.filename.replace(/\.(md|yaml|yml)$/i, '');
141
+ const firstSegment = stem.split('-')[0].toLowerCase();
142
+ if (allInitiatives.has(firstSegment)) {
143
+ return { initiative: firstSegment, source: 'filename-prefix' };
144
+ }
145
+ }
146
+
147
+ // Step 4: Parent directory scan
148
+ // The dir is something like `gyre-artifacts` — split on dashes and check each
149
+ // segment against the taxonomy. We can't reuse `_scanCorpus` here because it
150
+ // uses a kebab-aware boundary class (`[a-z0-9-]`) that intentionally treats
151
+ // `gyre-artifacts` as a single token, which is correct for content scanning
152
+ // but wrong for directory naming where `-` IS the segment separator.
153
+ if (fileInfo.dir && taxonomy && taxonomy.initiatives) {
154
+ const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : [];
155
+ const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : [];
156
+ const allInitiatives = new Set([...platform, ...user]);
157
+ const aliases = taxonomy.aliases || {};
158
+ const dirSegments = fileInfo.dir.toLowerCase().split('-');
159
+ for (const seg of dirSegments) {
160
+ if (allInitiatives.has(seg)) {
161
+ return { initiative: seg, source: 'parent-dir' };
162
+ }
163
+ if (aliases[seg]) {
164
+ return { initiative: aliases[seg], source: 'parent-dir' };
165
+ }
166
+ }
167
+ }
168
+
169
+ // Step 5: Story-key prefix mapping
170
+ // Match patterns like `tf-2-10-...`, `p3-epic-2-retrospective`, `ag-1-1-...`
171
+ // Take the first dash-separated segment of the filename stem and look it up in STORY_PREFIX_MAP.
172
+ if (fileInfo.filename) {
173
+ const stem = fileInfo.filename.replace(/\.(md|yaml|yml)$/i, '');
174
+ const firstSegment = stem.split('-')[0].toLowerCase();
175
+ if (Object.prototype.hasOwnProperty.call(STORY_PREFIX_MAP, firstSegment)) {
176
+ return { initiative: STORY_PREFIX_MAP[firstSegment], source: 'story-prefix' };
177
+ }
178
+ }
179
+
180
+ // Step 6: Folder default — last resort for cross-cutting platform artifacts
181
+ // (e.g. `planning-artifacts/architecture.md` → convoke)
182
+ if (fileInfo.dir && Object.prototype.hasOwnProperty.call(PORTFOLIO_FOLDER_DEFAULT_MAP, fileInfo.dir)) {
183
+ const defaultInit = PORTFOLIO_FOLDER_DEFAULT_MAP[fileInfo.dir];
184
+ if (defaultInit) {
185
+ return { initiative: defaultInit, source: 'folder-default' };
186
+ }
187
+ }
188
+
189
+ return { initiative: null, source: null };
190
+ }
191
+
192
+ /**
193
+ * Produce a one-line reason explaining why a file could not be attributed.
194
+ *
195
+ * @param {Object} fileInfo - File descriptor
196
+ * @param {string} content - File content (may be empty if unreadable)
197
+ * @param {Object|null} _frontmatter - Frontmatter (reserved for future heuristics)
198
+ * @returns {string} Reason
199
+ */
200
+ function explainUnattributed(fileInfo, content, _frontmatter) {
201
+ if (!content || content.length === 0) {
202
+ return 'unreadable or empty';
203
+ }
204
+ // Check whether the filename has a recognizable type prefix BEFORE checking content length —
205
+ // matches the spec's order so a 3-line file with no prefix returns the more actionable
206
+ // 'no type prefix' message rather than 'insufficient content'.
207
+ const hasTypePrefix = /^[a-z]+\d*-/.test(fileInfo.filename);
208
+ if (!hasTypePrefix) {
209
+ return 'no type prefix in filename';
210
+ }
211
+ const lineCount = content.split('\n').length;
212
+ if (lineCount < 5) {
213
+ return 'insufficient content for inference';
214
+ }
215
+ return 'no initiative signal in filename, frontmatter title, content, or parent directory';
216
+ }
217
+
218
+ /**
219
+ * Create an empty InitiativeState for a given initiative.
220
+ * @param {string} initiative - Initiative ID
221
+ * @returns {import('../types').InitiativeState}
222
+ */
223
+ function makeEmptyState(initiative) {
224
+ return {
225
+ initiative,
226
+ phase: { value: null, source: null, confidence: null },
227
+ status: { value: null, source: null, confidence: null },
228
+ lastArtifact: { file: null, date: null },
229
+ nextAction: { value: null, source: null }
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Generate portfolio view of all initiatives.
235
+ *
236
+ * @param {string} projectRoot - Absolute path to project root
237
+ * @param {Object} [options={}]
238
+ * @param {string} [options.sort='alpha'] - Sort mode: 'alpha' or 'last-activity'
239
+ * @param {number} [options.staleDays=30] - Days threshold for stale detection
240
+ * @returns {Promise<{initiatives: import('../types').InitiativeState[], summary: {total: number, governed: number, ungoverned: number}}>}
241
+ */
242
+ async function generatePortfolio(projectRoot, options = {}) {
243
+ const { sort = 'alpha', staleDays = 30, wipThreshold = 4, filter = null } = options;
244
+
245
+ // Pre-flight: read taxonomy (FR39 — error if absent)
246
+ const taxonomy = readTaxonomy(projectRoot);
247
+
248
+ // Scan: discover subdirectories dynamically
249
+ const outputDir = path.join(projectRoot, '_bmad-output');
250
+ if (!fs.existsSync(outputDir)) {
251
+ console.warn('Warning: _bmad-output/ directory not found.');
252
+ return { initiatives: [], summary: { total: 0, governed: 0, ungoverned: 0 } };
253
+ }
254
+ const allDirs = fs.readdirSync(outputDir, { withFileTypes: true })
255
+ .filter(e => e.isDirectory() && !e.name.startsWith('.') && !EXCLUDE_DIRS.includes(e.name))
256
+ .map(e => e.name);
257
+
258
+ const allFiles = await scanArtifactDirs(projectRoot, allDirs, ['_archive']);
259
+
260
+ // Parse: index files by initiative
261
+ const registry = new Map();
262
+ let governed = 0;
263
+ let ungoverned = 0;
264
+ let attributableButUngoverned = 0; // Story 6.3: counts files attributed via fallback layers
265
+ const unattributedFiles = []; // Story 6.3: track per-file reasons (replaces bare counter)
266
+
267
+ const mdFiles = allFiles.filter(f => f.filename.endsWith('.md'));
268
+
269
+ for (const file of mdFiles) {
270
+
271
+ // Read frontmatter FIRST — governed files have authoritative metadata
272
+ let frontmatter = null;
273
+ let content = '';
274
+ let readFailed = false;
275
+ try {
276
+ content = fs.readFileSync(file.fullPath, 'utf8');
277
+ frontmatter = parseFrontmatter(content).data;
278
+ } catch {
279
+ // Unreadable — treat as no frontmatter and skip fallback attribution
280
+ // (otherwise filename-prefix/story-prefix could silently attribute a file we never read)
281
+ readFailed = true;
282
+ }
283
+
284
+ // Strategy: frontmatter initiative is authoritative if present
285
+ let initiative, artifactType, isGoverned, typeResult;
286
+ let attributionSource = null; // Story 6.3: tracks which fallback layer provided the attribution
287
+
288
+ if (frontmatter && frontmatter.initiative && frontmatter.artifact_type) {
289
+ // Governed file — use frontmatter as source of truth
290
+ initiative = frontmatter.initiative;
291
+ artifactType = frontmatter.artifact_type;
292
+ isGoverned = true;
293
+ governed++;
294
+ typeResult = { type: artifactType, hcPrefix: null, remainder: '', date: null, typeConfidence: 'high', typeSource: 'frontmatter' };
295
+ } else {
296
+ // Ungoverned file — fall back to filename inference
297
+ typeResult = inferArtifactType(file.filename, taxonomy);
298
+ const initResult = typeResult.type
299
+ ? inferInitiative(typeResult.remainder, taxonomy)
300
+ : { initiative: null, confidence: 'low', source: 'no-type', candidates: [] };
301
+
302
+ if (initResult.initiative) {
303
+ initiative = initResult.initiative;
304
+ artifactType = typeResult.type;
305
+ isGoverned = false;
306
+ ungoverned++;
307
+ } else if (readFailed) {
308
+ // Don't attempt fallback attribution on a file we couldn't read —
309
+ // otherwise filename/story-prefix layers would silently produce a phantom attribution.
310
+ unattributedFiles.push({
311
+ filename: file.filename,
312
+ dir: file.dir,
313
+ reason: 'unreadable or empty'
314
+ });
315
+ continue;
316
+ } else {
317
+ // Story 6.3: try content-fallback layers before giving up
318
+ const fallback = attributeFile(file, content, frontmatter, taxonomy);
319
+ if (fallback.initiative) {
320
+ initiative = fallback.initiative;
321
+ // Type may not be inferable — synthetic 'unknown' so registry can still index it
322
+ artifactType = typeResult.type || 'unknown';
323
+ isGoverned = false;
324
+ ungoverned++;
325
+ attributableButUngoverned++;
326
+ attributionSource = fallback.source;
327
+ } else {
328
+ // Truly unattributed — record reason for the diagnostic section
329
+ unattributedFiles.push({
330
+ filename: file.filename,
331
+ dir: file.dir,
332
+ reason: explainUnattributed(file, content, frontmatter)
333
+ });
334
+ continue;
335
+ }
336
+ }
337
+ }
338
+
339
+ const enriched = {
340
+ filename: file.filename,
341
+ dir: file.dir,
342
+ fullPath: file.fullPath,
343
+ type: artifactType,
344
+ hcPrefix: typeResult.hcPrefix,
345
+ date: typeResult.date,
346
+ initiative,
347
+ frontmatter,
348
+ content,
349
+ isGoverned,
350
+ degradedMode: !isGoverned,
351
+ // Story 6.3: 'frontmatter-title' | 'content-fallback' | 'filename-prefix' | 'parent-dir' | 'story-prefix' | 'folder-default' | null
352
+ attributionSource
353
+ };
354
+
355
+ if (!registry.has(initiative)) {
356
+ registry.set(initiative, []);
357
+ }
358
+ registry.get(initiative).push(enriched);
359
+ }
360
+ const unattributed = unattributedFiles.length;
361
+
362
+ // FR39: warn if no governed artifacts
363
+ if (governed === 0 && mdFiles.length > 0) {
364
+ console.warn('Warning: No governed artifacts found. Run migration to populate.');
365
+ }
366
+
367
+ // Infer: run rule chain for each initiative in taxonomy
368
+ const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
369
+ let results = [];
370
+
371
+ for (const initiative of allInitiatives) {
372
+ const artifacts = registry.get(initiative) || [];
373
+ let state = makeEmptyState(initiative);
374
+ state = applyFrontmatterRule(state, artifacts, { projectRoot });
375
+ state = applyArtifactChainRule(state, artifacts, { projectRoot });
376
+ state = applyGitRecencyRule(state, artifacts, { projectRoot, staleDays });
377
+ state = applyConflictResolver(state, artifacts, { projectRoot });
378
+ results.push(state);
379
+ }
380
+
381
+ // Sort
382
+ if (sort === 'last-activity') {
383
+ results.sort((a, b) => (b.lastArtifact.date || '').localeCompare(a.lastArtifact.date || ''));
384
+ } else {
385
+ results.sort((a, b) => a.initiative.localeCompare(b.initiative));
386
+ }
387
+
388
+ // Filter by initiative prefix (before WIP count)
389
+ if (filter) {
390
+ const prefix = filter.replace(/\*$/, '');
391
+ results = results.filter(s => s.initiative.startsWith(prefix));
392
+ }
393
+
394
+ // WIP radar: count active initiatives (ongoing, blocked, or stale)
395
+ const activeStatuses = ['ongoing', 'stale', 'blocked'];
396
+ const activeInitiatives = results.filter(s => activeStatuses.includes(s.status.value));
397
+ const wipRadar = activeInitiatives.length > wipThreshold
398
+ ? {
399
+ active: activeInitiatives.length,
400
+ threshold: wipThreshold,
401
+ initiatives: activeInitiatives
402
+ .sort((a, b) => (b.lastArtifact.date || '').localeCompare(a.lastArtifact.date || ''))
403
+ .map(s => s.initiative)
404
+ }
405
+ : null;
406
+
407
+ // Calculate governance health score (of attributable files only — excludes unattributed)
408
+ const attributable = governed + ungoverned;
409
+ const healthPercentage = attributable > 0 ? Math.round((governed / attributable) * 100) : 0;
410
+
411
+ return {
412
+ initiatives: results,
413
+ wipRadar,
414
+ unattributedFiles, // Story 6.3: full list with reasons (for --show-unattributed)
415
+ summary: {
416
+ total: mdFiles.length,
417
+ governed,
418
+ ungoverned,
419
+ unattributed,
420
+ attributableButUngoverned, // Story 6.3: count of files attributed via fallback layers
421
+ healthScore: { governed, total: attributable, percentage: healthPercentage }
422
+ }
423
+ };
424
+ }
425
+
426
+ // --- CLI ---
427
+
428
+ function printHelp() {
429
+ console.log(`
430
+ Usage: convoke-portfolio [options]
431
+
432
+ Generate a portfolio view of all initiatives from artifact analysis.
433
+
434
+ Options:
435
+ --terminal Terminal table output (default)
436
+ --markdown Markdown table output
437
+ --sort <mode> Sort: alpha (default), last-activity
438
+ --filter <prefix> Filter initiatives by prefix (e.g., --filter gyre)
439
+ --verbose Show inference trace per initiative (source + confidence)
440
+ --show-unattributed List each unattributed file with its reason
441
+ --help, -h Show this help
442
+
443
+ Examples:
444
+ convoke-portfolio Default terminal view
445
+ convoke-portfolio --markdown Markdown output for chat/docs
446
+ convoke-portfolio --sort last-activity Sort by most recent activity
447
+ convoke-portfolio --show-unattributed See why each unattributed file was skipped
448
+ `);
449
+ }
450
+
451
+ async function main() {
452
+ const args = process.argv.slice(2);
453
+
454
+ if (args.includes('--help') || args.includes('-h')) {
455
+ printHelp();
456
+ return;
457
+ }
458
+
459
+ const projectRoot = findProjectRoot();
460
+ if (!projectRoot) {
461
+ console.error('Error: Not in a Convoke project. Could not find _bmad/ directory.');
462
+ process.exit(1);
463
+ }
464
+
465
+ const useMarkdown = args.includes('--markdown');
466
+ const useVerbose = args.includes('--verbose');
467
+ const showUnattributed = args.includes('--show-unattributed');
468
+ const sortMode = args.includes('--sort') && args[args.indexOf('--sort') + 1] === 'last-activity'
469
+ ? 'last-activity'
470
+ : 'alpha';
471
+ const filterIdx = args.indexOf('--filter');
472
+ const filterPattern = (filterIdx !== -1 && args[filterIdx + 1] && !args[filterIdx + 1].startsWith('--'))
473
+ ? args[filterIdx + 1]
474
+ : null;
475
+
476
+ // Read portfolio config from _bmad/bmm/config.yaml (optional)
477
+ let wipThreshold = 4;
478
+ let staleDays = 30;
479
+ try {
480
+ const yaml = require('js-yaml');
481
+ const configPath = path.join(projectRoot, '_bmad', 'bmm', 'config.yaml');
482
+ if (fs.existsSync(configPath)) {
483
+ const config = yaml.load(fs.readFileSync(configPath, 'utf8'));
484
+ if (config && config.portfolio) {
485
+ const wt = Number(config.portfolio.wip_threshold);
486
+ if (!isNaN(wt)) wipThreshold = wt;
487
+ const sd = Number(config.portfolio.stale_days);
488
+ if (!isNaN(sd)) staleDays = sd;
489
+ }
490
+ }
491
+ } catch {
492
+ // Config read failed — use defaults
493
+ }
494
+
495
+ try {
496
+ const result = await generatePortfolio(projectRoot, {
497
+ sort: sortMode,
498
+ filter: filterPattern,
499
+ wipThreshold,
500
+ staleDays
501
+ });
502
+
503
+ const output = useMarkdown
504
+ ? formatMarkdown(result.initiatives)
505
+ : formatTerminal(result.initiatives);
506
+
507
+ console.log(output);
508
+
509
+ // WIP radar (only when threshold exceeded)
510
+ if (result.wipRadar) {
511
+ console.log(`\nWIP: ${result.wipRadar.active} active (threshold: ${result.wipRadar.threshold}) -- sorted by last activity`);
512
+ console.log(` ${result.wipRadar.initiatives.join(', ')}`);
513
+ }
514
+
515
+ console.log(`\nTotal: ${result.summary.total} artifacts | Governed: ${result.summary.governed} | Ungoverned: ${result.summary.ungoverned} | Unattributed: ${result.summary.unattributed}`);
516
+ const hs = result.summary.healthScore;
517
+ console.log(`Governance: ${hs.governed}/${hs.total} artifacts governed (${hs.percentage}%)`);
518
+
519
+ // Story 6.3: Surface attributable-but-ungoverned guidance
520
+ if (result.summary.attributableButUngoverned > 0) {
521
+ console.log(
522
+ `${result.summary.attributableButUngoverned} files attributable to existing initiatives ` +
523
+ `but ungoverned — run convoke-migrate-artifacts to govern them`
524
+ );
525
+ }
526
+
527
+ // Story 6.3: Unattributed details
528
+ if (result.unattributedFiles && result.unattributedFiles.length > 0) {
529
+ if (showUnattributed) {
530
+ console.log(`\n--- Unattributed Files (${result.unattributedFiles.length}) ---`);
531
+ for (const u of result.unattributedFiles) {
532
+ console.log(` ${u.dir}/${u.filename}: ${u.reason}`);
533
+ }
534
+ } else {
535
+ console.log(
536
+ `\n${result.unattributedFiles.length} unattributed files ` +
537
+ `(run with --show-unattributed to see details)`
538
+ );
539
+ }
540
+ }
541
+
542
+ // Verbose: inference trace per initiative
543
+ if (useVerbose) {
544
+ console.log('\n--- Inference Trace ---');
545
+ for (const s of result.initiatives) {
546
+ const p = s.phase;
547
+ const st = s.status;
548
+ console.log(` [${s.initiative}] phase: ${p.value} (${p.source}, ${p.confidence}) | status: ${st.value} (${st.source}, ${st.confidence})`);
549
+ }
550
+ }
551
+ } catch (err) {
552
+ console.error(`Error: ${err.message}`);
553
+ process.exit(1);
554
+ }
555
+ }
556
+
557
+ if (require.main === module) {
558
+ main().catch(err => {
559
+ console.error(`Error: ${err.message}`);
560
+ process.exit(1);
561
+ });
562
+ }
563
+
564
+ module.exports = {
565
+ generatePortfolio,
566
+ makeEmptyState,
567
+ attributeFile,
568
+ explainUnattributed,
569
+ EXCLUDE_DIRS,
570
+ STORY_PREFIX_MAP,
571
+ PORTFOLIO_FOLDER_DEFAULT_MAP
572
+ };