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.
- package/CHANGELOG.md +60 -0
- package/README.md +14 -13
- package/_bmad/bme/_artifacts/config.yaml +15 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/SKILL.md +6 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-01-scope.md +138 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-02-dryrun.md +199 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-03-resolve.md +174 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-04-execute.md +213 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/workflow.md +85 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/SKILL.md +6 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-01-scan.md +131 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-02-explore.md +131 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-03-recommend.md +149 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/workflow.md +78 -0
- package/_bmad/bme/_gyre/guides/GYRE-TEAM-GUIDE.md +506 -0
- package/_bmad/bme/_portability/skills/bmad-export-skill/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-export-skill/workflow.md +74 -0
- package/_bmad/bme/_portability/skills/bmad-generate-catalog/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-generate-catalog/workflow.md +42 -0
- package/_bmad/bme/_portability/skills/bmad-seed-catalog/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-seed-catalog/workflow.md +61 -0
- package/_bmad/bme/_portability/skills/bmad-validate-exports/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-validate-exports/workflow.md +43 -0
- package/_bmad/bme/_team-factory/agents/team-factory.md +128 -0
- package/_bmad/bme/_team-factory/config.yaml +13 -0
- package/_bmad/bme/_team-factory/lib/cascade-logic.js +184 -0
- package/_bmad/bme/_team-factory/lib/collision-detector.js +228 -0
- package/_bmad/bme/_team-factory/lib/manifest-tracker.js +214 -0
- package/_bmad/bme/_team-factory/lib/spec-differ.js +176 -0
- package/_bmad/bme/_team-factory/lib/spec-parser.js +201 -0
- package/_bmad/bme/_team-factory/lib/spec-writer.js +128 -0
- package/_bmad/bme/_team-factory/lib/types/factory-types.js +193 -0
- package/_bmad/bme/_team-factory/lib/utils/csv-utils.js +62 -0
- package/_bmad/bme/_team-factory/lib/utils/naming-utils.js +45 -0
- package/_bmad/bme/_team-factory/lib/validators/end-to-end-validator.js +898 -0
- package/_bmad/bme/_team-factory/lib/writers/activation-validator.js +175 -0
- package/_bmad/bme/_team-factory/lib/writers/config-appender.js +192 -0
- package/_bmad/bme/_team-factory/lib/writers/config-creator.js +215 -0
- package/_bmad/bme/_team-factory/lib/writers/csv-appender.js +118 -0
- package/_bmad/bme/_team-factory/lib/writers/csv-creator.js +190 -0
- package/_bmad/bme/_team-factory/lib/writers/registry-appender.js +372 -0
- package/_bmad/bme/_team-factory/lib/writers/registry-writer.js +409 -0
- package/_bmad/bme/_team-factory/module-help.csv +3 -0
- package/_bmad/bme/_team-factory/schemas/schema-independent.json +147 -0
- package/_bmad/bme/_team-factory/schemas/schema-sequential.json +242 -0
- package/_bmad/bme/_team-factory/templates/team-spec-template.yaml +86 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-01-scope.md +105 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-02-connect.md +110 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-03-review.md +116 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-04-generate.md +160 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md +146 -0
- package/_bmad/bme/_team-factory/workflows/step-00-route.md +76 -0
- package/_bmad/bme/_vortex/config.yaml +4 -4
- package/_bmad/bme/_vortex/guides/VORTEX-TEAM-GUIDE.md +441 -0
- package/package.json +17 -8
- package/scripts/archive.js +26 -45
- package/scripts/convoke-check.js +88 -0
- package/scripts/convoke-doctor.js +303 -4
- package/scripts/install-gyre-agents.js +0 -0
- package/scripts/lib/artifact-utils.js +2182 -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 +572 -0
- package/scripts/lib/portfolio/rules/artifact-chain-rule.js +156 -0
- package/scripts/lib/portfolio/rules/conflict-resolver.js +99 -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 +439 -0
- package/scripts/portability/catalog-generator.js +353 -0
- package/scripts/portability/classify-skills.js +646 -0
- package/scripts/portability/convoke-export.js +522 -0
- package/scripts/portability/export-engine.js +1133 -0
- package/scripts/portability/generate-adapters.js +79 -0
- package/scripts/portability/manifest-csv.js +147 -0
- package/scripts/portability/seed-catalog-repo.js +427 -0
- package/scripts/portability/templates/canonical-example.md +102 -0
- package/scripts/portability/templates/canonical-format.md +218 -0
- package/scripts/portability/templates/readme-template.md +72 -0
- package/scripts/portability/test-constants.js +42 -0
- package/scripts/portability/validate-classification.js +529 -0
- package/scripts/portability/validate-exports.js +348 -0
- package/scripts/update/lib/agent-registry.js +35 -0
- package/scripts/update/lib/config-merger.js +140 -10
- package/scripts/update/lib/migration-runner.js +1 -1
- package/scripts/update/lib/refresh-installation.js +293 -8
- package/scripts/update/lib/taxonomy-merger.js +138 -0
- package/scripts/update/lib/utils.js +27 -1
- package/scripts/update/lib/validator.js +114 -4
- 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,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
|
+
};
|