convoke-agents 3.1.0 → 3.2.1
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 +31 -0
- package/README.md +37 -10
- 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/_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/package.json +13 -7
- package/scripts/convoke-doctor.js +172 -1
- package/scripts/install-gyre-agents.js +0 -0
- package/scripts/lib/artifact-utils.js +521 -13
- package/scripts/lib/portfolio/portfolio-engine.js +301 -34
- package/scripts/lib/portfolio/rules/artifact-chain-rule.js +33 -3
- package/scripts/lib/portfolio/rules/conflict-resolver.js +22 -0
- package/scripts/migrate-artifacts.js +69 -10
- 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 +1156 -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/refresh-installation.js +293 -8
- package/scripts/update/lib/utils.js +27 -1
- package/scripts/update/lib/validator.js +114 -4
|
@@ -27,6 +27,194 @@ const { formatMarkdown } = require('./formatters/markdown-formatter');
|
|
|
27
27
|
/** Directories to exclude from portfolio scan */
|
|
28
28
|
const EXCLUDE_DIRS = ['_archive', 'brainstorming', 'design-artifacts', 'journey-examples', 'project-documentation', 'test-artifacts', 'drafts'];
|
|
29
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
|
+
|
|
30
218
|
/**
|
|
31
219
|
* Create an empty InitiativeState for a given initiative.
|
|
32
220
|
* @param {string} initiative - Initiative ID
|
|
@@ -73,60 +261,103 @@ async function generatePortfolio(projectRoot, options = {}) {
|
|
|
73
261
|
const registry = new Map();
|
|
74
262
|
let governed = 0;
|
|
75
263
|
let ungoverned = 0;
|
|
76
|
-
let
|
|
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)
|
|
77
266
|
|
|
78
267
|
const mdFiles = allFiles.filter(f => f.filename.endsWith('.md'));
|
|
79
268
|
|
|
80
269
|
for (const file of mdFiles) {
|
|
81
270
|
|
|
82
|
-
|
|
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
|
|
271
|
+
// Read frontmatter FIRST — governed files have authoritative metadata
|
|
94
272
|
let frontmatter = null;
|
|
95
273
|
let content = '';
|
|
274
|
+
let readFailed = false;
|
|
96
275
|
try {
|
|
97
276
|
content = fs.readFileSync(file.fullPath, 'utf8');
|
|
98
277
|
frontmatter = parseFrontmatter(content).data;
|
|
99
278
|
} catch {
|
|
100
|
-
// Unreadable — treat as no frontmatter
|
|
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;
|
|
101
282
|
}
|
|
102
283
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
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;
|
|
106
293
|
governed++;
|
|
294
|
+
typeResult = { type: artifactType, hcPrefix: null, remainder: '', date: null, typeConfidence: 'high', typeSource: 'frontmatter' };
|
|
107
295
|
} else {
|
|
108
|
-
|
|
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
|
+
}
|
|
109
337
|
}
|
|
110
338
|
|
|
111
339
|
const enriched = {
|
|
112
340
|
filename: file.filename,
|
|
113
341
|
dir: file.dir,
|
|
114
342
|
fullPath: file.fullPath,
|
|
115
|
-
type:
|
|
343
|
+
type: artifactType,
|
|
116
344
|
hcPrefix: typeResult.hcPrefix,
|
|
117
345
|
date: typeResult.date,
|
|
118
|
-
initiative
|
|
346
|
+
initiative,
|
|
119
347
|
frontmatter,
|
|
120
348
|
content,
|
|
121
349
|
isGoverned,
|
|
122
|
-
degradedMode: !isGoverned
|
|
350
|
+
degradedMode: !isGoverned,
|
|
351
|
+
// Story 6.3: 'frontmatter-title' | 'content-fallback' | 'filename-prefix' | 'parent-dir' | 'story-prefix' | 'folder-default' | null
|
|
352
|
+
attributionSource
|
|
123
353
|
};
|
|
124
354
|
|
|
125
|
-
if (!registry.has(
|
|
126
|
-
registry.set(
|
|
355
|
+
if (!registry.has(initiative)) {
|
|
356
|
+
registry.set(initiative, []);
|
|
127
357
|
}
|
|
128
|
-
registry.get(
|
|
358
|
+
registry.get(initiative).push(enriched);
|
|
129
359
|
}
|
|
360
|
+
const unattributed = unattributedFiles.length;
|
|
130
361
|
|
|
131
362
|
// FR39: warn if no governed artifacts
|
|
132
363
|
if (governed === 0 && mdFiles.length > 0) {
|
|
@@ -180,11 +411,13 @@ async function generatePortfolio(projectRoot, options = {}) {
|
|
|
180
411
|
return {
|
|
181
412
|
initiatives: results,
|
|
182
413
|
wipRadar,
|
|
414
|
+
unattributedFiles, // Story 6.3: full list with reasons (for --show-unattributed)
|
|
183
415
|
summary: {
|
|
184
416
|
total: mdFiles.length,
|
|
185
417
|
governed,
|
|
186
418
|
ungoverned,
|
|
187
419
|
unattributed,
|
|
420
|
+
attributableButUngoverned, // Story 6.3: count of files attributed via fallback layers
|
|
188
421
|
healthScore: { governed, total: attributable, percentage: healthPercentage }
|
|
189
422
|
}
|
|
190
423
|
};
|
|
@@ -199,17 +432,19 @@ Usage: convoke-portfolio [options]
|
|
|
199
432
|
Generate a portfolio view of all initiatives from artifact analysis.
|
|
200
433
|
|
|
201
434
|
Options:
|
|
202
|
-
--terminal
|
|
203
|
-
--markdown
|
|
204
|
-
--sort <mode>
|
|
205
|
-
--filter <prefix>
|
|
206
|
-
--verbose
|
|
207
|
-
--
|
|
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
|
|
208
442
|
|
|
209
443
|
Examples:
|
|
210
|
-
convoke-portfolio
|
|
211
|
-
convoke-portfolio --markdown
|
|
212
|
-
convoke-portfolio --sort last-activity
|
|
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
|
|
213
448
|
`);
|
|
214
449
|
}
|
|
215
450
|
|
|
@@ -229,6 +464,7 @@ async function main() {
|
|
|
229
464
|
|
|
230
465
|
const useMarkdown = args.includes('--markdown');
|
|
231
466
|
const useVerbose = args.includes('--verbose');
|
|
467
|
+
const showUnattributed = args.includes('--show-unattributed');
|
|
232
468
|
const sortMode = args.includes('--sort') && args[args.indexOf('--sort') + 1] === 'last-activity'
|
|
233
469
|
? 'last-activity'
|
|
234
470
|
: 'alpha';
|
|
@@ -280,6 +516,29 @@ async function main() {
|
|
|
280
516
|
const hs = result.summary.healthScore;
|
|
281
517
|
console.log(`Governance: ${hs.governed}/${hs.total} artifacts governed (${hs.percentage}%)`);
|
|
282
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
|
+
|
|
283
542
|
// Verbose: inference trace per initiative
|
|
284
543
|
if (useVerbose) {
|
|
285
544
|
console.log('\n--- Inference Trace ---');
|
|
@@ -302,4 +561,12 @@ if (require.main === module) {
|
|
|
302
561
|
});
|
|
303
562
|
}
|
|
304
563
|
|
|
305
|
-
module.exports = {
|
|
564
|
+
module.exports = {
|
|
565
|
+
generatePortfolio,
|
|
566
|
+
makeEmptyState,
|
|
567
|
+
attributeFile,
|
|
568
|
+
explainUnattributed,
|
|
569
|
+
EXCLUDE_DIRS,
|
|
570
|
+
STORY_PREFIX_MAP,
|
|
571
|
+
PORTFOLIO_FOLDER_DEFAULT_MAP
|
|
572
|
+
};
|
|
@@ -90,11 +90,41 @@ function applyArtifactChainRule(state, artifacts, _options = {}) {
|
|
|
90
90
|
return state;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// No recognized pattern
|
|
94
|
-
|
|
93
|
+
// No recognized pattern — collect evidence so the operator can see WHY it's unknown (Story 6.3)
|
|
94
|
+
const evidence = collectPhaseEvidence(artifacts, types, hcPrefixes);
|
|
95
|
+
state.phase = { value: 'unknown', source: 'artifact-chain', confidence: 'inferred', evidence };
|
|
95
96
|
return state;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Collect a one-line description of what the phase inference looked at when it
|
|
101
|
+
* couldn't determine a phase. Used to populate the Next Action with context
|
|
102
|
+
* instead of the generic "Create PRD or brief" message.
|
|
103
|
+
*
|
|
104
|
+
* @param {Array<Object>} artifacts - All artifacts for this initiative
|
|
105
|
+
* @param {Set<string>} types - Distinct artifact types present
|
|
106
|
+
* @param {Set<string>} hcPrefixes - HC prefixes present (e.g. 'hc1', 'hc2')
|
|
107
|
+
* @returns {string[]} Evidence list, e.g. ["3 artifacts found", "no PRD/brief", "no HC chain", ...]
|
|
108
|
+
*/
|
|
109
|
+
function collectPhaseEvidence(artifacts, types, hcPrefixes) {
|
|
110
|
+
const evidence = [];
|
|
111
|
+
const count = artifacts.length;
|
|
112
|
+
evidence.push(count === 1 ? '1 artifact found' : `${count} artifacts found`);
|
|
113
|
+
|
|
114
|
+
// Special-case: only HC1 present (incomplete discovery, doesn't trigger discovery branch)
|
|
115
|
+
if (hcPrefixes.size === 1 && hcPrefixes.has('hc1')) {
|
|
116
|
+
evidence.push('incomplete HC chain (needs HC2-HC6)');
|
|
117
|
+
return evidence;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!types.has('prd') && !types.has('brief')) evidence.push('no PRD/brief');
|
|
121
|
+
if (!types.has('arch')) evidence.push('no architecture');
|
|
122
|
+
if (hcPrefixes.size === 0) evidence.push('no HC chain');
|
|
123
|
+
if (!types.has('epic')) evidence.push('no epic');
|
|
124
|
+
|
|
125
|
+
return evidence;
|
|
126
|
+
}
|
|
127
|
+
|
|
98
128
|
/**
|
|
99
129
|
* Check if epic content indicates completion via flexible markers.
|
|
100
130
|
* @param {string} content - Epic file content
|
|
@@ -123,4 +153,4 @@ function detectHCChain(state, hcPrefixes) {
|
|
|
123
153
|
}
|
|
124
154
|
}
|
|
125
155
|
|
|
126
|
-
module.exports = { applyArtifactChainRule, isEpicDone, detectHCChain, DONE_PATTERNS };
|
|
156
|
+
module.exports = { applyArtifactChainRule, isEpicDone, detectHCChain, collectPhaseEvidence, DONE_PATTERNS };
|
|
@@ -32,6 +32,28 @@ function applyConflictResolver(state, artifacts, _options = {}) {
|
|
|
32
32
|
state.lastArtifact = { file: last.filename, date: last.date || 'unknown' };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Story 6.3: If phase is unknown AND a recognized-type artifact exists AND we have evidence,
|
|
36
|
+
// surface a context-aware next action instead of the generic "Create PRD or brief".
|
|
37
|
+
// - Initiatives with zero artifacts still get the generic message (legitimate use case).
|
|
38
|
+
// - Initiatives whose ONLY artifacts are fallback-attributed (synthetic 'unknown' type)
|
|
39
|
+
// also get the generic message — those don't reflect a real phase signal worth elaborating on.
|
|
40
|
+
// This guards against the design-intent inversion caught in code review:
|
|
41
|
+
// a single fallback-attributed note shouldn't override "Create PRD or brief".
|
|
42
|
+
const hasRecognizedArtifact = artifacts.some(a => a && a.type && a.type !== 'unknown');
|
|
43
|
+
if (
|
|
44
|
+
state.phase.value === 'unknown' &&
|
|
45
|
+
Array.isArray(state.phase.evidence) &&
|
|
46
|
+
state.phase.evidence.length > 0 &&
|
|
47
|
+
hasRecognizedArtifact
|
|
48
|
+
) {
|
|
49
|
+
const summary = state.phase.evidence.slice(0, 2).join(', ');
|
|
50
|
+
state.nextAction = {
|
|
51
|
+
value: `Unknown phase: ${summary}`,
|
|
52
|
+
source: 'conflict-resolver'
|
|
53
|
+
};
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
// Derive nextAction from phase if not already set by chain-gap analysis
|
|
36
58
|
if (!state.nextAction.value) {
|
|
37
59
|
state.nextAction = deriveNextAction(state);
|
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
verifyHistoryChain,
|
|
24
24
|
executeInjections,
|
|
25
25
|
resolveAmbiguous,
|
|
26
|
+
loadResolutionMap,
|
|
26
27
|
detectMigrationState,
|
|
27
28
|
generateGovernanceADR,
|
|
28
29
|
supersedePreviousADR
|
|
@@ -39,7 +40,7 @@ const VALID_DIR_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
|
39
40
|
* Parse CLI arguments from argv array.
|
|
40
41
|
*
|
|
41
42
|
* @param {string[]} argv - Arguments (typically process.argv.slice(2))
|
|
42
|
-
* @returns {{help: boolean, includeDirs: string[], apply: boolean, force: boolean, verbose: boolean}}
|
|
43
|
+
* @returns {{help: boolean, includeDirs: string[], apply: boolean, force: boolean, verbose: boolean, resolutionFile: string|null, resolutionFileError: string|null}}
|
|
43
44
|
*/
|
|
44
45
|
function parseArgs(argv) {
|
|
45
46
|
const help = argv.includes('--help') || argv.includes('-h');
|
|
@@ -66,7 +67,37 @@ function parseArgs(argv) {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
// Story 6.4: --resolution-file <path> — operator decisions for AMBIGUOUS entries.
|
|
71
|
+
// Loaded and validated in main() after the manifest is generated.
|
|
72
|
+
// Also accept the GNU --resolution-file=path form. Reject missing/empty/flag-like
|
|
73
|
+
// values loudly via parse error so operators don't accidentally run a destructive
|
|
74
|
+
// --apply --force without their overrides being honored.
|
|
75
|
+
let resolutionFile = null;
|
|
76
|
+
let resolutionFileError = null;
|
|
77
|
+
|
|
78
|
+
// Check the equals form first: --resolution-file=path
|
|
79
|
+
const eqArg = argv.find(a => a.startsWith('--resolution-file='));
|
|
80
|
+
if (eqArg) {
|
|
81
|
+
const value = eqArg.slice('--resolution-file='.length);
|
|
82
|
+
if (!value || value.startsWith('-')) {
|
|
83
|
+
resolutionFileError = `--resolution-file requires a non-empty path (got: '${value}')`;
|
|
84
|
+
} else {
|
|
85
|
+
resolutionFile = value;
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
const resolutionIdx = argv.indexOf('--resolution-file');
|
|
89
|
+
if (resolutionIdx !== -1) {
|
|
90
|
+
const nextArg = argv[resolutionIdx + 1];
|
|
91
|
+
// Reject: missing arg, empty arg, anything starting with `-` (covers --flags AND -singledash)
|
|
92
|
+
if (!nextArg || nextArg.startsWith('-')) {
|
|
93
|
+
resolutionFileError = `--resolution-file requires a path argument (got: '${nextArg || '<missing>'}')`;
|
|
94
|
+
} else {
|
|
95
|
+
resolutionFile = nextArg;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { help, includeDirs, apply, force, verbose, resolutionFile, resolutionFileError };
|
|
70
101
|
}
|
|
71
102
|
|
|
72
103
|
// --- Help ---
|
|
@@ -79,17 +110,21 @@ Analyze artifact files and show what the governance migration would do.
|
|
|
79
110
|
Dry-run by default — no files are modified.
|
|
80
111
|
|
|
81
112
|
Options:
|
|
82
|
-
--include <dirs>
|
|
83
|
-
|
|
84
|
-
--verbose
|
|
85
|
-
--apply
|
|
86
|
-
--force
|
|
87
|
-
--
|
|
113
|
+
--include <dirs> Comma-separated directory names to scan (relative to _bmad-output/)
|
|
114
|
+
Default: planning-artifacts,vortex-artifacts,gyre-artifacts
|
|
115
|
+
--verbose Show cross-references for ambiguous files
|
|
116
|
+
--apply Execute the rename migration (commit 1: git mv)
|
|
117
|
+
--force Bypass confirmation prompt (use with --apply for automation)
|
|
118
|
+
--resolution-file <path> JSON file with operator decisions for ambiguous entries.
|
|
119
|
+
Combined with --force gives a fully non-interactive run.
|
|
120
|
+
Schema: { "schemaVersion": 1, "resolutions": { "dir/file.md": { "action": "rename", "initiative": "convoke" } } }
|
|
121
|
+
--help, -h Show this help
|
|
88
122
|
|
|
89
123
|
Examples:
|
|
90
124
|
convoke-migrate-artifacts Dry-run with default scope
|
|
91
125
|
convoke-migrate-artifacts --verbose Dry-run with cross-references
|
|
92
126
|
convoke-migrate-artifacts --include planning-artifacts Dry-run for one directory
|
|
127
|
+
convoke-migrate-artifacts --apply --force --resolution-file resolutions.json Non-interactive apply with operator decisions
|
|
93
128
|
`);
|
|
94
129
|
}
|
|
95
130
|
|
|
@@ -172,6 +207,13 @@ async function main() {
|
|
|
172
207
|
return;
|
|
173
208
|
}
|
|
174
209
|
|
|
210
|
+
// Story 6.4: fail fast on a malformed --resolution-file flag so a typo can't
|
|
211
|
+
// silently turn into a destructive --apply --force run with no overrides applied.
|
|
212
|
+
if (args.resolutionFileError) {
|
|
213
|
+
console.error(`Error: ${args.resolutionFileError}`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
175
217
|
if (args.force && !args.apply) {
|
|
176
218
|
console.log('Warning: --force has no effect without --apply. Running dry-run instead.');
|
|
177
219
|
}
|
|
@@ -247,8 +289,25 @@ async function main() {
|
|
|
247
289
|
// Load taxonomy for ambiguous resolution
|
|
248
290
|
const taxonomy = readTaxonomy(projectRoot);
|
|
249
291
|
|
|
250
|
-
//
|
|
251
|
-
|
|
292
|
+
// Story 6.4: optional pre-loaded operator resolutions via --resolution-file.
|
|
293
|
+
// Loaded once here so any validation error fails fast before we touch the manifest.
|
|
294
|
+
let resolutionMap = null;
|
|
295
|
+
if (args.resolutionFile) {
|
|
296
|
+
try {
|
|
297
|
+
resolutionMap = loadResolutionMap(args.resolutionFile, taxonomy);
|
|
298
|
+
const count = Object.keys(resolutionMap).length;
|
|
299
|
+
console.log(`Loaded ${count} operator resolution(s) from ${args.resolutionFile}.`);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error(`Error loading resolution file: ${err.message}`);
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Resolve ambiguous files: resolution map (if any) → no-candidates skip → force skip → interactive prompt
|
|
307
|
+
const resolution = await resolveAmbiguous(manifest, taxonomy, projectRoot, {
|
|
308
|
+
force: args.force,
|
|
309
|
+
resolutionMap
|
|
310
|
+
});
|
|
252
311
|
if (resolution.resolved > 0 || resolution.skipped > 0) {
|
|
253
312
|
console.log(`\nAmbiguous resolution: ${resolution.resolved} resolved, ${resolution.skipped} skipped.`);
|
|
254
313
|
}
|