ma-agents 3.17.0 → 3.18.0-beta.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/README.md +208 -1
- package/bin/cli.js +4258 -181
- package/docs/architecture.md +11 -0
- package/lib/bmad-extension/skills/add-sprint/SKILL.md +112 -0
- package/lib/bmad-extension/skills/add-to-sprint/SKILL.md +112 -0
- package/lib/bmad-extension/skills/bmad-dev-epic/SKILL.md +112 -0
- package/lib/bmad-extension/skills/bmad-dev-story/workflow.md +112 -0
- package/lib/bmad-extension/skills/bmad-knowledge/SKILL.md +112 -0
- package/lib/bmad-extension/skills/bmad-sprint-planning/workflow.md +161 -0
- package/lib/bmad-extension/skills/bmad-sprint-status/workflow.md +112 -0
- package/lib/bmad-extension/skills/cleanup-done/SKILL.md +112 -0
- package/lib/bmad-extension/skills/close-sprint/SKILL.md +112 -0
- package/lib/bmad-extension/skills/generate-backlog/SKILL.md +163 -1
- package/lib/bmad-extension/skills/ma-agent-cyber/customize.toml +31 -0
- package/lib/bmad-extension/skills/ma-agent-devops/customize.toml +31 -0
- package/lib/bmad-extension/skills/ma-agent-sqa/customize.toml +32 -0
- package/lib/bmad-extension/skills/ma-agent-sre/customize.toml +31 -0
- package/lib/bmad-extension/skills/mil498-ocd/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension/skills/mil498-sdd/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension/skills/mil498-sdp/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension/skills/mil498-srs/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension/skills/mil498-ssdd/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension/skills/mil498-sss/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension/skills/mil498-std/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension/skills/modify-sprint/SKILL.md +112 -0
- package/lib/bmad-extension/skills/prioritize-backlog/SKILL.md +163 -1
- package/lib/bmad-extension/skills/remove-from-sprint/SKILL.md +112 -0
- package/lib/bmad-extension/skills/sprint-status-view/SKILL.md +112 -0
- package/lib/bmad-extension/skills/sqa-audit/SKILL.md +6 -2
- package/lib/bmad-extension/skills/sqa-ieee12207/SKILL.md +2 -2
- package/lib/bmad-extension/skills/sqa-requirements-quality/SKILL.md +1 -1
- package/lib/bmad-extension/workflows/add-sprint/workflow.md +112 -0
- package/lib/bmad-extension/workflows/add-to-sprint/workflow.md +112 -0
- package/lib/bmad-extension/workflows/modify-sprint/workflow.md +112 -0
- package/lib/bmad-extension/workflows/remove-from-sprint/workflow.md +112 -0
- package/lib/bmad-extension/workflows/sprint-status-view/workflow.md +112 -0
- package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +1 -1
- package/lib/bmad-extension-plugin/skills/add-sprint/SKILL.md +112 -0
- package/lib/bmad-extension-plugin/skills/add-to-sprint/SKILL.md +112 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-epic/SKILL.md +112 -0
- package/lib/bmad-extension-plugin/skills/bmad-dev-story/workflow.md +112 -0
- package/lib/bmad-extension-plugin/skills/bmad-knowledge/SKILL.md +112 -0
- package/lib/bmad-extension-plugin/skills/bmad-sprint-planning/workflow.md +161 -0
- package/lib/bmad-extension-plugin/skills/bmad-sprint-status/workflow.md +112 -0
- package/lib/bmad-extension-plugin/skills/cleanup-done/SKILL.md +112 -0
- package/lib/bmad-extension-plugin/skills/close-sprint/SKILL.md +112 -0
- package/lib/bmad-extension-plugin/skills/generate-backlog/SKILL.md +163 -1
- package/lib/bmad-extension-plugin/skills/ma-agent-cyber/customize.toml +31 -0
- package/lib/bmad-extension-plugin/skills/ma-agent-devops/customize.toml +31 -0
- package/lib/bmad-extension-plugin/skills/ma-agent-sqa/customize.toml +32 -0
- package/lib/bmad-extension-plugin/skills/ma-agent-sre/customize.toml +31 -0
- package/lib/bmad-extension-plugin/skills/mil498-ocd/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension-plugin/skills/mil498-sdd/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension-plugin/skills/mil498-sdp/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension-plugin/skills/mil498-srs/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension-plugin/skills/mil498-ssdd/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension-plugin/skills/mil498-sss/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension-plugin/skills/mil498-std/prompts/01-discover-project-artifacts.md +2 -2
- package/lib/bmad-extension-plugin/skills/modify-sprint/SKILL.md +112 -0
- package/lib/bmad-extension-plugin/skills/prioritize-backlog/SKILL.md +163 -1
- package/lib/bmad-extension-plugin/skills/remove-from-sprint/SKILL.md +112 -0
- package/lib/bmad-extension-plugin/skills/sprint-status-view/SKILL.md +112 -0
- package/lib/bmad-extension-plugin/skills/sqa-audit/SKILL.md +6 -2
- package/lib/bmad-extension-plugin/skills/sqa-ieee12207/SKILL.md +2 -2
- package/lib/bmad-extension-plugin/skills/sqa-requirements-quality/SKILL.md +1 -1
- package/lib/bmad.js +155 -5
- package/lib/bound-projects.js +265 -0
- package/lib/confluence-page-tree.js +925 -0
- package/lib/confluence-publish-hook.js +1781 -0
- package/lib/custom-marketplace.js +28 -18
- package/lib/installer.js +357 -10
- package/lib/store-backends.js +64 -0
- package/package.json +2 -2
- package/skills/add-sprint/SKILL.md +112 -0
- package/skills/add-to-sprint/SKILL.md +112 -0
- package/skills/bmad-knowledge/SKILL.md +112 -0
- package/skills/bmad-sprint-planning/SKILL.md +176 -3
- package/skills/bmad-sprint-status/SKILL.md +112 -0
- package/skills/cleanup-done/SKILL.md +112 -0
- package/skills/close-sprint/SKILL.md +112 -0
- package/skills/generate-backlog/SKILL.md +164 -2
- package/skills/modify-sprint/SKILL.md +112 -0
- package/skills/prioritize-backlog/SKILL.md +163 -1
- package/skills/remove-from-sprint/SKILL.md +112 -0
- package/skills/sprint-status-view/SKILL.md +112 -0
- package/skills/story-status-lookup/SKILL.md +112 -0
package/bin/cli.js
CHANGED
|
@@ -32,16 +32,35 @@ const prompts = require('prompts');
|
|
|
32
32
|
const chalk = require('chalk');
|
|
33
33
|
const path = require('path');
|
|
34
34
|
const fs = require('fs');
|
|
35
|
+
// Used ONLY as a validity oracle for the authoritative binding file (see
|
|
36
|
+
// resolveProjectTopology). Already loaded on every run via lib/bmad.js.
|
|
37
|
+
const yaml = require('js-yaml');
|
|
35
38
|
const { execFileSync } = require('child_process');
|
|
36
|
-
const { installSkill, uninstallSkill, getStatus, listSkills, listAgents, updateProjectContextRepoLayout, migrateRetiredSkills, reconcileAgentsSharedSkills } = require('../lib/installer');
|
|
37
|
-
|
|
39
|
+
const { installSkill, uninstallSkill, getStatus, listSkills, listAgents, updateProjectContextRepoLayout, migrateRetiredSkills, reconcileAgentsSharedSkills, generateProjectContext } = require('../lib/installer');
|
|
40
|
+
// Story 31.13 / OQ-1 — the PER-MACHINE bound-project record. Required here and
|
|
41
|
+
// nowhere else: `resolveProjectTopology` must never depend on it (NFR83), and the
|
|
42
|
+
// single import site is what makes "losing it degrades `status` only" a
|
|
43
|
+
// structural property rather than a promise.
|
|
44
|
+
const { readBoundProjects, recordBoundProject, boundProjectsPath } = require('../lib/bound-projects');
|
|
45
|
+
const { getProfile, setProfile, resolveProfile, getBmadModules, setBmadModules, showDemerzelDeprecationNoticeOnce, getCustomMarketplaceSelection, hasShownNotice, markNoticeShown } = require('../lib/profile');
|
|
38
46
|
const { reconfigure: runReconfigure, ReconfigureYesRejectedError, ManifestNotFoundError, RoomodesSlugDivergenceError } = require('../lib/reconfigure');
|
|
39
47
|
const { uninstallProfileArtifacts } = require('../lib/uninstall');
|
|
40
48
|
const bmad = require('../lib/bmad');
|
|
41
49
|
const { getBmadPlatformCode } = require('../lib/agents');
|
|
42
50
|
const { enumerateCustomSource, NFR71_WARNING, CustomMarketplaceError, reconcileCustomMarketplaceSelection, registryTrustLabel } = require('../lib/custom-marketplace');
|
|
43
51
|
const { setLogSink, diag, diagError } = require('../lib/diag');
|
|
52
|
+
// Story 31.15 — the publish trigger for the five BMAD authoring skills. Kept in
|
|
53
|
+
// its own leaf module for the same reason `lib/confluence-page-tree.js` is: the
|
|
54
|
+
// marker discipline and the TOML reader are pure and must be testable without a
|
|
55
|
+
// CLI around them.
|
|
56
|
+
const confluencePublishHook = require('../lib/confluence-publish-hook');
|
|
44
57
|
const { handleCreateSkill, handleValidateSkill, handleSetMandatory, handleCustomizeAgent, handleCreateAgent } = require('../lib/skill-authoring');
|
|
58
|
+
// 2B nit 1 — the knowledge-store `backend` enum and its default now live in a
|
|
59
|
+
// dependency-free leaf module under lib/, so `lib/installer.js` (which must NOT
|
|
60
|
+
// require this file — the dependency runs the other way) consumes the SAME
|
|
61
|
+
// definition instead of open-coding a second one. See lib/store-backends.js.
|
|
62
|
+
const { STORE_BACKEND_LIST, DEFAULT_STORE_BACKEND } = require('../lib/store-backends');
|
|
63
|
+
const crypto = require('crypto');
|
|
45
64
|
|
|
46
65
|
const PKG = require('../package.json');
|
|
47
66
|
const NAME = PKG.name;
|
|
@@ -110,7 +129,15 @@ ${chalk.bold('Usage:')}
|
|
|
110
129
|
${chalk.cyan(`npx ${NAME} install`)} <skill> <agents...> Install directly
|
|
111
130
|
${chalk.cyan(`npx ${NAME} uninstall`)} <skill> <agents..> Uninstall a skill
|
|
112
131
|
${chalk.cyan(`npx ${NAME} uninstall --profile-artifacts`)} Remove ma-agents-owned profile content from all injection files
|
|
113
|
-
${chalk.cyan(`npx ${NAME} status`)} Show installed skills
|
|
132
|
+
${chalk.cyan(`npx ${NAME} status`)} Show install scope, bound projects and installed skills
|
|
133
|
+
${chalk.cyan(`npx ${NAME} bind`)} Bind this project (knowledge stores + sprint management)
|
|
134
|
+
— installs nothing
|
|
135
|
+
${chalk.cyan(`npx ${NAME} confluence-plan`)} Show the Confluence page tree a
|
|
136
|
+
Confluence-backed store publishes,
|
|
137
|
+
and the operations it performs
|
|
138
|
+
${chalk.cyan(`npx ${NAME} confluence-record-root-page`)} Record the page id Confluence gave
|
|
139
|
+
a store's root page, after publishing
|
|
140
|
+
(--store, --title, --id)
|
|
114
141
|
${chalk.cyan(`npx ${NAME} list`)} List available skills
|
|
115
142
|
${chalk.cyan(`npx ${NAME} agents`)} List supported agents
|
|
116
143
|
${chalk.cyan(`npx ${NAME} create-skill`)} <name> Scaffold a new skill directory
|
|
@@ -206,13 +233,58 @@ function showAgents() {
|
|
|
206
233
|
console.log('');
|
|
207
234
|
}
|
|
208
235
|
|
|
209
|
-
function showStatus(args) {
|
|
236
|
+
async function showStatus(args) {
|
|
210
237
|
const globalFlag = args.includes('--global');
|
|
211
238
|
const scope = globalFlag ? 'global' : 'project';
|
|
212
239
|
const positional = args.filter(a => a !== '--global');
|
|
213
240
|
|
|
214
241
|
const results = getStatus(positional, '', scope);
|
|
215
242
|
|
|
243
|
+
// Story 31.13 AC7 (FR253) — the ACTIVE install scope, and for a global install
|
|
244
|
+
// the per-machine list of bound projects with each one's binding health.
|
|
245
|
+
//
|
|
246
|
+
// BEFORE the early return below, deliberately: a machine with a global install
|
|
247
|
+
// and no project-scope skills takes that return, and it is precisely the
|
|
248
|
+
// machine whose bound-project list the user needs. Reporting scope only when
|
|
249
|
+
// skills happen to be listed would make AC7 invisible on its own primary case.
|
|
250
|
+
const scopes = detectInstallScopes();
|
|
251
|
+
printScopeReport(scopes);
|
|
252
|
+
|
|
253
|
+
// Story 31.15 AC12(c) — THE DETECTION PATH INDEPENDENT OF THE GENERATOR.
|
|
254
|
+
//
|
|
255
|
+
// A third writer (an agent running the upstream `bmad-customize` skill) can
|
|
256
|
+
// leave `_bmad/custom/<skill>.toml` in a state where the resolver discards the
|
|
257
|
+
// WHOLE team layer at exit 0 — the publish hook and the customizer's own
|
|
258
|
+
// change together — without ever calling ma-agents' generator. The generator's
|
|
259
|
+
// refusal cannot see that, because the generator did not run. This can, and it
|
|
260
|
+
// is the only thing that fires between two installs.
|
|
261
|
+
//
|
|
262
|
+
// Before the early return below, for the same reason `printScopeReport` is: a
|
|
263
|
+
// project with no ma-agents skills listed is still a project whose publish may
|
|
264
|
+
// be silently dead.
|
|
265
|
+
printPublishHookCollisionReport(process.cwd());
|
|
266
|
+
|
|
267
|
+
// Story 31.13 AC3 — THE OFFER'S CLI CALL SITE.
|
|
268
|
+
//
|
|
269
|
+
// The offer that reaches a globally-installed SKILL at activation time is the
|
|
270
|
+
// Story 31.4 preflight embedded in the skill documents; the CLI is not present
|
|
271
|
+
// at that moment and cannot be. But `offerBind` must not be a seam with no
|
|
272
|
+
// production caller — an exported function nothing calls is the "presence, not
|
|
273
|
+
// effect" shape this epic keeps producing, and it is what left Story 31.11's
|
|
274
|
+
// AC6 offer with no reader.
|
|
275
|
+
//
|
|
276
|
+
// `status` on a GLOBAL install, run inside a project with no committed binding,
|
|
277
|
+
// is the one CLI moment where the user is asking about exactly this state. So
|
|
278
|
+
// the offer is made here, and only here.
|
|
279
|
+
//
|
|
280
|
+
// `yes` is derived from whether a TTY is attached. Without one — a pipe, a CI
|
|
281
|
+
// step, a test harness — there is no user to choose, so `offerBind` states the
|
|
282
|
+
// remedy and stops rather than prompting. It never binds either way; only an
|
|
283
|
+
// explicit `true` answer can do that.
|
|
284
|
+
if (scopes.global) {
|
|
285
|
+
await offerBind({ projectRoot: process.cwd(), yes: !process.stdin.isTTY });
|
|
286
|
+
}
|
|
287
|
+
|
|
216
288
|
if (results.length === 0) {
|
|
217
289
|
console.log(chalk.gray(`\n No skills installed (${scope} scope)\n`));
|
|
218
290
|
return;
|
|
@@ -497,13 +569,103 @@ function normalizePath(p) {
|
|
|
497
569
|
return p.replace(/\\/g, '/');
|
|
498
570
|
}
|
|
499
571
|
|
|
572
|
+
// Characters that cannot appear RAW inside a YAML double-quoted scalar without
|
|
573
|
+
// either terminating it early or producing something a real YAML parser rejects:
|
|
574
|
+
// \ and " - the two structural characters of the scalar itself.
|
|
575
|
+
// U+0000-U+001F - C0 controls. Includes CR/LF (which would break out of the
|
|
576
|
+
// scalar and inject a sibling key) and TAB/NUL/VT/FF, which
|
|
577
|
+
// YAML forbids raw and js-yaml rejects outright.
|
|
578
|
+
// U+007F-U+009F - DEL and the C1 controls, likewise forbidden raw. U+0085
|
|
579
|
+
// (NEL) lives in this range and is a YAML line break.
|
|
580
|
+
// U+2028 / U+2029 - Unicode line/paragraph separators; YAML line breaks.
|
|
581
|
+
// U+00A0 / U+FEFF - legal raw, but JS String.prototype.trim() treats both as
|
|
582
|
+
// whitespace, so leaving them raw would make this module's
|
|
583
|
+
// own line-based reader lossy. Escaped for round-trip
|
|
584
|
+
// fidelity rather than for safety.
|
|
585
|
+
const YAML_DQ_MUST_ESCAPE = /[\\"\u0000-\u001f\u007f-\u009f\u00a0\u2028\u2029\ufeff]/g;
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Renders any value as a VALID YAML double-quoted scalar.
|
|
589
|
+
*
|
|
590
|
+
* INVARIANT: total. It never throws, and there is no input for which it emits
|
|
591
|
+
* something a conforming YAML parser rejects. Correct escaping subsumes the
|
|
592
|
+
* injection defence entirely - an escaped CR/LF cannot break out of the scalar -
|
|
593
|
+
* so there is nothing left here to reject. Any *policy* rejection of line breaks
|
|
594
|
+
* belongs at the serializer boundary, which knows the file and field name (see
|
|
595
|
+
* assertSingleLineIdentity). Paired with yamlUnescapeDoubleQuoted so writer and
|
|
596
|
+
* reader agree on exactly one legal value set.
|
|
597
|
+
*
|
|
598
|
+
* Shared with writeConfigField (_bmad/bmm/config.yaml), which is why a throw here
|
|
599
|
+
* would name the wrong file. Why the earlier *rejecting* escaper was withdrawn is
|
|
600
|
+
* in the commit message and the story record.
|
|
601
|
+
*/
|
|
500
602
|
function yamlEscapeValue(v) {
|
|
501
|
-
if (
|
|
603
|
+
if (v == null || v === '') return '""';
|
|
502
604
|
const s = String(v);
|
|
503
|
-
|
|
504
|
-
|
|
605
|
+
return '"' + s.replace(YAML_DQ_MUST_ESCAPE, (ch) => {
|
|
606
|
+
if (ch === '\\') return '\\\\';
|
|
607
|
+
if (ch === '"') return '\\"';
|
|
608
|
+
return '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0');
|
|
609
|
+
}) + '"';
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The exact inverse of yamlEscapeValue for the three escape forms this module
|
|
614
|
+
* emits (backslash-backslash, backslash-quote, \uXXXX), plus the remainder of
|
|
615
|
+
* the YAML 1.2 double-quoted escape set so that a hand-edited file - or one
|
|
616
|
+
* written by a real YAML emitter - still reads correctly. An unknown escape
|
|
617
|
+
* degrades to the escaped character itself rather than throwing: this reader's
|
|
618
|
+
* job is to resolve a binding, and resolveProjectTopology classifies unusable
|
|
619
|
+
* content separately.
|
|
620
|
+
*/
|
|
621
|
+
function yamlUnescapeDoubleQuoted(s) {
|
|
622
|
+
return s.replace(/\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (_m, esc) => {
|
|
623
|
+
if (esc.length === 5 && esc[0] === 'u') return String.fromCharCode(parseInt(esc.slice(1), 16));
|
|
624
|
+
if (esc.length === 3 && esc[0] === 'x') return String.fromCharCode(parseInt(esc.slice(1), 16));
|
|
625
|
+
switch (esc) {
|
|
626
|
+
case '0': return '\u0000';
|
|
627
|
+
case 'a': return '\u0007';
|
|
628
|
+
case 'b': return '\b';
|
|
629
|
+
case 't': return '\t';
|
|
630
|
+
case 'n': return '\n';
|
|
631
|
+
case 'v': return '\v';
|
|
632
|
+
case 'f': return '\f';
|
|
633
|
+
case 'r': return '\r';
|
|
634
|
+
case 'e': return '\u001b';
|
|
635
|
+
case 'N': return '\u0085';
|
|
636
|
+
case '_': return '\u00a0';
|
|
637
|
+
case 'L': return '\u2028';
|
|
638
|
+
case 'P': return '\u2029';
|
|
639
|
+
default: return esc;
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The POLICY guard that the first round's B-2 throw was reaching for, relocated
|
|
646
|
+
* to the layer that knows which file it is writing. Applies ONLY to the
|
|
647
|
+
* free-text human-identity fields of a confluence store (space key, root-page
|
|
648
|
+
* title/id) - NOT to paths or git URLs, because U+2028 / U+2029 / U+0085 are all
|
|
649
|
+
* legal in a real directory name on both Linux and Windows, were writable by a
|
|
650
|
+
* pre-31.2 ma-agents, and must therefore keep round-tripping rather than crash
|
|
651
|
+
* an install that has already completed.
|
|
652
|
+
*
|
|
653
|
+
* Escaping already makes these values SAFE; this guard only enforces that a
|
|
654
|
+
* multi-line Confluence space key or page title is nonsense input worth
|
|
655
|
+
* reporting (P5-9.3 fail-loud posture), and it names the real file and field so
|
|
656
|
+
* the message is actionable.
|
|
657
|
+
*/
|
|
658
|
+
function assertSingleLineIdentity(value, fieldName) {
|
|
659
|
+
if (value == null) return value;
|
|
660
|
+
const s = String(value);
|
|
661
|
+
if (/[\r\n\u2028\u2029\u0085]/.test(s)) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
`Refusing to write _bmad-output/project-layout.yaml: the confluence store's ` +
|
|
664
|
+
`${fieldName} contains a line break, which is not valid identity for a ` +
|
|
665
|
+
`Confluence space or page: ${JSON.stringify(s)}`
|
|
666
|
+
);
|
|
505
667
|
}
|
|
506
|
-
return
|
|
668
|
+
return s;
|
|
507
669
|
}
|
|
508
670
|
|
|
509
671
|
function toPortablePath(absolutePath, projectRoot) {
|
|
@@ -532,7 +694,11 @@ function writeConfigField(content, fieldName, value) {
|
|
|
532
694
|
const regex = new RegExp(`^${fieldName}:.*$`, 'm');
|
|
533
695
|
const newLine = `${fieldName}: ${yamlEscapeValue(value)}`;
|
|
534
696
|
if (regex.test(content)) {
|
|
535
|
-
|
|
697
|
+
// Function replacer, NOT the string form: a `$&` / `$'` / `$1` sequence in a
|
|
698
|
+
// legitimate path or Jira URL would otherwise be expanded as a replacement
|
|
699
|
+
// pattern and corrupt config.yaml. Same class as the escaping bug above - a
|
|
700
|
+
// value channel being treated as syntax.
|
|
701
|
+
return content.replace(regex, () => newLine);
|
|
536
702
|
}
|
|
537
703
|
return content.trimEnd() + '\n' + newLine + '\n';
|
|
538
704
|
}
|
|
@@ -550,21 +716,523 @@ function canonicalArtifactPath(suffix) {
|
|
|
550
716
|
return `{project-root}/_bmad-output/${suffix}`;
|
|
551
717
|
}
|
|
552
718
|
|
|
553
|
-
|
|
554
|
-
|
|
719
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
720
|
+
// THE GENERATION STAMP (Story 31.3 AC7; consumed by Story 31.4 AC6)
|
|
721
|
+
//
|
|
722
|
+
// AC7 asks for a stamp "identifying the `project-layout.yaml` state
|
|
723
|
+
// `config.yaml` was generated from, so Story 31.4 can detect drift WITHOUT
|
|
724
|
+
// depending on filesystem mtimes".
|
|
725
|
+
//
|
|
726
|
+
// ── WHY NOT MTIME ─────────────────────────────────────────────────────────
|
|
727
|
+
// Decision P5-9.3's second rule still reads "when a skill activates and
|
|
728
|
+
// `project-layout.yaml` is NEWER THAN the generated `config.yaml`, the skill
|
|
729
|
+
// fails loud" — an mtime comparison. That wording is SUPERSEDED and was never
|
|
730
|
+
// amended (the implementation-readiness review filed it as "C-6 BEFORE 31.3 —
|
|
731
|
+
// amend P5-9.3"). It is not implementable as written: `git checkout`, `git
|
|
732
|
+
// pull` and a fresh clone all set working-tree mtimes to checkout time in no
|
|
733
|
+
// guaranteed order, so a pull that moves a store can leave the committed file
|
|
734
|
+
// OLDER than the generated one (missed drift — the exact failure the guard
|
|
735
|
+
// exists to prevent) and an ordinary rebuild can leave it NEWER with nothing
|
|
736
|
+
// changed (false alarm on every skill activation). This is open question OQ-2,
|
|
737
|
+
// and it settles HERE, with the story that writes the stamp — not with 31.4,
|
|
738
|
+
// which only consumes it.
|
|
739
|
+
//
|
|
740
|
+
// ── THE FORMAT (STABLE CONTRACT FOR STORY 31.4) ───────────────────────────
|
|
741
|
+
//
|
|
742
|
+
// project_layout_stamp: "sha256:<64 lowercase hex chars>"
|
|
743
|
+
//
|
|
744
|
+
// one flat scalar in `_bmad/bmm/config.yaml`, written by `writeConfigField`
|
|
745
|
+
// like every other generated field, so it round-trips through the same escaper
|
|
746
|
+
// and needs no new parser on either side.
|
|
747
|
+
//
|
|
748
|
+
// The hashed input is the CANONICAL RE-SERIALIZATION of the three location
|
|
749
|
+
// records — `system_requirements_store`, `software_requirements_store`,
|
|
750
|
+
// `sprint_management`, in that fixed order — produced by the SAME
|
|
751
|
+
// `serializeStore` / `serializeSprintManagement` functions that write
|
|
752
|
+
// `project-layout.yaml` itself. Deliberately NOT a hash of the file's raw
|
|
753
|
+
// bytes. What is therefore EXCLUDED from the stamp, and why:
|
|
754
|
+
//
|
|
755
|
+
// - the `generated:` date, which changes on every install with nothing having
|
|
756
|
+
// moved;
|
|
757
|
+
// - comments and the header banner;
|
|
758
|
+
// - key order and whitespace;
|
|
759
|
+
// - LINE ENDINGS. This repo is developed on Windows and CI'd on Linux; a
|
|
760
|
+
// raw-byte hash would report drift on every cross-platform checkout with
|
|
761
|
+
// `core.autocrlf` in play, and a guard that cries wolf on a clean clone is
|
|
762
|
+
// a guard people learn to ignore;
|
|
763
|
+
// - `schema_version`. A Story 31.7 migration that rewrites a legacy file into
|
|
764
|
+
// schema v2 WITHOUT moving anything must not read as drift — a legacy
|
|
765
|
+
// binding and its migrated equivalent normalise to the same three records
|
|
766
|
+
// and therefore to the same stamp. That is the AC8/NFR82 posture (nothing
|
|
767
|
+
// moved ⇒ nothing to re-bind) expressed in the stamp itself.
|
|
768
|
+
//
|
|
769
|
+
// What is INCLUDED is exactly what can move an artifact: each store's backend,
|
|
770
|
+
// mode, portable path, git URL, Confluence space key and root-page identity,
|
|
771
|
+
// and the sprint-management record (including the Jira URL / project key).
|
|
772
|
+
//
|
|
773
|
+
// ── HOW STORY 31.4 USES IT ────────────────────────────────────────────────
|
|
774
|
+
// 1. `resolveProjectTopology(projectRoot)` — the committed binding NOW.
|
|
775
|
+
// 2. `computeProjectLayoutStamp(topology, projectRoot)` — what the stamp
|
|
776
|
+
// WOULD be for that binding.
|
|
777
|
+
// 3. `readProjectLayoutStamp(configContent)` — what `config.yaml` records.
|
|
778
|
+
// 4. Not equal ⇒ drift. Both functions are exported for exactly this.
|
|
779
|
+
// Comparison is on the opaque string; 31.4 must not parse it.
|
|
780
|
+
//
|
|
781
|
+
// `readProjectLayoutStamp` returns a DISCRIMINATED result — never a bare
|
|
782
|
+
// string, never a bare `null` — because "there is no stamp" and "there is a
|
|
783
|
+
// stamp and it cannot be used" are DIFFERENT FACTS, and only one of them is
|
|
784
|
+
// benign:
|
|
785
|
+
//
|
|
786
|
+
// { ok: true, value: 'sha256:<64 lowercase hex>' }
|
|
787
|
+
// { ok: false, reason: 'absent', message }
|
|
788
|
+
// { ok: false, reason: 'malformed', message, raw }
|
|
789
|
+
//
|
|
790
|
+
// - `absent` — the file carries no `project_layout_stamp:` line at
|
|
791
|
+
// all. THAT, and only that, is the PRE-F26 config that
|
|
792
|
+
// 31.4 AC5 degrades to today's behaviour for. Not drift.
|
|
793
|
+
// - `malformed` — the line IS present but its value is not a stamp this
|
|
794
|
+
// build wrote: truncated, re-cased, a different algorithm,
|
|
795
|
+
// damaged by a merge, commented out, or indented into some
|
|
796
|
+
// other block. This is NOT a pre-F26 config and MUST NOT
|
|
797
|
+
// be treated as one. The file claims to have been
|
|
798
|
+
// generated and the claim is unreadable, so 31.4 must
|
|
799
|
+
// report `message` verbatim and refuse to certify the
|
|
800
|
+
// binding — the same posture it takes for drift.
|
|
801
|
+
//
|
|
802
|
+
// An earlier draft of this contract said a null read means "pre-F26, NOT
|
|
803
|
+
// drift", and the reader returned that same null for both states. That made
|
|
804
|
+
// `valid binding + mangled stamp` — ordinary merge damage, a hand-edit, a
|
|
805
|
+
// truncated write — classify as pre-F26, so 31.4 would decline to block and
|
|
806
|
+
// a skill would run against paths nothing had verified. Validation
|
|
807
|
+
// degrading to permissive is the defect class F26 exists to remove; it is
|
|
808
|
+
// not allowed to live in the contract 31.4 is built from.
|
|
809
|
+
//
|
|
810
|
+
// 5. ORDER: THE RESOLVER FIRST, THE STAMP SECOND. 31.4 keys on
|
|
811
|
+
// `resolveProjectTopology` and consults the stamp only afterwards.
|
|
812
|
+
// That ordering is what makes the `unbound + fallback` branch of
|
|
813
|
+
// `generateBmmConfig` below correct rather than a silent-permissive hole:
|
|
814
|
+
// that branch generates a config from the caller's answered layout and
|
|
815
|
+
// deliberately writes NO stamp, because there is no `project-layout.yaml`
|
|
816
|
+
// to stamp. A stamp invented there would be a false claim about a binding
|
|
817
|
+
// that does not exist, and would later collide with the real binding the
|
|
818
|
+
// next install writes. Because 31.4 asks the resolver first, it learns
|
|
819
|
+
// `unbound` from the file system BEFORE the stamp is read, so an `absent`
|
|
820
|
+
// stamp is never the fact it reasons from — it is only ever a corroboration
|
|
821
|
+
// of something the resolver already established.
|
|
822
|
+
// 6. To NAME the drift ("which store moved, from where to where", 31.4 AC1),
|
|
823
|
+
// 31.4 compares the per-store path fields already in `config.yaml`
|
|
824
|
+
// (`system_requirements_path` / `software_requirements_path` /
|
|
825
|
+
// `sprint_management_path`) against the freshly resolved topology. The
|
|
826
|
+
// stamp answers "did anything move?"; those fields answer "what".
|
|
827
|
+
//
|
|
828
|
+
// The `sha256:` prefix is part of the value, not decoration: it makes the hash
|
|
829
|
+
// function explicit in the file, so a future change of algorithm is a visible
|
|
830
|
+
// format change rather than a silent mismatch that reads as permanent drift.
|
|
831
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
832
|
+
const PROJECT_LAYOUT_STAMP_FIELD = 'project_layout_stamp';
|
|
833
|
+
const PROJECT_LAYOUT_STAMP_PREFIX = 'sha256:';
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* The exact bytes the stamp hashes. Split out from the hashing so a test can
|
|
837
|
+
* assert WHAT is covered rather than only that two hashes differ — a hash test
|
|
838
|
+
* that only ever compares digests cannot tell "the field is covered" from "the
|
|
839
|
+
* field changed something else".
|
|
840
|
+
*/
|
|
841
|
+
function canonicalBindingBytes(topology, projectRoot) {
|
|
842
|
+
return (
|
|
843
|
+
`system_requirements_store:\n${serializeStore(topology.systemRequirementsStore, projectRoot)}` +
|
|
844
|
+
`software_requirements_store:\n${serializeStore(topology.softwareRequirementsStore, projectRoot)}` +
|
|
845
|
+
serializeSprintManagement(topology.sprintManagement, projectRoot)
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function computeProjectLayoutStamp(topology, projectRoot) {
|
|
850
|
+
const canonical = canonicalBindingBytes(topology, projectRoot);
|
|
851
|
+
return PROJECT_LAYOUT_STAMP_PREFIX +
|
|
852
|
+
crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Reads the stamp back out of `config.yaml` content.
|
|
857
|
+
*
|
|
858
|
+
* CONTRACT — a DISCRIMINATED result, in the same shape and for the same reason as
|
|
859
|
+
* `resolveProjectTopology`'s. Never returns null, never throws:
|
|
860
|
+
*
|
|
861
|
+
* { ok: true, value: 'sha256:<64 lowercase hex>' }
|
|
862
|
+
* { ok: false, reason: 'absent', message: '...' }
|
|
863
|
+
* { ok: false, reason: 'malformed', message: '...', raw: '<what was there>' }
|
|
864
|
+
*
|
|
865
|
+
* The two failure reasons are NOT interchangeable and the split is the whole
|
|
866
|
+
* point of the function's shape. `absent` is a pre-F26 config: nothing ever wrote
|
|
867
|
+
* a stamp here, 31.4 AC5 degrades to today's behaviour, and that is benign.
|
|
868
|
+
* `malformed` is a config that CLAIMS to have been generated and whose claim
|
|
869
|
+
* cannot be read — merge damage, a hand-edit, a truncated write. The previous
|
|
870
|
+
* signature returned the same `null` for both, which meant a valid binding
|
|
871
|
+
* carrying a mangled stamp was indistinguishable from a project that predates the
|
|
872
|
+
* feature, and a consumer following the documented contract would decline to
|
|
873
|
+
* block. A guard that degrades to permissive on damaged input is not a guard.
|
|
874
|
+
*
|
|
875
|
+
* The discriminated shape (rather than the superseded null-or-string pair) is
|
|
876
|
+
* what makes that unmissable: any caller reaching for `.value` is forced past
|
|
877
|
+
* `.ok`, so it cannot silently inherit the old conflation.
|
|
878
|
+
*
|
|
879
|
+
* MATCHING IS DELIBERATELY THE WRITER'S OWN MATCHING. `writeConfigField` locates
|
|
880
|
+
* a field with `^<field>:.*$` under the `m` flag and replaces the FIRST match
|
|
881
|
+
* only, so the reader anchors the key to column 0 and takes the first occurrence
|
|
882
|
+
* too. Reader and writer therefore always speak about the same line: a
|
|
883
|
+
* `# project_layout_stamp: …` in a comment, or an indented `project_layout_stamp:`
|
|
884
|
+
* nested inside some other mapping, is not the field either of them means, and is
|
|
885
|
+
* not accepted as one. (A duplicate top-level key cannot arise from the writer,
|
|
886
|
+
* and if one were hand-added the whole file would already fail js-yaml's
|
|
887
|
+
* duplicate-key check — a far louder failure than this reader's.)
|
|
888
|
+
*
|
|
889
|
+
* The VALUE is validated rather than accepted as free text, and anything that is
|
|
890
|
+
* not exactly this build's output is `malformed` rather than quietly dropped: a
|
|
891
|
+
* truncated digest of any length, uppercase hex, another algorithm's prefix, an
|
|
892
|
+
* unbalanced quote, or trailing content after the digest.
|
|
893
|
+
*/
|
|
894
|
+
const STAMP_ABSENT = 'absent';
|
|
895
|
+
const STAMP_MALFORMED = 'malformed';
|
|
896
|
+
|
|
897
|
+
function readProjectLayoutStamp(configContent) {
|
|
898
|
+
const content = String(configContent == null ? '' : configContent);
|
|
899
|
+
|
|
900
|
+
// Step 1 — is the field there AT ALL? Column-anchored, first occurrence, `m`
|
|
901
|
+
// flag: exactly what writeConfigField would rewrite.
|
|
902
|
+
const keyLine = content.match(/^project_layout_stamp:(.*)$/m);
|
|
903
|
+
if (!keyLine) {
|
|
904
|
+
return {
|
|
905
|
+
ok: false,
|
|
906
|
+
reason: STAMP_ABSENT,
|
|
907
|
+
message:
|
|
908
|
+
`_bmad/bmm/config.yaml carries no ${PROJECT_LAYOUT_STAMP_FIELD}: field. This is a ` +
|
|
909
|
+
`config generated before F26 introduced the stamp, not evidence that anything moved.`,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// `.` does not match `\n` but DOES match `\r`, so a CRLF file would otherwise
|
|
914
|
+
// leave a carriage return glued to the digest and read as malformed.
|
|
915
|
+
const rawValue = keyLine[1].replace(/\r$/, '').trim();
|
|
916
|
+
|
|
917
|
+
// Step 2 — is the value one this build wrote? Quotes must BALANCE; stripping a
|
|
918
|
+
// leading `"` without requiring the trailing one would accept a value the YAML
|
|
919
|
+
// parser reads differently, which is the reader/parser disagreement the suite's
|
|
920
|
+
// js-yaml oracle exists to prevent.
|
|
921
|
+
let unquoted = rawValue;
|
|
922
|
+
if (/^"[^]*"$/.test(unquoted) || /^'[^]*'$/.test(unquoted)) {
|
|
923
|
+
unquoted = unquoted.slice(1, -1);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(unquoted)) {
|
|
927
|
+
return {
|
|
928
|
+
ok: false,
|
|
929
|
+
reason: STAMP_MALFORMED,
|
|
930
|
+
raw: rawValue,
|
|
931
|
+
message:
|
|
932
|
+
`_bmad/bmm/config.yaml has a ${PROJECT_LAYOUT_STAMP_FIELD}: field whose value is not a ` +
|
|
933
|
+
`stamp this ma-agents wrote (expected "sha256:" followed by 64 lowercase hex ` +
|
|
934
|
+
`characters), got ${JSON.stringify(rawValue)}. The file claims to have been generated ` +
|
|
935
|
+
`from a committed binding and that claim cannot be read — treat it as unverified, NOT ` +
|
|
936
|
+
`as a config that predates the stamp. Re-run the installer (or \`ma-agents bind\`) to ` +
|
|
937
|
+
`regenerate config.yaml from _bmad-output/project-layout.yaml.`,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return { ok: true, value: unquoted };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The legacy `{knowledgebase, sprintManagement}` wizard shape viewed as a
|
|
946
|
+
* topology: both stores point at the one answered location. Exactly the mapping
|
|
947
|
+
* `writeProjectLayoutYaml` applies when the committed stores agree, kept in one
|
|
948
|
+
* place so the generated config and the committed binding cannot disagree about
|
|
949
|
+
* what a single answer means.
|
|
950
|
+
*/
|
|
951
|
+
function topologyFromLegacyLayout(layout) {
|
|
952
|
+
const kb = (layout && layout.knowledgebase) || { mode: 'same', path: '.' };
|
|
953
|
+
return {
|
|
954
|
+
// Story 31.5 fix round. The collapse is only correct for a layout that
|
|
955
|
+
// HAS no per-store records — a pre-31.5 project, where one answered location
|
|
956
|
+
// stood for both stores. Story 31.5's wizard feeds this path a layout that
|
|
957
|
+
// carries both: install Step 3.6 reaches it whenever `writeProjectLayoutYaml`
|
|
958
|
+
// throws (an unwritable `_bmad-output/`), leaving the binding `unbound`, and a
|
|
959
|
+
// divergent SYSTEM store then silently collapsed onto the software store's
|
|
960
|
+
// location in the generated `config.yaml` — with the right record sitting
|
|
961
|
+
// unread on the very object being passed in. Same collapse class the read
|
|
962
|
+
// path already fixed; prefer the explicit record, fall back to the view.
|
|
963
|
+
systemRequirementsStore: (layout && layout.systemRequirementsStore) || kb,
|
|
964
|
+
softwareRequirementsStore: (layout && layout.softwareRequirementsStore) || kb,
|
|
965
|
+
sprintManagement: (layout && layout.sprintManagement) || { mode: 'same', path: '.' },
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// A store's value for a `*_path` field in config.yaml. `toPortablePath(undefined)`
|
|
970
|
+
// already returns `'.'`, so a confluence store (which has no `.path` at all)
|
|
971
|
+
// resolves to the local-artifacts default without a special case — see the long
|
|
972
|
+
// note in generateBmmConfig about why that value is deliberately unchanged.
|
|
973
|
+
function storeConfigPath(store, projectRoot) {
|
|
974
|
+
return toPortablePath(store && store.path, projectRoot).portable;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// A store's value for a `*_space_key` field in config.yaml (Story 31.9).
|
|
978
|
+
//
|
|
979
|
+
// Keyed on the RESOLVED backend rather than on the presence of `.spaceKey`, so a
|
|
980
|
+
// file-system store that somehow carries a stray `spaceKey` never publishes one
|
|
981
|
+
// — the backend is the fact, the key is only meaningful under it. Returns `''`
|
|
982
|
+
// (which `yamlEscapeValue` renders as `""`) for every non-confluence store, and
|
|
983
|
+
// also for a confluence store whose space key was never recorded: the routing
|
|
984
|
+
// block distinguishes "not recorded" from "recorded" itself, and must be able to
|
|
985
|
+
// say so rather than print `undefined`.
|
|
986
|
+
function confluenceSpaceKeyOf(store) {
|
|
987
|
+
const backend = backendOfStore(store);
|
|
988
|
+
if (backend !== 'confluence') return '';
|
|
989
|
+
const key = store && store.spaceKey;
|
|
990
|
+
return typeof key === 'string' ? key : '';
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Story 31.3 — GENERATE `_bmad/bmm/config.yaml` FROM `_bmad-output/project-layout.yaml`.
|
|
995
|
+
*
|
|
996
|
+
* AC1. The file does not move and no skill learns a new location: all 39
|
|
997
|
+
* existing references keep reading `_bmad/bmm/config.yaml`, and every field they
|
|
998
|
+
* read today (`knowledgebase_path`, `sprint_management_path`,
|
|
999
|
+
* `planning_artifacts`, `implementation_artifacts`) is still there with the same
|
|
1000
|
+
* derivation. It GROWS two per-store fields, `system_requirements_path` and
|
|
1001
|
+
* `software_requirements_path`, because a single-valued `planning_artifacts`
|
|
1002
|
+
* cannot serve two stores in different places. The retained single-valued fields
|
|
1003
|
+
* resolve to the SOFTWARE requirements store (FR264 as amended 2026-08-02), so
|
|
1004
|
+
* untouched references keep working unchanged.
|
|
1005
|
+
*
|
|
1006
|
+
* AC2. ONE WAY ONLY: committed -> per-machine. This function READS
|
|
1007
|
+
* `project-layout.yaml` and WRITES `config.yaml`. There is no inverse anywhere.
|
|
1008
|
+
* (The FR265 fallback in `readExistingLayout` is NOT an inverse: it pre-populates
|
|
1009
|
+
* a WIZARD from a pre-F26 config so the user can be asked, and writes nothing.)
|
|
1010
|
+
*
|
|
1011
|
+
* AC3. Fires on install and `ma-agents bind` only. Both production call sites are
|
|
1012
|
+
* write paths — Step 3.6 of `install` (inside the `installScope === 'project'`
|
|
1013
|
+
* gate; FR251 forbids a global install doing project-scoped work) and
|
|
1014
|
+
* `handleConfigLayout`. No read path calls it.
|
|
1015
|
+
*
|
|
1016
|
+
* AC4. Overwrites wholesale and says so, naming the file. "Wholesale" is scoped
|
|
1017
|
+
* to the GENERATED fields: the file is upstream BMAD's module config and also
|
|
1018
|
+
* carries fields ma-agents does not own (`user_name`, `output_folder`, ...).
|
|
1019
|
+
* Truncating it would destroy those and break every skill, so generation
|
|
1020
|
+
* overwrites each generated field unconditionally — never merging with, or
|
|
1021
|
+
* preferring, whatever was there — and leaves foreign keys alone. This is what
|
|
1022
|
+
* the story's own technical note directs by naming `writeConfigField()` as the
|
|
1023
|
+
* helper to reuse.
|
|
1024
|
+
*
|
|
1025
|
+
* AC5. `_bmad/config.user.toml` and `_bmad/custom/config.user.toml` are never
|
|
1026
|
+
* touched: this function opens exactly one path. (Module configs here are YAML,
|
|
1027
|
+
* root and user configs are TOML — `config.user.yaml` does not exist.)
|
|
1028
|
+
*
|
|
1029
|
+
* AC7. Writes the generation stamp documented above.
|
|
1030
|
+
*
|
|
1031
|
+
* 2B NIT 4 — A DERIVED FILE IS NEVER BUILT FROM A BINDING THE SYSTEM REFUSED TO
|
|
1032
|
+
* TRUST. `writeProjectLayoutYaml` already refuses to overwrite an `invalid` /
|
|
1033
|
+
* `unreadable` / `too-new` binding, but the derived `config.yaml` used to be
|
|
1034
|
+
* written anyway, from the bare default, and the warning never mentioned
|
|
1035
|
+
* `config.yaml` at all. A generated file derived from a binding we would not read
|
|
1036
|
+
* is exactly the permissive degradation P5-9.1 exists to prevent, so this
|
|
1037
|
+
* function refuses too, names `config.yaml` in the message, and leaves the
|
|
1038
|
+
* existing file untouched.
|
|
1039
|
+
*
|
|
1040
|
+
* @param {object} opts
|
|
1041
|
+
* @param {string} opts.projectRoot
|
|
1042
|
+
* @param {object=} opts.fallbackLayout Legacy `{knowledgebase, sprintManagement}`
|
|
1043
|
+
* used ONLY when the project has no committed binding at all (`unbound`).
|
|
1044
|
+
* Never used to override a binding that exists — that would make the generator
|
|
1045
|
+
* two-sourced and reintroduce the disagreement this story removes.
|
|
1046
|
+
* @returns {boolean} whether config.yaml was written.
|
|
1047
|
+
*/
|
|
1048
|
+
function generateBmmConfig(opts) {
|
|
1049
|
+
const options = opts || {};
|
|
1050
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
1051
|
+
const configPath = path.join(projectRoot, '_bmad', 'bmm', 'config.yaml');
|
|
1052
|
+
const fallbackLayout = options.fallbackLayout || null;
|
|
1053
|
+
|
|
1054
|
+
// ── SOURCE SELECTION. The committed binding is authoritative; the caller's
|
|
1055
|
+
// layout is a fallback for an unbound project only.
|
|
1056
|
+
const binding = resolveProjectTopology(projectRoot);
|
|
1057
|
+
|
|
1058
|
+
if (!binding.ok && binding.reason !== BINDING_UNBOUND) {
|
|
1059
|
+
// 2B nit 4. Fail loud and name BOTH files — the derived one especially,
|
|
1060
|
+
// because that is the file the user's skills will actually read.
|
|
1061
|
+
console.log(chalk.red(
|
|
1062
|
+
` Refusing to regenerate _bmad/bmm/config.yaml — the committed binding in\n` +
|
|
1063
|
+
` _bmad-output/project-layout.yaml is ${binding.reason}:\n` +
|
|
1064
|
+
` ${binding.message}\n` +
|
|
1065
|
+
` _bmad/bmm/config.yaml has been left UNCHANGED rather than generated from a default,\n` +
|
|
1066
|
+
` because a derived config built from a binding this build refuses to read would\n` +
|
|
1067
|
+
` silently point your skills at the wrong place. Fix the binding (or delete it and\n` +
|
|
1068
|
+
` re-run the installer), then re-run to regenerate config.yaml.`
|
|
1069
|
+
));
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
let topology;
|
|
1074
|
+
// Whether this generation has a committed binding state to stamp. The stamp
|
|
1075
|
+
// itself is COMPUTED INSIDE the try below (R-4), not here — see the note there.
|
|
1076
|
+
let stampable;
|
|
1077
|
+
if (binding.ok) {
|
|
1078
|
+
topology = binding.topology;
|
|
1079
|
+
stampable = true;
|
|
1080
|
+
} else if (fallbackLayout) {
|
|
1081
|
+
// Unbound: no committed binding to generate from. This is reachable when
|
|
1082
|
+
// `writeProjectLayoutYaml` could not write (unwritable `_bmad-output/`), and
|
|
1083
|
+
// in the direct-call path the existing suites exercise. Generate today's
|
|
1084
|
+
// fields from the answered layout so the install still produces a usable
|
|
1085
|
+
// config — but write NO stamp, because there is no binding state to stamp,
|
|
1086
|
+
// and a stamp invented here would be a claim about a file that does not
|
|
1087
|
+
// exist — one that would collide with the real binding the next install
|
|
1088
|
+
// writes.
|
|
1089
|
+
//
|
|
1090
|
+
// This is NOT the silent-permissive shape it can look like. Per the contract
|
|
1091
|
+
// above (step 5), Story 31.4 keys on `resolveProjectTopology` FIRST and the
|
|
1092
|
+
// stamp SECOND: it learns `unbound` from the absent `project-layout.yaml`
|
|
1093
|
+
// before it ever reads the stamp, so the resulting `absent` stamp only
|
|
1094
|
+
// corroborates a fact already established — it is never the fact 31.4
|
|
1095
|
+
// reasons from. Writing a stamp here would be the permissive move, not the
|
|
1096
|
+
// strict one.
|
|
1097
|
+
topology = topologyFromLegacyLayout(fallbackLayout);
|
|
1098
|
+
stampable = false;
|
|
1099
|
+
} else {
|
|
1100
|
+
console.log(chalk.yellow(
|
|
1101
|
+
` Skipping _bmad/bmm/config.yaml generation: this project has no committed binding in\n` +
|
|
1102
|
+
` _bmad-output/project-layout.yaml and no layout was supplied to generate from.\n` +
|
|
1103
|
+
` Run \`${NAME} bind\` (or the installer) to write the binding first.`
|
|
1104
|
+
));
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
555
1108
|
try {
|
|
1109
|
+
// R-4 — INSIDE the try on purpose. `computeProjectLayoutStamp` re-runs the
|
|
1110
|
+
// `project-layout.yaml` serializers, and those THROW rather than return on a
|
|
1111
|
+
// value they refuse to write (`serializeStore`'s backend-enum check,
|
|
1112
|
+
// `assertWritableMode`, `assertSingleLineIdentity`). Install Step 3.6 already
|
|
1113
|
+
// wraps `writeProjectLayoutYaml` in a try/catch for exactly this hazard but
|
|
1114
|
+
// does NOT wrap `generateBmmConfig`, so a throw escaping from here would reach
|
|
1115
|
+
// `main().catch` and print a stack trace over a substantially-finished
|
|
1116
|
+
// install. Every malformed identity found so far is normalised by
|
|
1117
|
+
// `parseLayoutYaml`/`storeFromRaw` before a serializer sees it, so no
|
|
1118
|
+
// reachable input is claimed — this closes an asymmetry rather than a proven
|
|
1119
|
+
// defect, and costs one line to close.
|
|
1120
|
+
const stamp = stampable ? computeProjectLayoutStamp(topology, projectRoot) : null;
|
|
1121
|
+
|
|
556
1122
|
if (!fs.existsSync(configPath)) {
|
|
557
1123
|
// Create an empty file so writeConfigField can append to it
|
|
558
|
-
fs.mkdirSync(path.join(
|
|
1124
|
+
fs.mkdirSync(path.join(projectRoot, '_bmad', 'bmm'), { recursive: true });
|
|
559
1125
|
fs.writeFileSync(configPath, '', 'utf-8');
|
|
560
1126
|
}
|
|
561
1127
|
let content = fs.readFileSync(configPath, 'utf-8');
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
const
|
|
1128
|
+
|
|
1129
|
+
const systemStore = topology.systemRequirementsStore;
|
|
1130
|
+
const softwareStore = topology.softwareRequirementsStore;
|
|
1131
|
+
const sprint = topology.sprintManagement || { mode: 'same', path: '.' };
|
|
1132
|
+
|
|
1133
|
+
// ── The non-file-system note (B-1 consequence 3), now per store.
|
|
1134
|
+
//
|
|
1135
|
+
// `toPortablePath(undefined)` already returns `'.'`, so nothing crashes and
|
|
1136
|
+
// the literal `undefined` is never written — but writing `.` for a Confluence
|
|
1137
|
+
// store SILENTLY says "the knowledgebase is this repo", which is exactly the
|
|
1138
|
+
// permissive-default shape B-1 itself was. So it is said out loud rather than
|
|
1139
|
+
// left to fall out of a falsy check.
|
|
1140
|
+
//
|
|
1141
|
+
// The VALUE is deliberately unchanged (`.`): `knowledgebase_path` is what
|
|
1142
|
+
// `planning_artifacts` is derived from below and what the Knowledge Atlas
|
|
1143
|
+
// config scan reads (lib/knowledge-atlas/src/scan/config.ts), so changing it
|
|
1144
|
+
// would move resolved artifact paths and violate NFR82.
|
|
1145
|
+
//
|
|
1146
|
+
// What `knowledgebase_path` SHOULD mean for a Confluence-backed store remains
|
|
1147
|
+
// a genuine open question and is NOT decided here — see
|
|
1148
|
+
// `_bmad-output/implementation-artifacts/
|
|
1149
|
+
// bug-knowledgebase-path-has-no-defined-meaning-for-a-confluence-store.md`.
|
|
1150
|
+
// This story adds the per-store fields the question's own analysis says are
|
|
1151
|
+
// the precondition for answering it (one scalar cannot describe two stores),
|
|
1152
|
+
// but choosing what a Confluence store's path MEANS belongs to Story 31.6,
|
|
1153
|
+
// which owns the Confluence read path. Pinned by
|
|
1154
|
+
// `test/project-layout-two-store-round-trip.test.js` and by this story's
|
|
1155
|
+
// suite so it cannot drift silently.
|
|
1156
|
+
const softwareBackend = backendOfStore(softwareStore);
|
|
1157
|
+
const systemBackend = backendOfStore(systemStore);
|
|
1158
|
+
let notes = [];
|
|
1159
|
+
if (softwareBackend !== DEFAULT_STORE_BACKEND) {
|
|
1160
|
+
notes.push({ backend: softwareBackend, fields: ['knowledgebase_path', 'software_requirements_path'] });
|
|
1161
|
+
}
|
|
1162
|
+
if (systemBackend !== DEFAULT_STORE_BACKEND) {
|
|
1163
|
+
notes.push({ backend: systemBackend, fields: ['system_requirements_path'] });
|
|
1164
|
+
}
|
|
1165
|
+
// While the two stores are the same store (every project that can exist
|
|
1166
|
+
// before Story 31.5), one note covering all three fields — not the same
|
|
1167
|
+
// sentence twice.
|
|
1168
|
+
if (notes.length === 2 && !storesDiverge(systemStore, softwareStore)) {
|
|
1169
|
+
notes = [{
|
|
1170
|
+
backend: softwareBackend,
|
|
1171
|
+
fields: ['knowledgebase_path', 'software_requirements_path', 'system_requirements_path'],
|
|
1172
|
+
}];
|
|
1173
|
+
}
|
|
1174
|
+
for (const note of notes) {
|
|
1175
|
+
console.log(chalk.yellow(
|
|
1176
|
+
` Note: the knowledge store is ${note.backend}-backed, which has no filesystem path.\n` +
|
|
1177
|
+
` config.yaml's ${note.fields.join(', ')} ${note.fields.length > 1 ? 'are' : 'is'} left at "." ` +
|
|
1178
|
+
`(the local artifacts default)\n` +
|
|
1179
|
+
` so planning artifacts keep resolving where they do today. The authoritative\n` +
|
|
1180
|
+
` ${note.backend} identity lives in _bmad-output/project-layout.yaml and is unchanged.`
|
|
1181
|
+
));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ── The legacy single-valued field. FR264 as amended 2026-08-02: it resolves
|
|
1185
|
+
// to the SOFTWARE requirements store. Unconditionally, divergent or not.
|
|
1186
|
+
const kbPath = storeConfigPath(softwareStore, projectRoot);
|
|
565
1187
|
content = writeConfigField(content, 'knowledgebase_path', kbPath);
|
|
566
1188
|
|
|
567
|
-
|
|
1189
|
+
// ── The two NEW per-store fields (AC1). Written next to the legacy field
|
|
1190
|
+
// they generalise, so a human reading config.yaml sees the relationship.
|
|
1191
|
+
// `software_requirements_path` is by construction the same value as
|
|
1192
|
+
// `knowledgebase_path`; it is written anyway rather than aliased, because a
|
|
1193
|
+
// reader of the new field must not have to know the legacy one exists.
|
|
1194
|
+
content = writeConfigField(content, 'system_requirements_path', storeConfigPath(systemStore, projectRoot));
|
|
1195
|
+
content = writeConfigField(content, 'software_requirements_path', kbPath);
|
|
1196
|
+
|
|
1197
|
+
// ── STORY 31.9 — THE BACKEND, IN THE RESOLVED CONFIGURATION.
|
|
1198
|
+
//
|
|
1199
|
+
// FR261/AC2 requires each skill to read its store's backend "from the
|
|
1200
|
+
// resolved configuration as a gateway check at the start", following
|
|
1201
|
+
// `mcp-detection-pattern-spec.md` §2.6. The resolved configuration is THIS
|
|
1202
|
+
// file: Story 31.6's AC5 rule 1 makes `_bmad-output/project-layout.yaml`
|
|
1203
|
+
// one-way (workflows resolve from the generated config, never from the
|
|
1204
|
+
// binding), and it is enforced by a test that scans every skill entry
|
|
1205
|
+
// document with Story 31.4's preflight removed. So the backend has to be
|
|
1206
|
+
// HERE, or the gateway check cannot exist without opening a second source.
|
|
1207
|
+
//
|
|
1208
|
+
// Four fields, not two, and written UNCONDITIONALLY:
|
|
1209
|
+
//
|
|
1210
|
+
// - `*_backend` — the RESOLVED backend (`backendOfStore` already collapses
|
|
1211
|
+
// absent/empty to `file-system`, R-5), so the value is always a member of
|
|
1212
|
+
// the closed enum. Written for a file-system store too: a field present
|
|
1213
|
+
// only when it is `confluence` would make "absent" mean two different
|
|
1214
|
+
// things (a pre-F26 config vs. a file-system store) and §2.7's default
|
|
1215
|
+
// would have to swallow both.
|
|
1216
|
+
//
|
|
1217
|
+
// - `*_space_key` — the Confluence space, or `""` for a file-system store.
|
|
1218
|
+
// Written even when empty because `writeConfigField` updates-or-appends
|
|
1219
|
+
// and NEVER deletes (see the F1 note on `sprint_management_path`): a
|
|
1220
|
+
// project re-bound from `confluence` back to `file-system` would
|
|
1221
|
+
// otherwise keep its old space key in a freshly generated config, and the
|
|
1222
|
+
// halt message would name a space that is no longer configured.
|
|
1223
|
+
//
|
|
1224
|
+
// NFR79 SCOPE. The space key is the ONLY Confluence value that crosses into
|
|
1225
|
+
// config.yaml. No token, no username, no URL — Story 31.8 does not record
|
|
1226
|
+
// them, so nothing here can leak them. `root_page` stays in the binding: the
|
|
1227
|
+
// gate does not need it, and 31.10 owns it.
|
|
1228
|
+
//
|
|
1229
|
+
// NFR82. All four are ADDITIVE. No existing field's value changes, so no
|
|
1230
|
+
// artifact moves for any project that exists today.
|
|
1231
|
+
content = writeConfigField(content, 'system_requirements_backend', systemBackend);
|
|
1232
|
+
content = writeConfigField(content, 'software_requirements_backend', softwareBackend);
|
|
1233
|
+
content = writeConfigField(content, 'system_requirements_space_key', confluenceSpaceKeyOf(systemStore));
|
|
1234
|
+
content = writeConfigField(content, 'software_requirements_space_key', confluenceSpaceKeyOf(softwareStore));
|
|
1235
|
+
|
|
568
1236
|
// 3.12.3: when the user provides a non-`.` path for knowledgebase /
|
|
569
1237
|
// sprint management, treat that path as the literal artifacts directory
|
|
570
1238
|
// — no nested `_bmad-output/<…>-artifacts/` subtree. The wizard prompt
|
|
@@ -572,90 +1240,875 @@ function writeRepoLayoutConfig(layout) {
|
|
|
572
1240
|
// artifacts folder, not a parent. Only `.` (current repo) gets the
|
|
573
1241
|
// canonical `_bmad-output/...` layout because in single-repo mode
|
|
574
1242
|
// there's no other folder semantics to honor.
|
|
575
|
-
|
|
1243
|
+
const planningArtifacts = kbPath === '.' ? canonicalArtifactPath('planning-artifacts') : kbPath;
|
|
1244
|
+
let summary;
|
|
1245
|
+
if (sprint.mode === 'jira') {
|
|
576
1246
|
// Jira backend: write sprint_backend + Jira config; no sprint_management_path
|
|
577
1247
|
content = writeConfigField(content, 'sprint_backend', 'jira');
|
|
578
|
-
content = writeConfigField(content, 'jira_url',
|
|
579
|
-
content = writeConfigField(content, 'jira_project_key',
|
|
580
|
-
const planningArtifacts = kbPath === '.' ? canonicalArtifactPath('planning-artifacts') : kbPath;
|
|
581
|
-
const implArtifacts = canonicalArtifactPath('implementation-artifacts');
|
|
1248
|
+
content = writeConfigField(content, 'jira_url', sprint.jiraUrl);
|
|
1249
|
+
content = writeConfigField(content, 'jira_project_key', sprint.jiraProjectKey);
|
|
582
1250
|
content = writeConfigField(content, 'planning_artifacts', planningArtifacts);
|
|
583
|
-
content = writeConfigField(content, 'implementation_artifacts',
|
|
584
|
-
|
|
585
|
-
console.log(chalk.gray(` Config: knowledgebase_path="${kbPath}", sprint_backend=jira, jira_url="${layout.sprintManagement.jiraUrl}", jira_project_key="${layout.sprintManagement.jiraProjectKey}"`));
|
|
1251
|
+
content = writeConfigField(content, 'implementation_artifacts', canonicalArtifactPath('implementation-artifacts'));
|
|
1252
|
+
summary = ` Config: knowledgebase_path="${kbPath}", sprint_backend=jira, jira_url="${sprint.jiraUrl}", jira_project_key="${sprint.jiraProjectKey}"`;
|
|
586
1253
|
} else {
|
|
587
1254
|
// File-system backend
|
|
588
|
-
const
|
|
589
|
-
const spPath = spPortable.portable;
|
|
1255
|
+
const spPath = storeConfigPath(sprint, projectRoot);
|
|
590
1256
|
content = writeConfigField(content, 'sprint_backend', 'file-system');
|
|
591
1257
|
content = writeConfigField(content, 'sprint_management_path', spPath);
|
|
592
|
-
const planningArtifacts = kbPath === '.' ? canonicalArtifactPath('planning-artifacts') : kbPath;
|
|
593
|
-
const implArtifacts = spPath === '.' ? canonicalArtifactPath('implementation-artifacts') : spPath;
|
|
594
1258
|
content = writeConfigField(content, 'planning_artifacts', planningArtifacts);
|
|
595
|
-
content = writeConfigField(content, 'implementation_artifacts',
|
|
596
|
-
|
|
597
|
-
|
|
1259
|
+
content = writeConfigField(content, 'implementation_artifacts',
|
|
1260
|
+
spPath === '.' ? canonicalArtifactPath('implementation-artifacts') : spPath);
|
|
1261
|
+
summary = ` Config: knowledgebase_path="${kbPath}", sprint_backend=file-system, sprint_management_path="${spPath}"`;
|
|
598
1262
|
}
|
|
1263
|
+
|
|
1264
|
+
// AC7 — LAST, so it sits at the end of the generated block and reads as what
|
|
1265
|
+
// it is: a statement about everything above it.
|
|
1266
|
+
if (stamp) content = writeConfigField(content, PROJECT_LAYOUT_STAMP_FIELD, stamp);
|
|
1267
|
+
|
|
1268
|
+
fs.writeFileSync(configPath, content, 'utf-8');
|
|
1269
|
+
|
|
1270
|
+
// AC4 — the overwrite notice. Names the file it overwrote, every time.
|
|
1271
|
+
//
|
|
1272
|
+
// The unbound branch deliberately does NOT name `project-layout.yaml`: there
|
|
1273
|
+
// is no such file in that case, so naming it would be a false statement about
|
|
1274
|
+
// where these values came from. (It also keeps
|
|
1275
|
+
// `project-layout-binding-spine.test.js`'s "a config.yaml write must never
|
|
1276
|
+
// name project-layout.yaml" scenario honest.)
|
|
1277
|
+
console.log(chalk.gray(stamp
|
|
1278
|
+
? ` Regenerated _bmad/bmm/config.yaml from _bmad-output/project-layout.yaml — its generated fields were OVERWRITTEN.`
|
|
1279
|
+
: ` Wrote _bmad/bmm/config.yaml — its generated fields were OVERWRITTEN.`));
|
|
1280
|
+
console.log(chalk.gray(summary));
|
|
1281
|
+
return true;
|
|
599
1282
|
} catch (e) {
|
|
600
1283
|
console.log(chalk.red(` Cannot write config.yaml: ${e.message}`));
|
|
1284
|
+
return false;
|
|
601
1285
|
}
|
|
602
1286
|
}
|
|
603
1287
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
1288
|
+
/**
|
|
1289
|
+
* The legacy-shape entry point, retained deliberately.
|
|
1290
|
+
*
|
|
1291
|
+
* Every caller that has a `{knowledgebase, sprintManagement}` layout in hand —
|
|
1292
|
+
* `handleConfigLayout`, Step 3.6 of the installer, and the existing suites — goes
|
|
1293
|
+
* through here. It is a THIN adapter, not a second writer: there is exactly one
|
|
1294
|
+
* function that writes config.yaml, and the committed binding still wins whenever
|
|
1295
|
+
* one exists, so calling this on a bound project generates from the binding and
|
|
1296
|
+
* ignores the argument. That precedence is the whole point of AC1 — a caller
|
|
1297
|
+
* cannot route around the authoritative source by passing a layout.
|
|
1298
|
+
*/
|
|
1299
|
+
function writeRepoLayoutConfig(layout) {
|
|
1300
|
+
return generateBmmConfig({ projectRoot: process.cwd(), fallbackLayout: layout });
|
|
1301
|
+
}
|
|
608
1302
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
1303
|
+
/**
|
|
1304
|
+
* ─── STORY 31.15 — THE PUBLISH TRIGGER, AS A SECOND OUTPUT OF THE SAME PASS ──
|
|
1305
|
+
*
|
|
1306
|
+
* Epic 31 built the entire Confluence publish protocol and nothing fired it for
|
|
1307
|
+
* the five BMAD authoring skills. They are upstream `bmad-method` documents:
|
|
1308
|
+
* ma-agents does not own them and cannot edit them. What it does own is the
|
|
1309
|
+
* project they are installed into, and upstream sanctions exactly one channel
|
|
1310
|
+
* into that — `_bmad/custom/<skill>.toml`, the TEAM layer of the three-layer
|
|
1311
|
+
* customization merge every one of the five resolves at activation and refuses
|
|
1312
|
+
* to begin without.
|
|
1313
|
+
*
|
|
1314
|
+
* WHY IT LIVES BESIDE `generateBmmConfig` AND NOT IN A PIPELINE OF ITS OWN.
|
|
1315
|
+
* `config.yaml` is derived from the committed binding at exactly three sites —
|
|
1316
|
+
* install (Step 3.6), `reconfigure-layout` and `bind`. A sibling called from
|
|
1317
|
+
* those same three sites cannot go stale relative to `config.yaml`, which
|
|
1318
|
+
* removes a whole drift class without widening Story 31.4's guard scope. A
|
|
1319
|
+
* FOURTH call site would reintroduce it; do not add one.
|
|
1320
|
+
*
|
|
1321
|
+
* ORDERING IS LOAD-BEARING (§4.6 R3). On an UPDATE, bmad-method's installer
|
|
1322
|
+
* classifies any file absent from its `files-manifest.csv` as a `customFile`,
|
|
1323
|
+
* backs it up, and restores it VERBATIM after the install
|
|
1324
|
+
* (`node_modules/bmad-method/tools/installer/core/installer.js:588`, `:624-636`,
|
|
1325
|
+
* `:509-546`, `:918-923`). `config.yaml` is explicitly EXEMPTED from that
|
|
1326
|
+
* restore (`installer.js:915-917`); `_bmad/custom/<skill>.toml` is not. So a
|
|
1327
|
+
* STALE hook is restored rather than regenerated. Inside `ma-agents install`
|
|
1328
|
+
* that is harmless only because this generation runs AFTER `installBmad()` and
|
|
1329
|
+
* overwrites the restored block. Moving this call ahead of `installBmad()` is a
|
|
1330
|
+
* defect, not a refactor. Pinned by
|
|
1331
|
+
* `test/project-layout-confluence-publish-hook.test.js`.
|
|
1332
|
+
*
|
|
1333
|
+
* INSTALL-TIME GATING, NOT RUNTIME GATING (§4.3). A `file-system` store gets no
|
|
1334
|
+
* hook and emits nothing at runtime, because Step B1 of the shipped gate is
|
|
1335
|
+
* explicit that such a project gets "no message from this section, no prompt,
|
|
1336
|
+
* and no offer" (`skills/generate-backlog/SKILL.md:139`). The named reason FR262
|
|
1337
|
+
* requires for a no-publish path is therefore reported HERE, to the operator
|
|
1338
|
+
* running the install, and not there, to the user running the workflow.
|
|
1339
|
+
*
|
|
1340
|
+
* AC5(a) — no silent skip. Every outcome is named out loud. The refusal path
|
|
1341
|
+
* additionally sets a non-zero exit code, so a collision surfaces at install or
|
|
1342
|
+
* `bind` time rather than as a publish that quietly stopped happening.
|
|
1343
|
+
*
|
|
1344
|
+
* @param {object} opts
|
|
1345
|
+
* @param {string} opts.projectRoot
|
|
1346
|
+
* @param {object=} opts.fallbackLayout same meaning as in `generateBmmConfig`
|
|
1347
|
+
* @param {boolean=} opts.quiet suppress the informational lines (tests)
|
|
1348
|
+
* @returns {{results: Array, refused: boolean, reason: string|null}}
|
|
1349
|
+
*/
|
|
1350
|
+
function generateConfluencePublishHooks(opts) {
|
|
1351
|
+
const options = opts || {};
|
|
1352
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
1353
|
+
|
|
1354
|
+
// SOURCE SELECTION, identical to `generateBmmConfig`'s and for the identical
|
|
1355
|
+
// reason (2B nit 4): a derived artifact is never built from a binding the
|
|
1356
|
+
// system refused to trust. A hook generated from a bare default would point a
|
|
1357
|
+
// publish at a store nobody bound.
|
|
1358
|
+
const binding = resolveProjectTopology(projectRoot);
|
|
1359
|
+
let topology;
|
|
1360
|
+
if (binding.ok) {
|
|
1361
|
+
topology = binding.topology;
|
|
1362
|
+
} else if (binding.reason !== BINDING_UNBOUND) {
|
|
1363
|
+
if (!options.quiet) {
|
|
1364
|
+
console.log(chalk.red(
|
|
1365
|
+
` Refusing to regenerate the Confluence publish overrides in _bmad/custom/ — the\n` +
|
|
1366
|
+
` committed binding in _bmad-output/project-layout.yaml is ${binding.reason}:\n` +
|
|
1367
|
+
` ${binding.message}\n` +
|
|
1368
|
+
` The existing override files have been left UNCHANGED. Fix the binding (or delete it\n` +
|
|
1369
|
+
` and re-run the installer), then re-run to regenerate them.`
|
|
1370
|
+
));
|
|
614
1371
|
}
|
|
615
|
-
return;
|
|
1372
|
+
return { results: [], refused: false, reason: binding.reason };
|
|
1373
|
+
} else if (options.fallbackLayout) {
|
|
1374
|
+
topology = topologyFromLegacyLayout(options.fallbackLayout);
|
|
1375
|
+
} else {
|
|
1376
|
+
// Named, not silent. Same disposition `generateBmmConfig` takes.
|
|
1377
|
+
if (!options.quiet) {
|
|
1378
|
+
console.log(chalk.gray(
|
|
1379
|
+
` No Confluence publish overrides generated: this project has no committed binding in\n` +
|
|
1380
|
+
` _bmad-output/project-layout.yaml and no layout was supplied to generate from.`
|
|
1381
|
+
));
|
|
1382
|
+
}
|
|
1383
|
+
return { results: [], refused: false, reason: BINDING_UNBOUND };
|
|
616
1384
|
}
|
|
617
1385
|
|
|
618
|
-
//
|
|
619
|
-
|
|
1386
|
+
// The per-store view the generator needs, assembled from the SHIPPED tables so
|
|
1387
|
+
// a store added to `CONFLUENCE_STORE_RECORDS` is carried here automatically
|
|
1388
|
+
// rather than needing a second list kept in step by hand.
|
|
1389
|
+
const stores = {};
|
|
1390
|
+
for (const storeId of confluencePageTree.PUBLISHING_STORES) {
|
|
1391
|
+
const record = CONFLUENCE_STORE_RECORDS[storeId];
|
|
1392
|
+
const store = topology[record.topologyKey];
|
|
1393
|
+
stores[storeId] = {
|
|
1394
|
+
backend: backendOfStore(store),
|
|
1395
|
+
cliName: record.cliName,
|
|
1396
|
+
label: confluencePageTree.labelForStore(storeId),
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
620
1399
|
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
content += `# Tells agents where to find planning and sprint data\n`;
|
|
624
|
-
content += `generated: "${date}"\n`;
|
|
1400
|
+
const outcome = confluencePublishHook.generatePublishHooks({ projectRoot, stores });
|
|
1401
|
+
if (!options.quiet) reportPublishHookOutcome(outcome, stores);
|
|
625
1402
|
|
|
626
|
-
|
|
1403
|
+
// AC4(b) / AC12(b) — "exits non-zero". Set rather than thrown: the install has
|
|
1404
|
+
// real work after this point (project-context.md, the legacy-store notice, the
|
|
1405
|
+
// summary), and aborting mid-run would leave a half-configured project to
|
|
1406
|
+
// report a configuration problem. The process still exits non-zero.
|
|
1407
|
+
if (outcome.refused) process.exitCode = 1;
|
|
1408
|
+
return { results: outcome.results, refused: outcome.refused, reason: null };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Say what happened, by name, for every skill. FR262 / AC5(a).
|
|
1413
|
+
*
|
|
1414
|
+
* Aggregated by outcome rather than printed per skill: five identical lines on
|
|
1415
|
+
* every install is noise, and noise is how a real refusal gets scrolled past.
|
|
1416
|
+
*/
|
|
1417
|
+
function reportPublishHookOutcome(outcome, stores) {
|
|
1418
|
+
const H = confluencePublishHook;
|
|
1419
|
+
const by = (kind) => outcome.results.filter((r) => r.outcome === kind);
|
|
1420
|
+
|
|
1421
|
+
const wrote = by(H.HOOK_WROTE);
|
|
1422
|
+
const unchanged = by(H.HOOK_UNCHANGED);
|
|
1423
|
+
const removed = by(H.HOOK_REMOVED);
|
|
1424
|
+
const inapplicable = by(H.HOOK_NOT_APPLICABLE);
|
|
1425
|
+
|
|
1426
|
+
if (wrote.length || unchanged.length) {
|
|
1427
|
+
const skills = [...wrote, ...unchanged].map((r) => r.skill).sort();
|
|
1428
|
+
console.log(chalk.gray(
|
|
1429
|
+
` Confluence publish trigger ${wrote.length ? 'written to' : 'already current in'} ` +
|
|
1430
|
+
`_bmad/custom/ for ${skills.length} authoring skill${skills.length === 1 ? '' : 's'}: ` +
|
|
1431
|
+
`${skills.join(', ')}.`
|
|
1432
|
+
));
|
|
1433
|
+
}
|
|
1434
|
+
if (removed.length) {
|
|
1435
|
+
console.log(chalk.gray(
|
|
1436
|
+
` Confluence publish trigger removed from _bmad/custom/ for ` +
|
|
1437
|
+
`${removed.map((r) => r.skill).sort().join(', ')} — that store is no longer confluence-backed. ` +
|
|
1438
|
+
`Everything else in those files was left as it was.`
|
|
1439
|
+
));
|
|
1440
|
+
}
|
|
1441
|
+
if (inapplicable.length && !wrote.length && !unchanged.length) {
|
|
1442
|
+
// The NAMED reason for the commonest no-publish path. Stated to the operator
|
|
1443
|
+
// at install time, never to the user at workflow time (§4.3 / Step B1).
|
|
1444
|
+
const backends = [...new Set(Object.values(stores).map((s) => s.backend))].sort();
|
|
1445
|
+
console.log(chalk.gray(
|
|
1446
|
+
` No Confluence publish trigger generated: ${backends.length === 1
|
|
1447
|
+
? `both knowledge stores are ${backends[0]}-backed`
|
|
1448
|
+
: `neither knowledge store is confluence-backed (${backends.join(', ')})`}, ` +
|
|
1449
|
+
`so there is nothing for the authoring workflows to publish to.`
|
|
1450
|
+
));
|
|
1451
|
+
}
|
|
627
1452
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1453
|
+
for (const r of outcome.results) {
|
|
1454
|
+
if (r.outcome === H.HOOK_REFUSED_COLLISION || r.outcome === H.HOOK_REFUSED_INVALID_TOML) {
|
|
1455
|
+
console.log(chalk.red(
|
|
1456
|
+
` ma-agents — refusing to generate the Confluence publish trigger for ${r.skill}.\n` +
|
|
1457
|
+
` File: ${r.file}\n` +
|
|
1458
|
+
r.collisions.map((c) => ` Line ${c.line === null ? '?' : c.line}: ${c.detail}\n`).join('') +
|
|
1459
|
+
` NOTHING was written to that file. Writing beside what is already there would make it\n` +
|
|
1460
|
+
` invalid TOML, and _bmad/scripts/resolve_customization.py answers a parse failure in\n` +
|
|
1461
|
+
` this layer with a warning, an empty table and exit 0 — discarding your customization\n` +
|
|
1462
|
+
` AND the publish together, silently.\n` +
|
|
1463
|
+
remediesFor(r.collisions, r.skill).map((line) => ` ${line}\n`).join('') +
|
|
1464
|
+
` ${r.skill} will NOT publish to Confluence until this is resolved.`
|
|
1465
|
+
));
|
|
1466
|
+
} else if (r.outcome === H.HOOK_WRITE_FAILED) {
|
|
1467
|
+
console.log(chalk.red(
|
|
1468
|
+
` ma-agents — could not write the Confluence publish trigger for ${r.skill}:\n` +
|
|
1469
|
+
` ${r.file}\n ${r.error}\n` +
|
|
1470
|
+
` ${r.skill} will NOT publish to Confluence until this is resolved.`
|
|
1471
|
+
));
|
|
1472
|
+
}
|
|
635
1473
|
}
|
|
636
|
-
|
|
637
|
-
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* THE REMEDY, PER COLLISION KIND.
|
|
1478
|
+
*
|
|
1479
|
+
* FIX ROUND 2 (N2). Both surfaces used to print ONE fixed sentence — "Move your
|
|
1480
|
+
* override into `<skill>.user.toml` as a TOML array (`workflow.on_complete =
|
|
1481
|
+
* [ "..." ]`)" — for every collision kind. For a `[workflow]` table holding
|
|
1482
|
+
* `prd_template`, or for a file that simply does not parse, that instruction is
|
|
1483
|
+
* not merely unhelpful: it does not apply, and following it leaves the user in
|
|
1484
|
+
* exactly the state they started in. This is the same shape as the false-refusal
|
|
1485
|
+
* remedy fix round 1 closed once already, and it is closed here as a MAP FROM
|
|
1486
|
+
* KIND rather than as a longer paragraph, so a kind added to the module without
|
|
1487
|
+
* a remedy is a visible hole rather than a wrong sentence.
|
|
1488
|
+
*
|
|
1489
|
+
* @param {Array<{kind: string}>} collisions
|
|
1490
|
+
* @param {string} skill
|
|
1491
|
+
* @returns {string[]} one remedy per DISTINCT kind present, in a stable order
|
|
1492
|
+
*/
|
|
1493
|
+
function remediesFor(collisions, skill) {
|
|
1494
|
+
const H = confluencePublishHook;
|
|
1495
|
+
const userFile = `_bmad/custom/${skill}.user.toml`;
|
|
1496
|
+
const arrayForm = `\`${H.PUBLISH_HOOK_KEY} = [ "..." ]\``;
|
|
1497
|
+
const REMEDIES = new Map([
|
|
1498
|
+
[H.COLLISION_DUPLICATE_KEY,
|
|
1499
|
+
`Move your own \`${H.PUBLISH_HOOK_KEY}\` into ${userFile} as a TOML array\n` +
|
|
1500
|
+
` (${arrayForm}), which APPENDS to the publish instead of replacing it, then re-run.`],
|
|
1501
|
+
[H.COLLISION_DUPLICATE_TABLE,
|
|
1502
|
+
`Delete the \`[${H.PUBLISH_HOOK_TABLE}]\` header and express its fields as DOTTED KEYS at the\n` +
|
|
1503
|
+
` top level instead (\`${H.PUBLISH_HOOK_TABLE}.prd_template = "..."\`) — those sit beside\n` +
|
|
1504
|
+
` ma-agents' key without re-declaring the table. An \`on_complete\` of your own belongs in\n` +
|
|
1505
|
+
` ${userFile} as a TOML array (${arrayForm}).`],
|
|
1506
|
+
[H.COLLISION_TABLE_IS_VALUE,
|
|
1507
|
+
`\`${H.PUBLISH_HOOK_TABLE}\` is assigned a VALUE at the top level, so it cannot also be the\n` +
|
|
1508
|
+
` table ma-agents' key lives in. Express its fields as DOTTED KEYS instead\n` +
|
|
1509
|
+
` (\`${H.PUBLISH_HOOK_TABLE}.prd_template = "..."\`). An \`on_complete\` of your own belongs\n` +
|
|
1510
|
+
` in ${userFile} as a TOML array (${arrayForm}).`],
|
|
1511
|
+
[H.COLLISION_KEY_USED_AS_TABLE,
|
|
1512
|
+
`Nothing may be declared at or under \`${H.PUBLISH_HOOK_KEY}\` — ma-agents defines it as an\n` +
|
|
1513
|
+
` ARRAY of instructions. Move that content to a key of its own, and put an \`on_complete\`\n` +
|
|
1514
|
+
` of your own in ${userFile} as a TOML array (${arrayForm}).`],
|
|
1515
|
+
[H.COLLISION_UNPARSEABLE,
|
|
1516
|
+
`This file does not parse as TOML on its own. Fix the error at the line named above and\n` +
|
|
1517
|
+
` re-run — ma-agents will not add to a file the resolver is already discarding.`],
|
|
1518
|
+
[H.COLLISION_BLOCK_NOT_AT_ROOT,
|
|
1519
|
+
`Move ma-agents' BEGIN/END region back to the TOP of the file, or run \`ma-agents bind\`,\n` +
|
|
1520
|
+
` which re-anchors it there. A dotted key inherits the table above it.`],
|
|
1521
|
+
[H.COLLISION_HALF_BLOCK,
|
|
1522
|
+
`The BEGIN marker has no matching END. Remove the stray marker line — the next install or\n` +
|
|
1523
|
+
` \`ma-agents bind\` then emits a fresh region rather than guessing where yours ended.`],
|
|
1524
|
+
// FIX ROUND 4 (R1). The one kind whose remedy CANNOT be "let ma-agents sort
|
|
1525
|
+
// it out": deciding which of two identical BEGIN markers was ours is exactly
|
|
1526
|
+
// the guess that deletes the lines between them.
|
|
1527
|
+
[H.COLLISION_AMBIGUOUS_MARKERS,
|
|
1528
|
+
`There is more than one \`${H.PUBLISH_HOOK_MARKER_BEGIN}\` line in this file, so ma-agents\n` +
|
|
1529
|
+
` cannot tell which region is its own — and anything between the wrong pair of markers would\n` +
|
|
1530
|
+
` be DELETED. Delete the stray BEGIN line by hand (and its END, if it has one), keeping the\n` +
|
|
1531
|
+
` one region ma-agents generated, then re-run. ma-agents will not guess this for you.`],
|
|
1532
|
+
]);
|
|
1533
|
+
|
|
1534
|
+
const out = [];
|
|
1535
|
+
const seen = new Set();
|
|
1536
|
+
for (const c of collisions) {
|
|
1537
|
+
if (seen.has(c.kind)) continue;
|
|
1538
|
+
seen.add(c.kind);
|
|
1539
|
+
out.push(REMEDIES.has(c.kind)
|
|
1540
|
+
? REMEDIES.get(c.kind)
|
|
1541
|
+
// A kind with no remedy is a defect in this map, said out loud rather than
|
|
1542
|
+
// papered over with an instruction that may not apply.
|
|
1543
|
+
: `ma-agents has no specific remedy recorded for \`${c.kind}\`; please report this. The\n` +
|
|
1544
|
+
` general form is an override in ${userFile} as a TOML array (${arrayForm}).`);
|
|
638
1545
|
}
|
|
1546
|
+
return out;
|
|
1547
|
+
}
|
|
639
1548
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1549
|
+
/**
|
|
1550
|
+
* AC12(c) — THE DETECTION PATH, ON AN ALREADY-SHIPPED SURFACE.
|
|
1551
|
+
*
|
|
1552
|
+
* The generator's refusal is a write-side guard against a read-side failure, and
|
|
1553
|
+
* the third writer — an agent running the upstream `bmad-customize` skill —
|
|
1554
|
+
* reaches the read-side failure without ever calling the generator. Between that
|
|
1555
|
+
* run and the next install the publish is dead and nothing says so. This is the
|
|
1556
|
+
* only guard that fires inside that window.
|
|
1557
|
+
*
|
|
1558
|
+
* It is on `status` because `status` is what a user runs when they ask "is this
|
|
1559
|
+
* project set up correctly", it needs no arguments, and it already reports
|
|
1560
|
+
* binding health for the same reason (Story 31.13 AC7). It reports exactly what
|
|
1561
|
+
* the generator refuses over — same function, not a second opinion.
|
|
1562
|
+
*/
|
|
1563
|
+
function printPublishHookCollisionReport(projectRoot) {
|
|
1564
|
+
const found = confluencePublishHook.detectPublishHookCollisions(projectRoot);
|
|
1565
|
+
printUserLayerScalarOverrideReport(projectRoot);
|
|
1566
|
+
if (found.length === 0) return found;
|
|
1567
|
+
console.log(chalk.red(`\n Confluence publish trigger — ${found.length} override file(s) need attention:\n`));
|
|
1568
|
+
for (const entry of found) {
|
|
1569
|
+
console.log(chalk.red(` ${entry.file}`));
|
|
1570
|
+
for (const c of entry.collisions) {
|
|
1571
|
+
console.log(chalk.red(` line ${c.line === null ? '?' : c.line}: ${c.detail}`));
|
|
651
1572
|
}
|
|
652
|
-
|
|
653
|
-
|
|
1573
|
+
console.log(chalk.gray(
|
|
1574
|
+
` => ${entry.skill} does not publish to Confluence while this stands. ` +
|
|
1575
|
+
`_bmad/scripts/resolve_customization.py\n` +
|
|
1576
|
+
` answers a parse failure in this layer with a warning, an empty table and exit 0,\n` +
|
|
1577
|
+
` so the whole team layer is discarded and nothing else reports it.\n` +
|
|
1578
|
+
remediesFor(entry.collisions, entry.skill)
|
|
1579
|
+
.map((line) => ` ${line.replace(/\n {2}/g, '\n ')}\n`).join('').replace(/\n$/, '')
|
|
1580
|
+
));
|
|
1581
|
+
}
|
|
1582
|
+
console.log('');
|
|
1583
|
+
return found;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* R10 — the PERSONAL layer's scalar `on_complete`, on the same `status` surface.
|
|
1588
|
+
*
|
|
1589
|
+
* FIX ROUND 1 (independent review). The story DISCLOSED this residual and shipped
|
|
1590
|
+
* no detector for it, so the one end state a user can reach entirely on their own
|
|
1591
|
+
* — a scalar `workflow.on_complete` in `_bmad/custom/<skill>.user.toml`, which
|
|
1592
|
+
* `deep_merge` treats as a replacement rather than an append — killed the publish
|
|
1593
|
+
* at exit 0 with nothing anywhere saying so. The merge behaviour is upstream and
|
|
1594
|
+
* irreducible; the SILENCE was not, because `status` already walks
|
|
1595
|
+
* `_bmad/custom/` for the collision report.
|
|
1596
|
+
*
|
|
1597
|
+
* NOT an error and NOT a refusal: replacing the hook in your own layer is a
|
|
1598
|
+
* legitimate thing to do, so this states the consequence and the one-character
|
|
1599
|
+
* remedy and changes nothing. It is printed even on an otherwise-clean project,
|
|
1600
|
+
* because that is precisely the project this fires on.
|
|
1601
|
+
*/
|
|
1602
|
+
function printUserLayerScalarOverrideReport(projectRoot) {
|
|
1603
|
+
const overrides = confluencePublishHook.detectUserLayerScalarOverrides(projectRoot);
|
|
1604
|
+
if (overrides.length === 0) return overrides;
|
|
1605
|
+
console.log(chalk.yellow(
|
|
1606
|
+
`\n Confluence publish trigger — ${overrides.length} personal override(s) REPLACE it:\n`));
|
|
1607
|
+
for (const o of overrides) {
|
|
1608
|
+
console.log(chalk.yellow(` ${o.file}`));
|
|
1609
|
+
console.log(chalk.yellow(` line ${o.line === null ? '?' : o.line}: ${o.detail}`));
|
|
1610
|
+
console.log(chalk.gray(
|
|
1611
|
+
` => ${o.skill} does not publish to Confluence while this stands. ` +
|
|
1612
|
+
`_bmad/scripts/resolve_customization.py\n` +
|
|
1613
|
+
` merges two ARRAYS by appending and falls through to "the override wins" for\n` +
|
|
1614
|
+
` everything else, so a bare string silently takes the publish instruction's place\n` +
|
|
1615
|
+
` and the merge still exits 0. Make it a TOML array in that same .user.toml file\n` +
|
|
1616
|
+
` (\`${confluencePublishHook.PUBLISH_HOOK_KEY} = [ "..." ]\`) and both will run.\n` +
|
|
1617
|
+
` This file is yours: ma-agents has not changed it and will not.`
|
|
1618
|
+
));
|
|
1619
|
+
}
|
|
1620
|
+
console.log('');
|
|
1621
|
+
return overrides;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// ─── project-layout.yaml — schema v2: the two-store binding spine (Story 31.2) ──
|
|
1625
|
+
//
|
|
1626
|
+
// P5-9.1: this file is the SINGLE authoritative record of where a project's
|
|
1627
|
+
// knowledge lives, written unconditionally on every install and every bind.
|
|
1628
|
+
// Exactly three location records (AC1): `system_requirements_store`,
|
|
1629
|
+
// `software_requirements_store`, `sprint_management` — no fourth store, no
|
|
1630
|
+
// `implementation_artifacts` record (story files follow sprint_management,
|
|
1631
|
+
// FR267). Each of the two knowledge stores carries a `backend`
|
|
1632
|
+
// (`file-system` | `confluence`) plus its backend-specific fields. A
|
|
1633
|
+
// `confluence` store records ONLY the space key and a `{title, id}` root-page
|
|
1634
|
+
// identity (NFR79) — never credentials; `serializeConfluenceStore` below
|
|
1635
|
+
// enumerates exactly those fields and reads nothing else off the input
|
|
1636
|
+
// object, so a stray credential-shaped property can never reach the file.
|
|
1637
|
+
//
|
|
1638
|
+
// Story 31.5 has not landed yet: the installer still asks a single
|
|
1639
|
+
// knowledgebase question, so both stores are pointed at that one answer
|
|
1640
|
+
// (mapped in `writeProjectLayoutYaml` below) — this is what keeps the
|
|
1641
|
+
// existing single-knowledgebase wizard working unmodified until 31.5 splits
|
|
1642
|
+
// it into two independent questions. That mapping applies only while the two
|
|
1643
|
+
// committed stores AGREE; once they diverge, the single answer goes to the
|
|
1644
|
+
// software store (FR264) and the system store is preserved — see the
|
|
1645
|
+
// preservation block in `writeProjectLayoutYaml`.
|
|
1646
|
+
const PROJECT_LAYOUT_SCHEMA_VERSION = 2;
|
|
1647
|
+
|
|
1648
|
+
// `backend` is a CLOSED enum in both directions, exactly like `mode`. Defined
|
|
1649
|
+
// ONCE - now in `lib/store-backends.js`, not here - and consumed by both halves
|
|
1650
|
+
// (`storeFromRaw` on read, `serializeStore` on write) because the two spelled it
|
|
1651
|
+
// out as independent inline literals with independently hand-written error
|
|
1652
|
+
// messages, and that is the precise shape of the defect this epic has now found
|
|
1653
|
+
// three times: write side and read side disagreeing about a closed enum. Story
|
|
1654
|
+
// 31.5 asks `backend` per store, doubling the surface, so the set is
|
|
1655
|
+
// single-sourced before that lands rather than after.
|
|
1656
|
+
//
|
|
1657
|
+
// Story 31.3 / 2B nit 1 — MOVED OUT of this file. The definition used to live
|
|
1658
|
+
// here, which left `lib/installer.js` (which must not require bin/cli.js) with a
|
|
1659
|
+
// SECOND, unpinned spelling of the same default. Both now read the leaf module.
|
|
1660
|
+
// The Set is rebuilt from the shared list rather than exported from it because
|
|
1661
|
+
// membership tests are on hot read/write paths and Set lookup is what the rest of
|
|
1662
|
+
// this file is written against.
|
|
1663
|
+
const STORE_BACKENDS = new Set(STORE_BACKEND_LIST);
|
|
1664
|
+
|
|
1665
|
+
// The backend a store has when it never states one. Both the pre-F26 legacy
|
|
1666
|
+
// block (AC5) and the legacy `{mode, path}` wizard shape omit `backend:`
|
|
1667
|
+
// entirely, and "never stated" has always meant file-system on the read half,
|
|
1668
|
+
// the write half, the display half and `generateRepoLayoutSection`'s
|
|
1669
|
+
// project-context half. `DEFAULT_STORE_BACKEND` is imported from
|
|
1670
|
+
// `lib/store-backends.js` at the top of this file — deliberately NOT re-declared
|
|
1671
|
+
// here, so `const DEFAULT_STORE_BACKEND = '...'` appears nowhere in this file and
|
|
1672
|
+
// a fourth definition cannot be reintroduced by a copy/paste.
|
|
1673
|
+
|
|
1674
|
+
// Both backend messages are DERIVED from the set above, never hand-written, so
|
|
1675
|
+
// a member added to the enum cannot be named by one direction and omitted by
|
|
1676
|
+
// the other. Renders `"file-system" or "confluence"`; the quoting matches
|
|
1677
|
+
// requireKnownMode's, so an offending value and the legal values read alike.
|
|
1678
|
+
// The exact rendering is pinned by the suite - "each member appears somewhere
|
|
1679
|
+
// in the message" is too weak to notice the separator changing.
|
|
1680
|
+
function legalBackendsForMessage() {
|
|
1681
|
+
const members = [...STORE_BACKENDS].map((b) => JSON.stringify(b));
|
|
1682
|
+
if (members.length < 2) return members.join('');
|
|
1683
|
+
return `${members.slice(0, -1).join(', ')} or ${members[members.length - 1]}`;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// ─── The per-backend IDENTITY VIEW, keyed by STORE_BACKENDS ─────────────────
|
|
1687
|
+
//
|
|
1688
|
+
// What IDENTIFIES a store is backend-specific. A `file-system` store is
|
|
1689
|
+
// {mode, path, gitUrl?}; a `confluence` store has NEITHER a mode nor a path —
|
|
1690
|
+
// its identity is a space key plus a `{title, id}` root page. Two consumers need
|
|
1691
|
+
// that per-backend field list, and both previously assumed the file-system shape
|
|
1692
|
+
// was total:
|
|
1693
|
+
//
|
|
1694
|
+
// - showCurrentLayout, which printed `Mode: undefined / Path: undefined` for a
|
|
1695
|
+
// completely valid confluence store — the display defect;
|
|
1696
|
+
// - the DIVERGENCE test writeProjectLayoutYaml's preservation needs, to decide
|
|
1697
|
+
// whether the two committed stores already point at different places — the
|
|
1698
|
+
// read/write defect.
|
|
1699
|
+
//
|
|
1700
|
+
// One table, keyed by the enum members, exactly as MODE_PROMPT_TITLES is keyed by
|
|
1701
|
+
// the mode enums: a backend added to STORE_BACKENDS names its fields once here
|
|
1702
|
+
// and both the renderer and the comparator follow. A backend-aware renderer with
|
|
1703
|
+
// its own private list of backends would reinstate precisely the read/write
|
|
1704
|
+
// disagreement the shared-enum work removed.
|
|
1705
|
+
//
|
|
1706
|
+
// `storeIdentityFields` THROWS for a backend with no entry rather than degrading
|
|
1707
|
+
// to the file-system shape. A permissive fallback is exactly how a confluence
|
|
1708
|
+
// store came to render as `undefined`, and the suite pins that these keys are
|
|
1709
|
+
// the whole of STORE_BACKENDS, so the throw is unreachable by construction.
|
|
1710
|
+
//
|
|
1711
|
+
// Each field is `{label, value, omitWhenAbsent?}`: `label` is the human field
|
|
1712
|
+
// name, `value` the raw stored value with null/absent meaning "never
|
|
1713
|
+
// configured", and `omitWhenAbsent` marks a field that is simply not printed
|
|
1714
|
+
// when it has no value (today's `gitUrl`, shown only in remote mode).
|
|
1715
|
+
const STORE_BACKEND_VIEWS = Object.freeze({
|
|
1716
|
+
'file-system': Object.freeze({
|
|
1717
|
+
// Exactly today's three lines in today's order, and no `Backend:` line: a
|
|
1718
|
+
// file-system store must render byte-identically to before (display AC2).
|
|
1719
|
+
identity: (store) => [
|
|
1720
|
+
{ label: 'Mode', value: store.mode },
|
|
1721
|
+
{ label: 'Path', value: store.path },
|
|
1722
|
+
{ label: 'Git URL', value: store.gitUrl, omitWhenAbsent: true },
|
|
1723
|
+
],
|
|
1724
|
+
}),
|
|
1725
|
+
confluence: Object.freeze({
|
|
1726
|
+
// `backend` comes in as the RESOLVED backend rather than being re-spelled
|
|
1727
|
+
// here, so this table names each backend exactly once — as a key.
|
|
1728
|
+
identity: (store, backend) => [
|
|
1729
|
+
{ label: 'Backend', value: backend },
|
|
1730
|
+
{ label: 'Space key', value: store.spaceKey },
|
|
1731
|
+
{ label: 'Root page title', value: (store.rootPage && store.rootPage.title) },
|
|
1732
|
+
{ label: 'Root page id', value: (store.rootPage && store.rootPage.id) },
|
|
1733
|
+
],
|
|
1734
|
+
}),
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
// An absent/empty `backend` is "never stated" — file-system — matching
|
|
1738
|
+
// storeFromRaw on the read half and serializeStore on the write half.
|
|
1739
|
+
function backendOfStore(store) {
|
|
1740
|
+
const declared = (store && typeof store.backend === 'string' && store.backend !== '')
|
|
1741
|
+
? store.backend : null;
|
|
1742
|
+
return declared || DEFAULT_STORE_BACKEND;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function storeIdentityFields(store) {
|
|
1746
|
+
const backend = backendOfStore(store);
|
|
1747
|
+
const view = STORE_BACKEND_VIEWS[backend];
|
|
1748
|
+
if (!view) {
|
|
1749
|
+
throw new Error(
|
|
1750
|
+
`No identity view for knowledge-store backend ${describeValue(backend)} — ` +
|
|
1751
|
+
`STORE_BACKEND_VIEWS must name every member of STORE_BACKENDS ` +
|
|
1752
|
+
`(${legalBackendsForMessage()}). Refusing to guess which fields identify ` +
|
|
1753
|
+
`this store, because guessing the file-system shape is what rendered a ` +
|
|
1754
|
+
`confluence store as "undefined".`
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
return view.identity(store || {}, backend);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// ─── The per-backend WIZARD view, keyed by STORE_BACKENDS ───────────────────
|
|
1761
|
+
//
|
|
1762
|
+
// Story 31.5 AC3: each knowledge-store question selects a BACKEND first, and
|
|
1763
|
+
// then that backend's location. That makes the wizard a THIRD direction over the
|
|
1764
|
+
// `backend` enum — exactly as MODE_PROMPT_TITLES made it a third direction over
|
|
1765
|
+
// the `mode` enums — so it gets the same treatment: ONE table keyed by the enum
|
|
1766
|
+
// members, with the suite pinning that these keys are exactly STORE_BACKENDS. A
|
|
1767
|
+
// wizard carrying its own private list of backends would reinstate the read/write
|
|
1768
|
+
// enum disagreement this epic has already fixed three times.
|
|
1769
|
+
//
|
|
1770
|
+
// `collect` gathers that backend's location. `null` means "a member of the enum
|
|
1771
|
+
// whose wizard flow does not exist yet", and such a backend is NOT OFFERED.
|
|
1772
|
+
//
|
|
1773
|
+
// WHY NOT OFFERED, rather than offered-and-pending. Story 31.5's AC5 allowed
|
|
1774
|
+
// either "not offered until 31.8" or "offered, recorded with a clearly-stated
|
|
1775
|
+
// configuration-pending state". The implementation-readiness review directed the
|
|
1776
|
+
// first: a pending state would need a THIRD value in a two-value schema field
|
|
1777
|
+
// that Story 31.2 sealed as a closed enum shared by reader, writer and prompt,
|
|
1778
|
+
// which is precisely the closed-enum defect class this epic has already fixed
|
|
1779
|
+
// three times. Offering a backend the installer cannot configure would also
|
|
1780
|
+
// record an unusable store — the permissive degradation P5-9 exists to prevent.
|
|
1781
|
+
//
|
|
1782
|
+
// STORY 31.8 HAS LANDED, and it was exactly the one-line change predicted here:
|
|
1783
|
+
// `confluence` now names its flow and is therefore offered, with no other change
|
|
1784
|
+
// to this table and none to `offerableStoreBackends` below. `collect: null`
|
|
1785
|
+
// remains the documented way to carry a member of the enum ahead of its flow.
|
|
1786
|
+
const STORE_BACKEND_WIZARD = Object.freeze({
|
|
1787
|
+
'file-system': Object.freeze({
|
|
1788
|
+
title: 'File system (this repository, a local path, or a remote git repository)',
|
|
1789
|
+
// Named function, not an inline arrow, so the table entry and the flow it
|
|
1790
|
+
// names are greppable in both directions.
|
|
1791
|
+
collect: collectFileSystemStoreLocation,
|
|
1792
|
+
}),
|
|
1793
|
+
confluence: Object.freeze({
|
|
1794
|
+
title: 'Confluence',
|
|
1795
|
+
// Story 31.8 — space (FR259) and root page (FR260), collected OFFLINE. The
|
|
1796
|
+
// flow asks two questions and neither is a credential (NFR79); see the block
|
|
1797
|
+
// comment above `collectConfluenceStoreLocation`.
|
|
1798
|
+
collect: collectConfluenceStoreLocation,
|
|
1799
|
+
}),
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
// The backends the wizard may actually offer — DERIVED from the enum and the
|
|
1803
|
+
// table above, listed nowhere. Order is STORE_BACKENDS' insertion order, which
|
|
1804
|
+
// is the order the user reads.
|
|
1805
|
+
//
|
|
1806
|
+
// `wizard` is a parameter only so the suite can pin that this really is a
|
|
1807
|
+
// derivation rather than a literal that happens to read `['file-system']` today:
|
|
1808
|
+
// the table is frozen (deliberately), so the test cannot give confluence a flow
|
|
1809
|
+
// in place and observe it become offered. Every production call site takes the
|
|
1810
|
+
// default. A hardcoded list here would be a SECOND definition of what Story 31.8
|
|
1811
|
+
// has to change, which is exactly the shape this epic keeps re-finding.
|
|
1812
|
+
function offerableStoreBackends(wizard = STORE_BACKEND_WIZARD) {
|
|
1813
|
+
return [...STORE_BACKENDS].filter((backend) => {
|
|
1814
|
+
const entry = wizard[backend];
|
|
1815
|
+
return !!(entry && typeof entry.collect === 'function');
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Story 31.8 mutation round. `wizard` is threaded through here for exactly the
|
|
1820
|
+
// reason it exists on `offerableStoreBackends` above, and it was missing: with
|
|
1821
|
+
// two offerable backends, replacing this body with the literal
|
|
1822
|
+
// `[{...'file-system'}, {...confluence}]` was UNDETECTABLE. The existing
|
|
1823
|
+
// enum-poisoning test adds a member and asserts the choices do NOT grow — which a
|
|
1824
|
+
// hardcoded list also satisfies — and the derivation probe was aimed one function
|
|
1825
|
+
// too low. The parameter makes the derivation observable at the layer the wizard
|
|
1826
|
+
// actually calls. Every production call site takes the default.
|
|
1827
|
+
function backendChoicesForStore(wizard = STORE_BACKEND_WIZARD) {
|
|
1828
|
+
return offerableStoreBackends(wizard).map((value) => ({
|
|
1829
|
+
title: wizard[value].title,
|
|
1830
|
+
value,
|
|
1831
|
+
}));
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// ─── The three concerns the layout wizard asks about (Story 31.5 AC1) ───────
|
|
1835
|
+
//
|
|
1836
|
+
// FR111's SINGLE knowledgebase question is superseded by two independent
|
|
1837
|
+
// knowledge-store questions. FR112's sprint-management question is UNCHANGED and
|
|
1838
|
+
// remains a third, separate concern asked exactly as it is today — sprint state
|
|
1839
|
+
// is deliberately not folded into either store, because FR112 and FR267 are
|
|
1840
|
+
// explicit that it is tracked separately and story files follow
|
|
1841
|
+
// `sprint_management_path`, not either store.
|
|
1842
|
+
//
|
|
1843
|
+
// `contents` is AC7. The wizard states what each store holds AT THE POINT OF
|
|
1844
|
+
// ASKING, inside the question text itself rather than in a console.log printed
|
|
1845
|
+
// next to it: a nearby line is a line the user may have scrolled past, and a test
|
|
1846
|
+
// asserting that it appeared "somewhere in stdout" would prove presence rather
|
|
1847
|
+
// than that the two questions are unambiguous. Putting it in the `message` makes
|
|
1848
|
+
// the claim checkable against the question the user was actually shown.
|
|
1849
|
+
//
|
|
1850
|
+
// `key` doubles as the concern name passed to `modeChoicesFor`, which offers
|
|
1851
|
+
// SPRINT_MODES for `sprintManagement` and STORE_MODES for everything else — so
|
|
1852
|
+
// the two store concerns get STORE_MODES (no `jira`) by construction.
|
|
1853
|
+
const REPO_LAYOUT_CONCERNS = Object.freeze([
|
|
1854
|
+
Object.freeze({
|
|
1855
|
+
key: 'systemRequirementsStore',
|
|
1856
|
+
kind: 'store',
|
|
1857
|
+
label: 'system requirements store',
|
|
1858
|
+
contents: 'product brief and PRD (for a sharded PRD, its index and all shards)',
|
|
1859
|
+
}),
|
|
1860
|
+
Object.freeze({
|
|
1861
|
+
key: 'softwareRequirementsStore',
|
|
1862
|
+
kind: 'store',
|
|
1863
|
+
label: 'software requirements store',
|
|
1864
|
+
contents: 'architecture document, UX designs, epics',
|
|
1865
|
+
}),
|
|
1866
|
+
Object.freeze({
|
|
1867
|
+
key: 'sprintManagement',
|
|
1868
|
+
kind: 'sprint',
|
|
1869
|
+
// Byte-identical to the label FR112's question has always used, so
|
|
1870
|
+
// "Where is your sprint management managed?" is unchanged (AC1).
|
|
1871
|
+
label: 'sprint management',
|
|
1872
|
+
}),
|
|
1873
|
+
]);
|
|
1874
|
+
|
|
1875
|
+
// sprint_management is the third location record and carries NO backend (P5-9.5)
|
|
1876
|
+
// — its shape is discriminated by `mode: jira`, which likewise has no `path`.
|
|
1877
|
+
// Kept alongside the store views because the renderer walks all three records
|
|
1878
|
+
// and `Path: undefined` for a Jira sprint backend is the same defect in the same
|
|
1879
|
+
// function.
|
|
1880
|
+
function sprintIdentityFields(sprint) {
|
|
1881
|
+
const s = sprint || {};
|
|
1882
|
+
if (s.mode === 'jira') {
|
|
1883
|
+
return [
|
|
1884
|
+
{ label: 'Mode', value: s.mode },
|
|
1885
|
+
{ label: 'Jira URL', value: s.jiraUrl },
|
|
1886
|
+
{ label: 'Jira project key', value: s.jiraProjectKey },
|
|
1887
|
+
];
|
|
1888
|
+
}
|
|
1889
|
+
return [
|
|
1890
|
+
{ label: 'Mode', value: s.mode },
|
|
1891
|
+
{ label: 'Path', value: s.path },
|
|
1892
|
+
{ label: 'Git URL', value: s.gitUrl, omitWhenAbsent: true },
|
|
1893
|
+
];
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// An absent field and an empty one are the same "never configured" value on both
|
|
1897
|
+
// sides of a comparison — which is exactly what scalarString() already
|
|
1898
|
+
// normalised on read.
|
|
1899
|
+
function identityValueKey(v) {
|
|
1900
|
+
return (v == null || v === '') ? null : String(v);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/**
|
|
1904
|
+
* Do the two knowledge stores point at DIFFERENT places? Derived from the same
|
|
1905
|
+
* table the renderer uses, so a future backend can never be compared on
|
|
1906
|
+
* file-system fields it does not have.
|
|
1907
|
+
*
|
|
1908
|
+
* This is the predicate the whole read/write fix turns on: while the two stores
|
|
1909
|
+
* agree, collapsing them onto the single legacy `knowledgebase` value is exact
|
|
1910
|
+
* and mapping one answered location back onto both is correct (FR264/NFR82).
|
|
1911
|
+
* Once they disagree, that mapping destroys one of them.
|
|
1912
|
+
*/
|
|
1913
|
+
function storesDiverge(a, b) {
|
|
1914
|
+
if (backendOfStore(a) !== backendOfStore(b)) return true;
|
|
1915
|
+
const fa = storeIdentityFields(a);
|
|
1916
|
+
const fb = storeIdentityFields(b);
|
|
1917
|
+
// Same backend ⇒ same field list by construction; compared anyway so a table
|
|
1918
|
+
// entry that ever became input-dependent cannot make two stores compare equal
|
|
1919
|
+
// on a subset of their identity.
|
|
1920
|
+
if (fa.length !== fb.length) return true;
|
|
1921
|
+
for (let i = 0; i < fa.length; i++) {
|
|
1922
|
+
if (fa[i].label !== fb[i].label) return true;
|
|
1923
|
+
if (identityValueKey(fa[i].value) !== identityValueKey(fb[i].value)) return true;
|
|
1924
|
+
}
|
|
1925
|
+
return false;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// The words a genuinely absent optional field renders as. Never a JavaScript
|
|
1929
|
+
// primitive: `undefined` in this output reads as file corruption, and a user who
|
|
1930
|
+
// concludes that re-runs the wizard.
|
|
1931
|
+
const IDENTITY_NOT_CONFIGURED = 'not configured';
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* TOTAL display coercion for one identity value. Cannot throw and cannot emit
|
|
1935
|
+
* the literal `undefined`: null/absent/empty render as words, and anything else
|
|
1936
|
+
* goes through describeValue (itself total).
|
|
1937
|
+
*
|
|
1938
|
+
* A store whose `path` is genuinely the nine-character string "undefined" does
|
|
1939
|
+
* render as `undefined` — that is the true value of the field, not this defect.
|
|
1940
|
+
*/
|
|
1941
|
+
function renderIdentityValue(v) {
|
|
1942
|
+
if (typeof v === 'string') return v === '' ? IDENTITY_NOT_CONFIGURED : v;
|
|
1943
|
+
if (v == null) return IDENTITY_NOT_CONFIGURED;
|
|
1944
|
+
return describeValue(v);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// One-line rendering of a store's identity, for a warning message rather than
|
|
1948
|
+
// the multi-line display block. Same table, so it can never describe a store by
|
|
1949
|
+
// fields that store does not have.
|
|
1950
|
+
function summarizeStoreIdentity(store) {
|
|
1951
|
+
return storeIdentityFields(store)
|
|
1952
|
+
.filter((f) => !(f.omitWhenAbsent && (f.value == null || f.value === '')))
|
|
1953
|
+
.map((f) => `${f.label}: ${renderIdentityValue(f.value)}`)
|
|
1954
|
+
.join(', ');
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// TOTAL value renderer for error messages. `JSON.stringify` is not total: it
|
|
1958
|
+
// THROWS on a BigInt and on a circular object, and returns `undefined` (not a
|
|
1959
|
+
// string) for a function or a symbol. On an error path that means a bare
|
|
1960
|
+
// TypeError escaping in place of the intended message - naming neither the
|
|
1961
|
+
// offending field nor the legal set, which is the one thing the message exists
|
|
1962
|
+
// to say. Unreachable from today's callers (the wizard produces `{mode, path}`
|
|
1963
|
+
// and readExistingLayout returns validated topology or null), but an error path
|
|
1964
|
+
// is the worst possible place to hold a second error.
|
|
1965
|
+
function describeValue(v) {
|
|
1966
|
+
try {
|
|
1967
|
+
const s = JSON.stringify(v);
|
|
1968
|
+
if (typeof s === 'string') return s;
|
|
1969
|
+
} catch (e) { /* BigInt, circular reference, a throwing toJSON */ }
|
|
1970
|
+
try {
|
|
1971
|
+
return typeof v === 'symbol' ? v.toString() : String(v);
|
|
1972
|
+
} catch (e) { /* a throwing toString / valueOf / Symbol.toPrimitive */ }
|
|
1973
|
+
return `[unprintable ${typeof v}]`;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// N-1: `mode` is a CLOSED enum in both directions, exactly like `backend`.
|
|
1977
|
+
// It was previously the one field raw-interpolated on write (`mode: ${mode}`)
|
|
1978
|
+
// and merely string-coerced on read, so a hand-edited `mode:` was a value
|
|
1979
|
+
// channel that became SYNTAX on the next write - it round-tripped through
|
|
1980
|
+
// readExistingLayout -> collectRepoLayout(--yes) -> writeProjectLayoutYaml and
|
|
1981
|
+
// emitted whatever it contained (an `api_token:` key, a second
|
|
1982
|
+
// `schema_version:`) at valid indentation, into both store records. Escaping is
|
|
1983
|
+
// the wrong instrument here: `mode: same` is deliberately unquoted, so the
|
|
1984
|
+
// values are validated instead and only enum members are ever emitted.
|
|
1985
|
+
//
|
|
1986
|
+
// The sets come from collectRepoLayout: every store mode is same|local|remote,
|
|
1987
|
+
// and sprint_management additionally offers jira.
|
|
1988
|
+
const STORE_MODES = new Set(['same', 'local', 'remote']);
|
|
1989
|
+
const SPRINT_MODES = new Set(['same', 'local', 'remote', 'jira']);
|
|
1990
|
+
|
|
1991
|
+
// The wizard is the THIRD direction that touches these enums: it PRODUCES the
|
|
1992
|
+
// value the writer validates and the reader reads back. It used to spell the
|
|
1993
|
+
// members out a third time as a literal `modeChoices` array, so a prompt could
|
|
1994
|
+
// offer a mode the writer rejects, or an enum could gain a member the prompt
|
|
1995
|
+
// never offers. One title per member here; the per-concern choice list is the
|
|
1996
|
+
// relevant enum mapped through this table (Set iteration is insertion-ordered,
|
|
1997
|
+
// so the offered order is the enum's order). A member with no title would be
|
|
1998
|
+
// unofferable, so the suite pins that these keys are exactly the union of the
|
|
1999
|
+
// two enums.
|
|
2000
|
+
//
|
|
2001
|
+
// Frozen because it is exported: a consumer that assigned a title here would
|
|
2002
|
+
// silently change what the wizard offers for every subsequent run in the
|
|
2003
|
+
// process. The Sets above cannot be protected the same way - `Object.freeze`
|
|
2004
|
+
// does not stop `Set.prototype.add` - so `module.exports` hands out COPIES of
|
|
2005
|
+
// them instead of the live objects (see the export list at the bottom).
|
|
2006
|
+
const MODE_PROMPT_TITLES = Object.freeze({
|
|
2007
|
+
same: 'Current repository (default)',
|
|
2008
|
+
local: 'Local path',
|
|
2009
|
+
remote: 'Remote git repository',
|
|
2010
|
+
jira: 'Jira',
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
function modeChoicesFor(concern) {
|
|
2014
|
+
const legal = concern === 'sprintManagement' ? SPRINT_MODES : STORE_MODES;
|
|
2015
|
+
return [...legal].map((value) => ({ title: MODE_PROMPT_TITLES[value], value }));
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// Read half. Throws; resolveProjectTopology converts it to `invalid`. Failing
|
|
2019
|
+
// loud rather than coercing to 'same' mirrors the `backend` enum for the same
|
|
2020
|
+
// reason: `mode` decides whether a path is cloned, and whether sprint artifacts
|
|
2021
|
+
// live in Jira or on disk, so guessing would silently repoint a store. It also
|
|
2022
|
+
// keeps the non-destruction guard engaged instead of laundering a tampered
|
|
2023
|
+
// value into a clean file on the next write.
|
|
2024
|
+
function requireKnownMode(mode, legal, fieldName) {
|
|
2025
|
+
if (legal.has(mode)) return mode;
|
|
2026
|
+
throw new Error(
|
|
2027
|
+
`Unknown ${fieldName} mode ${JSON.stringify(mode)} in ` +
|
|
2028
|
+
`_bmad-output/project-layout.yaml. Expected one of ` +
|
|
2029
|
+
`${[...legal].map((m) => JSON.stringify(m)).join(', ')}. Refusing to guess.`
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// Write half. An unknown mode must never reach the file, so this throws before
|
|
2034
|
+
// any bytes are produced - same contract as serializeStore's backend enum, and
|
|
2035
|
+
// the two install-time call sites already degrade such a throw to
|
|
2036
|
+
// warn-and-leave-UNBOUND. Returns the validated member, which by construction
|
|
2037
|
+
// contains no character needing YAML quoting.
|
|
2038
|
+
//
|
|
2039
|
+
// `describeValue` here, plain `JSON.stringify` in requireKnownMode above. The
|
|
2040
|
+
// difference is the TYPE GUARD on the input, not the age or provenance of the
|
|
2041
|
+
// function. requireKnownMode is only ever reached through `scalarString()`, so
|
|
2042
|
+
// its `mode` is a non-empty string by construction and JSON.stringify is total
|
|
2043
|
+
// on it. This function takes `store.mode` / `sprint.mode` straight off a
|
|
2044
|
+
// caller-supplied object with no guard at all, and `writeProjectLayoutYaml` is
|
|
2045
|
+
// exported - so `writeProjectLayoutYaml({sprintManagement: {mode: 1n}})` really
|
|
2046
|
+
// does reach here with a BigInt and really would throw a bare TypeError naming
|
|
2047
|
+
// neither the field nor the legal set. Behaviour is unchanged for every
|
|
2048
|
+
// reachable input: describeValue(s) === JSON.stringify(s) for every string.
|
|
2049
|
+
function assertWritableMode(mode, legal, fieldName) {
|
|
2050
|
+
if (legal.has(mode)) return mode;
|
|
2051
|
+
throw new Error(
|
|
2052
|
+
`Refusing to write _bmad-output/project-layout.yaml: invalid ${fieldName} mode ` +
|
|
2053
|
+
`${describeValue(mode)} — must be one of ${[...legal].join(', ')}`
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// Minimal indentation-aware reader for the subset of YAML this module itself
|
|
2058
|
+
// writes (flat scalars, one level of nesting for a store's `root_page`).
|
|
2059
|
+
// Deliberately not a general YAML parser — scoped to project-layout.yaml's
|
|
2060
|
+
// own shape, old (FR122/16.2) and new (schema v2) alike, matching this
|
|
2061
|
+
// codebase's existing hand-rolled-parser convention for this file rather
|
|
2062
|
+
// than introducing a new dependency on a hot path.
|
|
2063
|
+
function parseLayoutYaml(content) {
|
|
2064
|
+
const root = {};
|
|
2065
|
+
const stack = [{ indent: -1, obj: root }];
|
|
2066
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
2067
|
+
if (!rawLine.trim() || rawLine.trim().startsWith('#')) continue;
|
|
2068
|
+
const indent = rawLine.match(/^\s*/)[0].length;
|
|
2069
|
+
const line = rawLine.trim();
|
|
2070
|
+
const colonIdx = line.indexOf(':');
|
|
2071
|
+
if (colonIdx === -1) continue;
|
|
2072
|
+
const key = line.slice(0, colonIdx).trim();
|
|
2073
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
2074
|
+
|
|
2075
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
|
|
2076
|
+
const parent = stack[stack.length - 1].obj;
|
|
2077
|
+
|
|
2078
|
+
if (value === '') {
|
|
2079
|
+
const child = {};
|
|
2080
|
+
parent[key] = child;
|
|
2081
|
+
stack.push({ indent, obj: child });
|
|
2082
|
+
} else {
|
|
2083
|
+
// R-4: decode with the exact inverse of yamlEscapeValue. The old
|
|
2084
|
+
// two-replace form handled only \" and \\, so every other escape the
|
|
2085
|
+
// writer emits read back as literal backslash-text - a reader/writer
|
|
2086
|
+
// disagreement that made the round trip lossy and non-idempotent.
|
|
2087
|
+
// `[\s\S]` rather than `.` in the quote test because `.` does not match
|
|
2088
|
+
// U+2028/U+2029/CR/LF, so a hand-written file carrying one of those raw
|
|
2089
|
+
// silently failed quote-detection and kept its surrounding quotes.
|
|
2090
|
+
if (/^"[\s\S]*"$/.test(value)) value = yamlUnescapeDoubleQuoted(value.slice(1, -1));
|
|
2091
|
+
else if (value === 'null' || value === '~') value = null;
|
|
2092
|
+
parent[key] = value;
|
|
654
2093
|
}
|
|
655
2094
|
}
|
|
2095
|
+
return root;
|
|
2096
|
+
}
|
|
656
2097
|
|
|
657
|
-
|
|
658
|
-
|
|
2098
|
+
/**
|
|
2099
|
+
* parseLayoutYaml renders an EMPTY scalar (`path:` / `backend:` / `space_key:`
|
|
2100
|
+
* with nothing after the colon) as `{}`, because an empty value is how this
|
|
2101
|
+
* reader signals "a nested block follows". Every field read off a parsed record
|
|
2102
|
+
* must therefore go through this coercion, or the object leaks downstream and
|
|
2103
|
+
* surfaces either as a TypeError (`path.isAbsolute({})`) or, worse, as the string
|
|
2104
|
+
* `"[object Object]"` written back into the authoritative file on the next save.
|
|
2105
|
+
*
|
|
2106
|
+
* Returns the string, or null for anything that is not a usable non-empty
|
|
2107
|
+
* scalar (`{}`, null, an empty string, a number - parseLayoutYaml never
|
|
2108
|
+
* produces one, but a caller could).
|
|
2109
|
+
*/
|
|
2110
|
+
function scalarString(v) {
|
|
2111
|
+
return (typeof v === 'string' && v !== '') ? v : null;
|
|
659
2112
|
}
|
|
660
2113
|
|
|
661
2114
|
function resolveStoredPath(storedPath, projectRoot) {
|
|
@@ -667,54 +2120,955 @@ function resolveStoredPath(storedPath, projectRoot) {
|
|
|
667
2120
|
return normalizePath(path.resolve(projectRoot, storedPath));
|
|
668
2121
|
}
|
|
669
2122
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
2123
|
+
// Raw parsed section -> internal store shape, with portable-path resolution.
|
|
2124
|
+
// Every field is read through scalarString(), so an empty scalar anywhere in the
|
|
2125
|
+
// record degrades to that field's documented default instead of leaking a `{}`
|
|
2126
|
+
// into the topology (see scalarString above).
|
|
2127
|
+
function storeFromRaw(raw, projectRoot) {
|
|
2128
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
2129
|
+
|
|
2130
|
+
// The backend enum is CLOSED on read as well as on write: an unrecognised
|
|
2131
|
+
// value must not silently fall through to file-system and, with no `path:`
|
|
2132
|
+
// present, resolve the store to the PROJECT ROOT. Fail loud per the P5-9.3 /
|
|
2133
|
+
// NFR56 posture; resolveProjectTopology turns this throw into an `invalid`
|
|
2134
|
+
// result.
|
|
2135
|
+
//
|
|
2136
|
+
// R-5: an ABSENT `backend:` and an EMPTY `backend:` are both "the field was
|
|
2137
|
+
// never stated" and both default to file-system (AC5's pre-F26 legacy block).
|
|
2138
|
+
// Previously only absence was handled, so an empty scalar reached the enum
|
|
2139
|
+
// check as `{}` and produced the unactionable message
|
|
2140
|
+
// `Unknown knowledge-store backend {}` - a throw where the contract promises
|
|
2141
|
+
// coercion, and the exact opposite of what the identical malformation on
|
|
2142
|
+
// `path:` does. Only a non-empty STRING that is not one of the two legal
|
|
2143
|
+
// values is a genuine unknown backend.
|
|
2144
|
+
const backend = scalarString(raw.backend) || DEFAULT_STORE_BACKEND;
|
|
2145
|
+
if (!STORE_BACKENDS.has(backend)) {
|
|
2146
|
+
throw new Error(
|
|
2147
|
+
`Unknown knowledge-store backend ${JSON.stringify(backend)} in ` +
|
|
2148
|
+
`_bmad-output/project-layout.yaml. Expected ${legalBackendsForMessage()}. ` +
|
|
2149
|
+
`Refusing to guess - an unrecognised backend would otherwise resolve this ` +
|
|
2150
|
+
`store to the project root.`
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
if (backend === 'confluence') {
|
|
2154
|
+
// R-3: this branch previously used bare `!= null` checks with no string
|
|
2155
|
+
// coercion, so an empty `space_key:` / `title:` / `id:` scalar resolved to
|
|
2156
|
+
// `{}` and the next write emitted `space_key: "[object Object]"` into the
|
|
2157
|
+
// authoritative file. Identical defect class to the file-system branch
|
|
2158
|
+
// below, in the branch the first fix round did not touch.
|
|
2159
|
+
const rp = (raw.root_page && typeof raw.root_page === 'object') ? raw.root_page : {};
|
|
2160
|
+
return {
|
|
2161
|
+
backend: 'confluence',
|
|
2162
|
+
spaceKey: scalarString(raw.space_key),
|
|
2163
|
+
rootPage: {
|
|
2164
|
+
title: scalarString(rp.title),
|
|
2165
|
+
id: scalarString(rp.id),
|
|
2166
|
+
},
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
const mode = requireKnownMode(scalarString(raw.mode) || 'same', STORE_MODES, 'knowledge-store');
|
|
2170
|
+
const rawPath = scalarString(raw.path) || '.';
|
|
2171
|
+
const resolvedPath = resolveStoredPath(rawPath, projectRoot);
|
|
2172
|
+
const entry = { backend: 'file-system', mode, path: resolvedPath };
|
|
2173
|
+
if (mode === 'remote') {
|
|
2174
|
+
const gitUrl = scalarString(raw.gitUrl);
|
|
2175
|
+
if (gitUrl) entry.gitUrl = gitUrl;
|
|
2176
|
+
}
|
|
2177
|
+
return entry;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function sprintFromRaw(raw, projectRoot) {
|
|
2181
|
+
if (!raw || typeof raw !== 'object') return { mode: 'same', path: '.' };
|
|
2182
|
+
if (scalarString(raw.mode) === 'jira') {
|
|
2183
|
+
// `raw.jira_url || ''` was the same uncoerced read as the confluence branch:
|
|
2184
|
+
// an empty `jira_url:` scalar is `{}`, which is TRUTHY, so it survived the
|
|
2185
|
+
// `||` and the next write emitted `jira_url: "[object Object]"`.
|
|
2186
|
+
return {
|
|
2187
|
+
mode: 'jira',
|
|
2188
|
+
jiraUrl: scalarString(raw.jira_url) || '',
|
|
2189
|
+
jiraProjectKey: scalarString(raw.jira_project_key) || '',
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
const mode = requireKnownMode(scalarString(raw.mode) || 'same', SPRINT_MODES, 'sprint_management');
|
|
2193
|
+
const rawPath = scalarString(raw.path) || '.';
|
|
2194
|
+
const resolvedPath = resolveStoredPath(rawPath, projectRoot);
|
|
2195
|
+
const entry = { mode, path: resolvedPath };
|
|
2196
|
+
if (mode === 'remote') {
|
|
2197
|
+
const gitUrl = scalarString(raw.gitUrl);
|
|
2198
|
+
if (gitUrl) entry.gitUrl = gitUrl;
|
|
2199
|
+
}
|
|
2200
|
+
return entry;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* AC8 / NFR83 - the binding resolver. Given only a project root, returns the full
|
|
2205
|
+
* committed topology from `_bmad-output/project-layout.yaml` alone, with NO
|
|
2206
|
+
* reference to `_bmad/` or any other per-machine install state. This is the
|
|
2207
|
+
* library function `ma-agents bind` and `ma-agents status` are meant to call (the
|
|
2208
|
+
* commands themselves are Story 31.13); a globally-installed BMAD SKILL does not
|
|
2209
|
+
* call this directly - per Story 31.6 AC5 skills keep reading the generated
|
|
2210
|
+
* `_bmad/bmm/config.yaml`, and satisfy NFR83 indirectly by detecting a
|
|
2211
|
+
* missing/stale binding and offering `ma-agents bind` (31.11 AC6 -> 31.13 AC3).
|
|
2212
|
+
* See implementation-readiness-f26.md finding F-3 - the two ACs describe two
|
|
2213
|
+
* different consumers, not a contradiction.
|
|
2214
|
+
*
|
|
2215
|
+
* AC5 (FR265 read half): a pre-F26 file (no `schema_version`, a single
|
|
2216
|
+
* `knowledgebase:` record) resolves as BOTH stores pointing at that one location -
|
|
2217
|
+
* no error, no rewrite. This function never writes.
|
|
2218
|
+
*
|
|
2219
|
+
* CONTRACT - a DISCRIMINATED result. Never returns null, never throws:
|
|
2220
|
+
*
|
|
2221
|
+
* { ok: true, topology: {...} }
|
|
2222
|
+
* { ok: false, reason: 'unbound' | 'unreadable' | 'invalid' | 'too-new', message: '...' }
|
|
2223
|
+
*
|
|
2224
|
+
* - `unbound` - no binding here to speak of: absent, or empty / comments-only.
|
|
2225
|
+
* The ONLY reason under which a caller may write.
|
|
2226
|
+
* - `unreadable` - bytes exist but nothing trustworthy can be extracted: an I/O
|
|
2227
|
+
* error, content js-yaml rejects (see the validity gate below),
|
|
2228
|
+
* or a parse yielding no keys at all.
|
|
2229
|
+
* - `invalid` - parses, declares a binding, but not one this build can
|
|
2230
|
+
* honour: an unrecognised `backend:` or `mode:`, or keys with
|
|
2231
|
+
* no recognisable store record.
|
|
2232
|
+
* - `too-new` - a `schema_version` from a newer ma-agents. Split out from
|
|
2233
|
+
* `invalid` so `ma-agents status` (31.13) can say "upgrade
|
|
2234
|
+
* ma-agents" rather than "this file is corrupt"; the behaviour
|
|
2235
|
+
* is identical - refuse to read, refuse to overwrite.
|
|
2236
|
+
*
|
|
2237
|
+
* Every `ok:false` reason other than `unbound` means a caller MUST NOT overwrite
|
|
2238
|
+
* the file and should report `message` verbatim (P5-9.3 / NFR56 fail-loud).
|
|
2239
|
+
* The discriminated shape (rather than the superseded null-or-throw pair) is what
|
|
2240
|
+
* makes that unmissable: any caller reaching for `.topology` is forced past `.ok`,
|
|
2241
|
+
* so 31.3, 31.4 and 31.13 cannot each forget a try/catch. Rationale for the change
|
|
2242
|
+
* is in the commit message and the story record.
|
|
2243
|
+
*/
|
|
2244
|
+
const BINDING_UNBOUND = 'unbound';
|
|
2245
|
+
const BINDING_UNREADABLE = 'unreadable';
|
|
2246
|
+
const BINDING_INVALID = 'invalid';
|
|
2247
|
+
const BINDING_TOO_NEW = 'too-new';
|
|
2248
|
+
|
|
2249
|
+
/**
|
|
2250
|
+
* Story 31.13 fix round — THE OUTCOME OF AN AUTHORITATIVE WRITE, AS A VALUE.
|
|
2251
|
+
*
|
|
2252
|
+
* `writeProjectLayoutYaml` has five exits and used to return `undefined` from
|
|
2253
|
+
* every one of them, so a caller could not tell "wrote it" from "could not create
|
|
2254
|
+
* the directory". `handleBind` consequently printed `Bound.`, exited 0, generated
|
|
2255
|
+
* a `config.yaml` and registered the project after a write that had FAILED — the
|
|
2256
|
+
* blocking defect this round exists to close. A guard bolted onto `handleBind`
|
|
2257
|
+
* alone would have left the same blindness for the next caller, so the outcome is
|
|
2258
|
+
* made observable AT THE SOURCE instead.
|
|
2259
|
+
*
|
|
2260
|
+
* The five outcomes are kept SEPARATE, by name, for the reason `BINDING_UNBOUND`
|
|
2261
|
+
* and `BINDING_UNREADABLE` are separate: two of them mean the binding on disk is
|
|
2262
|
+
* good and three mean it is not, and collapsing them loses exactly the distinction
|
|
2263
|
+
* the caller needs. In particular `UNCHANGED_LEGACY` (Story 31.7 AC3/AC4 declined
|
|
2264
|
+
* to rewrite a legacy file because NOTHING MOVED — the project IS bound, by the
|
|
2265
|
+
* file that was already there) must never read as a failure, and `FAILED_MKDIR` /
|
|
2266
|
+
* `FAILED_WRITE` must never read as a success.
|
|
2267
|
+
*/
|
|
2268
|
+
const LAYOUT_WRITE_WROTE = 'wrote';
|
|
2269
|
+
const LAYOUT_WRITE_UNCHANGED_LEGACY = 'unchanged-legacy';
|
|
2270
|
+
const LAYOUT_WRITE_REFUSED = 'refused';
|
|
2271
|
+
const LAYOUT_WRITE_FAILED_MKDIR = 'failed-mkdir';
|
|
2272
|
+
const LAYOUT_WRITE_FAILED_WRITE = 'failed-write';
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* Which outcomes leave a usable committed binding on disk.
|
|
2276
|
+
*
|
|
2277
|
+
* DEFAULT-DENY, deliberately: the bound set is enumerated and everything else is
|
|
2278
|
+
* unbound, so an outcome added later is treated as NOT-bound until someone decides
|
|
2279
|
+
* otherwise. The inverse spelling (`=== FAILED_MKDIR || === FAILED_WRITE`) would
|
|
2280
|
+
* silently admit a sixth outcome as a success, which is the shape of the defect
|
|
2281
|
+
* this whole mechanism replaces.
|
|
2282
|
+
*/
|
|
2283
|
+
function projectLayoutWriteIsBound(outcome) {
|
|
2284
|
+
const o = outcome && outcome.outcome;
|
|
2285
|
+
return o === LAYOUT_WRITE_WROTE || o === LAYOUT_WRITE_UNCHANGED_LEGACY;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
function resolveProjectTopology(projectRoot) {
|
|
673
2289
|
const layoutPath = path.join(projectRoot, '_bmad-output', 'project-layout.yaml');
|
|
2290
|
+
const fail = (reason, message) => ({ ok: false, reason, message });
|
|
2291
|
+
|
|
2292
|
+
let content;
|
|
674
2293
|
try {
|
|
675
|
-
if (fs.existsSync(layoutPath)) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const modeMatch = afterSection.match(/^\s+mode:\s*(\S+)/m);
|
|
686
|
-
const pathMatch = afterSection.match(/^\s+path:\s*"?([^"\n]+)"?/m);
|
|
687
|
-
const gitUrlMatch = afterSection.match(/^\s+gitUrl:\s*"?([^"\n]+)"?/m);
|
|
688
|
-
const jiraUrlMatch = afterSection.match(/^\s+jira_url:\s*"?([^"\n]+)"?/m);
|
|
689
|
-
const jiraProjectKeyMatch = afterSection.match(/^\s+jira_project_key:\s*"?([^"\n]+)"?/m);
|
|
690
|
-
|
|
691
|
-
if (modeMatch) {
|
|
692
|
-
const mode = modeMatch[1];
|
|
693
|
-
if (mode === 'jira') {
|
|
694
|
-
const jiraUrl = jiraUrlMatch ? jiraUrlMatch[1].replace(/^"(.*)"$/, '$1') : '';
|
|
695
|
-
const jiraProjectKey = jiraProjectKeyMatch ? jiraProjectKeyMatch[1].replace(/^"(.*)"$/, '$1') : '';
|
|
696
|
-
layout[key] = { mode: 'jira', jiraUrl, jiraProjectKey };
|
|
697
|
-
} else if (pathMatch) {
|
|
698
|
-
const resolvedPath = resolveStoredPath(pathMatch[1], projectRoot);
|
|
699
|
-
const entry = { mode, path: resolvedPath };
|
|
700
|
-
if (mode === 'remote' && gitUrlMatch) {
|
|
701
|
-
entry.gitUrl = gitUrlMatch[1];
|
|
702
|
-
}
|
|
703
|
-
layout[key] = entry;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
2294
|
+
if (!fs.existsSync(layoutPath)) {
|
|
2295
|
+
return fail(BINDING_UNBOUND,
|
|
2296
|
+
`No _bmad-output/project-layout.yaml in ${projectRoot} - this project has no ` +
|
|
2297
|
+
`committed knowledge binding yet.`);
|
|
2298
|
+
}
|
|
2299
|
+
content = fs.readFileSync(layoutPath, 'utf-8');
|
|
2300
|
+
} catch (e) {
|
|
2301
|
+
return fail(BINDING_UNREADABLE,
|
|
2302
|
+
`Cannot read _bmad-output/project-layout.yaml: ${e.message}`);
|
|
2303
|
+
}
|
|
707
2304
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
2305
|
+
const hasContent = content
|
|
2306
|
+
.split(/\r?\n/)
|
|
2307
|
+
.some((l) => l.trim() !== '' && !l.trim().startsWith('#'));
|
|
2308
|
+
if (!hasContent) {
|
|
2309
|
+
return fail(BINDING_UNBOUND,
|
|
2310
|
+
`_bmad-output/project-layout.yaml is empty - this project has no committed ` +
|
|
2311
|
+
`knowledge binding yet.`);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// N-2 - THE VALIDITY GATE. parseLayoutYaml is deliberately lenient (it skips
|
|
2315
|
+
// any line without a colon, and is indentation-agnostic about tabs), so it
|
|
2316
|
+
// happily "reads" files a real YAML parser rejects: a git merge conflict
|
|
2317
|
+
// (`<<<<<<< HEAD` / `=======` / `>>>>>>> origin/main` all lack a colon and are
|
|
2318
|
+
// silently dropped, leaving the last-writer side) or tab-indented content.
|
|
2319
|
+
// Without this gate the non-destruction guard in writeProjectLayoutYaml was
|
|
2320
|
+
// adjudicated by the lenient parser, so it returned ok:true for genuinely
|
|
2321
|
+
// corrupt bytes and the overwrite proceeded - destroying the conflict markers
|
|
2322
|
+
// and one side of the merge with no warning. project-layout.yaml is COMMITTED
|
|
2323
|
+
// and carries a `generated:` date that changes on every install, so conflicts
|
|
2324
|
+
// are ordinary, not exotic.
|
|
2325
|
+
//
|
|
2326
|
+
// js-yaml validates ONLY; parseLayoutYaml remains the reader (its escape
|
|
2327
|
+
// handling and shape are what the rest of this module is built on). The throw
|
|
2328
|
+
// is classified `unreadable` - bytes exist but nothing trustworthy can be
|
|
2329
|
+
// extracted - and is caught here so this function stays total.
|
|
2330
|
+
try {
|
|
2331
|
+
yaml.load(content);
|
|
2332
|
+
} catch (e) {
|
|
2333
|
+
return fail(BINDING_UNREADABLE,
|
|
2334
|
+
`_bmad-output/project-layout.yaml is not valid YAML: ${(e && e.message) || String(e)}\n` +
|
|
2335
|
+
` If this is a merge conflict, resolve it by hand — or delete the file and re-run the ` +
|
|
2336
|
+
`installer to write a fresh binding.`);
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
let raw;
|
|
2340
|
+
try {
|
|
2341
|
+
raw = parseLayoutYaml(content);
|
|
2342
|
+
} catch (e) {
|
|
2343
|
+
return fail(BINDING_UNREADABLE,
|
|
2344
|
+
`Cannot parse _bmad-output/project-layout.yaml: ${e.message}`);
|
|
2345
|
+
}
|
|
2346
|
+
if (!raw || typeof raw !== 'object' || Object.keys(raw).length === 0) {
|
|
2347
|
+
return fail(BINDING_UNREADABLE,
|
|
2348
|
+
`_bmad-output/project-layout.yaml has content but no readable fields. Fix it, or ` +
|
|
2349
|
+
`delete it and re-run the installer to write a fresh binding.`);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Schema gate. An absent or empty `schema_version:` means a pre-F26 file (AC5),
|
|
2353
|
+
// which is schema 1. A version this build does not understand is INVALID and
|
|
2354
|
+
// must not be read as v2 or overwritten: a newer ma-agents may have recorded
|
|
2355
|
+
// records or fields that would be silently dropped on the next write.
|
|
2356
|
+
const declaredVersion = scalarString(raw.schema_version);
|
|
2357
|
+
let schemaVersion = 1;
|
|
2358
|
+
if (declaredVersion !== null) {
|
|
2359
|
+
if (!/^\d+$/.test(declaredVersion.trim())) {
|
|
2360
|
+
return fail(BINDING_INVALID,
|
|
2361
|
+
`_bmad-output/project-layout.yaml has a non-numeric schema_version ` +
|
|
2362
|
+
`${JSON.stringify(declaredVersion)}. Refusing to guess which schema this file uses.`);
|
|
2363
|
+
}
|
|
2364
|
+
schemaVersion = parseInt(declaredVersion.trim(), 10);
|
|
2365
|
+
}
|
|
2366
|
+
if (schemaVersion > PROJECT_LAYOUT_SCHEMA_VERSION) {
|
|
2367
|
+
return fail(BINDING_TOO_NEW,
|
|
2368
|
+
`_bmad-output/project-layout.yaml declares schema_version ${schemaVersion}, but this ` +
|
|
2369
|
+
`ma-agents understands up to ${PROJECT_LAYOUT_SCHEMA_VERSION}. It was written by a newer ` +
|
|
2370
|
+
`version. Refusing to read or overwrite it - upgrade ma-agents instead.`);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
try {
|
|
2374
|
+
// THE DECLARED VERSION DECIDES WHICH SHAPE IS EXPECTED — always, with no
|
|
2375
|
+
// fallback. This condition used to also require `raw.system_requirements_store`
|
|
2376
|
+
// to be present, which meant a file DECLARING `schema_version: 2` while
|
|
2377
|
+
// carrying only a v1 `knowledgebase:` record fell through to the legacy branch
|
|
2378
|
+
// below and was reported as `schemaVersion: 1` — the declared version silently
|
|
2379
|
+
// ignored, the only place in this resolver where that happened. Story 31.7's
|
|
2380
|
+
// preservation branch in `writeProjectLayoutYaml` then made that permanent:
|
|
2381
|
+
// before it, the next install overwrote the incoherent file with a real v2 one
|
|
2382
|
+
// and the problem self-healed; after it, "nothing moved" is true (both stores
|
|
2383
|
+
// derive from the one legacy record) so nothing is ever written again, and the
|
|
2384
|
+
// legacy notice tells the user their file "predates the two-store layout"
|
|
2385
|
+
// about a file that declares schema 2. Frozen, and told something false.
|
|
2386
|
+
//
|
|
2387
|
+
// ma-agents cannot produce this shape: the writer emits `schema_version:` and
|
|
2388
|
+
// both store records together. It is reachable only by hand-editing or a bad
|
|
2389
|
+
// merge, so it is MALFORMED, and the posture for a malformed authoritative
|
|
2390
|
+
// file in this epic is to fail loud and touch nothing (P5-9.3): a v3 file is
|
|
2391
|
+
// `too-new` and refused, a v2 file missing one store record is `invalid`.
|
|
2392
|
+
// Reading a declared-v2 file as v1 was the sole exception. It now classifies
|
|
2393
|
+
// `invalid` like its siblings, which also restores a way OUT — the
|
|
2394
|
+
// non-destruction guard prints the message below and names the recovery — and
|
|
2395
|
+
// makes the detector's justifying comment true rather than false. Nothing is
|
|
2396
|
+
// migrated and nothing is rewritten; this is a refusal, not a repair.
|
|
2397
|
+
if (schemaVersion >= PROJECT_LAYOUT_SCHEMA_VERSION) {
|
|
2398
|
+
const systemStore = storeFromRaw(raw.system_requirements_store, projectRoot);
|
|
2399
|
+
const softwareStore = storeFromRaw(raw.software_requirements_store, projectRoot);
|
|
2400
|
+
if (!systemStore || !softwareStore) {
|
|
2401
|
+
return fail(BINDING_INVALID,
|
|
2402
|
+
`_bmad-output/project-layout.yaml declares schema_version ${schemaVersion} but is ` +
|
|
2403
|
+
`missing a required store record (system_requirements_store / ` +
|
|
2404
|
+
`software_requirements_store). A file that declares schema ` +
|
|
2405
|
+
`${PROJECT_LAYOUT_SCHEMA_VERSION} while carrying only a pre-F26 knowledgebase record ` +
|
|
2406
|
+
`is malformed, not legacy - a genuine pre-F26 file declares no schema_version at all. ` +
|
|
2407
|
+
`Fix it by hand, or delete it and re-run the installer to write a fresh binding.`);
|
|
714
2408
|
}
|
|
2409
|
+
return {
|
|
2410
|
+
ok: true,
|
|
2411
|
+
topology: {
|
|
2412
|
+
schemaVersion,
|
|
2413
|
+
generated: scalarString(raw.generated),
|
|
2414
|
+
systemRequirementsStore: systemStore,
|
|
2415
|
+
softwareRequirementsStore: softwareStore,
|
|
2416
|
+
sprintManagement: sprintFromRaw(raw.sprint_management, projectRoot),
|
|
2417
|
+
},
|
|
2418
|
+
};
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
// Legacy (FR122/Story 16.2 shape, or a file declaring a schema_version below
|
|
2422
|
+
// the current one): a single `knowledgebase:` record read as both stores
|
|
2423
|
+
// pointing at that location. Only reachable now when the DECLARED version is
|
|
2424
|
+
// below the current one — a declared-current file is adjudicated above,
|
|
2425
|
+
// whatever records it happens to carry.
|
|
2426
|
+
const legacyStore = storeFromRaw(raw.knowledgebase, projectRoot);
|
|
2427
|
+
if (!legacyStore) {
|
|
2428
|
+
return fail(BINDING_INVALID,
|
|
2429
|
+
`_bmad-output/project-layout.yaml has content but declares no recognisable store ` +
|
|
2430
|
+
`record (expected system_requirements_store for schema ` +
|
|
2431
|
+
`${PROJECT_LAYOUT_SCHEMA_VERSION}, or knowledgebase for a pre-F26 file). Fix it, or ` +
|
|
2432
|
+
`delete it and re-run the installer to write a fresh binding.`);
|
|
2433
|
+
}
|
|
2434
|
+
return {
|
|
2435
|
+
ok: true,
|
|
2436
|
+
topology: {
|
|
2437
|
+
schemaVersion: 1,
|
|
2438
|
+
generated: scalarString(raw.generated),
|
|
2439
|
+
systemRequirementsStore: legacyStore,
|
|
2440
|
+
softwareRequirementsStore: { ...legacyStore },
|
|
2441
|
+
sprintManagement: sprintFromRaw(raw.sprint_management, projectRoot),
|
|
2442
|
+
},
|
|
2443
|
+
};
|
|
2444
|
+
} catch (e) {
|
|
2445
|
+
// storeFromRaw's closed-enum throw, and anything else semantic. Converted
|
|
2446
|
+
// here so that this function's never-throws contract holds for every caller.
|
|
2447
|
+
return fail(BINDING_INVALID, e.message);
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2452
|
+
// STORY 31.7 (FR265, NFR82) — LEGACY SINGLE-STORE COMPATIBILITY, AND A
|
|
2453
|
+
// MIGRATION THAT IS OFFERED AND NEVER PERFORMED.
|
|
2454
|
+
//
|
|
2455
|
+
// ── HOW LEGACY IS DETECTED, AND WHY NOT BY FIELD NAMES ────────────────────
|
|
2456
|
+
// The story's own technical note is explicit: detect legacy by the ABSENCE of
|
|
2457
|
+
// Story 31.2's `schema_version` field, not by heuristics over which field names
|
|
2458
|
+
// happen to be present. That is what this function does, and it does it by
|
|
2459
|
+
// asking `resolveProjectTopology` rather than re-reading the file: the resolver
|
|
2460
|
+
// already derives `topology.schemaVersion` from exactly one thing — a present,
|
|
2461
|
+
// numeric `schema_version:` line, defaulting to 1 when absent or empty — and it
|
|
2462
|
+
// is the ONLY place that derivation exists. A second reader here would be a
|
|
2463
|
+
// second definition of "what schema is this file", i.e. the read/write enum
|
|
2464
|
+
// disagreement this epic has already fixed three times, one layer up.
|
|
2465
|
+
//
|
|
2466
|
+
// A heuristic over field names would ALSO be wrong on its own terms, not merely
|
|
2467
|
+
// duplicative. `knowledgebase:` present is not sufficient (a hand-edited v2 file
|
|
2468
|
+
// may carry a stale one), and `system_requirements_store:` absent is not
|
|
2469
|
+
// sufficient either: a file that DECLARES the current schema but is missing a
|
|
2470
|
+
// store record is `invalid` — a fault, not a legacy layout — whether what it
|
|
2471
|
+
// carries instead is a stale `knowledgebase:` record or nothing at all.
|
|
2472
|
+
//
|
|
2473
|
+
// That second claim is now true of EVERY declared-current file. It was NOT true
|
|
2474
|
+
// when this comment was first written: the shape `schema_version: 2` + a
|
|
2475
|
+
// `knowledgebase:` record and no `system_requirements_store:` fell through to the
|
|
2476
|
+
// resolver's legacy branch and came back `ok:true, schemaVersion: 1` — i.e. as
|
|
2477
|
+
// precisely the "legacy layout" this paragraph asserted it could not be, which
|
|
2478
|
+
// then earned it a notice saying its file "predates the two-store layout" about a
|
|
2479
|
+
// file declaring schema 2. The resolver was corrected (see the block above the
|
|
2480
|
+
// declared-version gate in `resolveProjectTopology`); the shape is pinned in
|
|
2481
|
+
// test/project-layout-legacy-single-store.test.js.
|
|
2482
|
+
//
|
|
2483
|
+
// ── WHY THIS CANNOT CONTRADICT STORY 31.4'S DISCRIMINATOR ─────────────────
|
|
2484
|
+
// Story 31.4's skill preflight decides "is this genuinely a pre-F26 project?"
|
|
2485
|
+
// in its Step P2 — the branch it reaches ONLY when `_bmad-output/
|
|
2486
|
+
// project-layout.yaml` is ABSENT (or blank/comments-only). There it corroborates
|
|
2487
|
+
// the absent `project_layout_stamp` against `system_requirements_path` /
|
|
2488
|
+
// `software_requirements_path` in `config.yaml`, because a config carrying those
|
|
2489
|
+
// is demonstrably NOT pre-F26 and must HALT.
|
|
2490
|
+
//
|
|
2491
|
+
// This function is disjoint from that branch BY CONSTRUCTION: it returns
|
|
2492
|
+
// `legacy: false` for every `ok:false` result, so it can only ever fire where a
|
|
2493
|
+
// `project-layout.yaml` EXISTS AND RESOLVES — which is precisely the input on
|
|
2494
|
+
// which 31.4 takes Step P1 → Step P3 and never reaches Step P2 at all. The two
|
|
2495
|
+
// therefore cannot disagree about the same project: for any project one of them
|
|
2496
|
+
// classifies, the other is in a different branch. Pinned in
|
|
2497
|
+
// test/project-layout-legacy-single-store.test.js ("agreement with Story 31.4").
|
|
2498
|
+
//
|
|
2499
|
+
// It also never reads a stamp, and never reads `config.yaml` at all. An absent
|
|
2500
|
+
// stamp is 31.4's evidence, and reusing it here would couple the informational
|
|
2501
|
+
// notice to the blocking guard's evidence — the coupling the story's second
|
|
2502
|
+
// technical note forbids.
|
|
2503
|
+
//
|
|
2504
|
+
// ── THE RESULT IS DISCRIMINATED, for the same reason 31.2's and 31.3's are ──
|
|
2505
|
+
// { legacy: true, topology }
|
|
2506
|
+
// { legacy: false, reason: 'two-store', topology }
|
|
2507
|
+
// { legacy: false, reason: 'unbound'|'unreadable'|'invalid'|'too-new', message }
|
|
2508
|
+
// A bare boolean would collapse "this project is fine, it is already split" into
|
|
2509
|
+
// "this project's binding is corrupt", and the caller would print an
|
|
2510
|
+
// informational notice for both — or, worse, for neither.
|
|
2511
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2512
|
+
|
|
2513
|
+
// AC1's second clause — "already on the two-store layout" — is a NON-legacy
|
|
2514
|
+
// outcome with its own name, so a caller cannot confuse it with a fault.
|
|
2515
|
+
const LEGACY_TWO_STORE = 'two-store';
|
|
2516
|
+
|
|
2517
|
+
function detectLegacySingleStore(projectRoot) {
|
|
2518
|
+
const binding = resolveProjectTopology(projectRoot);
|
|
2519
|
+
if (!binding.ok) {
|
|
2520
|
+
// Every fault, and `unbound`, are NOT legacy. `unbound` especially: a project
|
|
2521
|
+
// with no committed binding is Story 31.4's Step P2 input and is decided
|
|
2522
|
+
// there, against `config.yaml`'s per-store fields. Claiming it here would be
|
|
2523
|
+
// this story contradicting that discriminator — the exact failure the
|
|
2524
|
+
// technical notes warn about.
|
|
2525
|
+
return { legacy: false, reason: binding.reason, message: binding.message };
|
|
2526
|
+
}
|
|
2527
|
+
if (binding.topology.schemaVersion >= PROJECT_LAYOUT_SCHEMA_VERSION) {
|
|
2528
|
+
return { legacy: false, reason: LEGACY_TWO_STORE, topology: binding.topology };
|
|
2529
|
+
}
|
|
2530
|
+
return { legacy: true, topology: binding.topology };
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// ── THE NOTICE (AC2) ───────────────────────────────────────────────────────
|
|
2534
|
+
//
|
|
2535
|
+
// AC2 fixes the content at exactly three facts, one per constant below:
|
|
2536
|
+
// (i) this project is on the legacy single-store layout;
|
|
2537
|
+
// (ii) what that means — both stores resolve to the one location, and
|
|
2538
|
+
// nothing has been changed or moved;
|
|
2539
|
+
// (iii) the EXACT command that splits it, and that nothing happens unless the
|
|
2540
|
+
// user runs it.
|
|
2541
|
+
//
|
|
2542
|
+
// ── CHANNEL SEPARATION FROM STORY 31.4'S DRIFT ERROR ──────────────────────
|
|
2543
|
+
// The story's second technical note makes this a requirement, not a nicety:
|
|
2544
|
+
// legacy is informational and non-blocking, drift is loud and blocking, and if
|
|
2545
|
+
// they share a channel the user learns to ignore both. Four things keep them
|
|
2546
|
+
// apart, and each is pinned by a test:
|
|
2547
|
+
//
|
|
2548
|
+
// 1. DIFFERENT SURFACE. 31.4's guard lives in the 41 byte-identical preflight
|
|
2549
|
+
// blocks embedded in the skill entry documents and speaks at SKILL
|
|
2550
|
+
// ACTIVATION. This notice is printed by the ma-agents CLI during `install`
|
|
2551
|
+
// and nowhere else. No skill document mentions the legacy layout at all —
|
|
2552
|
+
// a legacy project produces ZERO extra output on skill activation, which is
|
|
2553
|
+
// also how AC5 is satisfied for "activations".
|
|
2554
|
+
// 2. DIFFERENT COLOUR. 31.4 halts in the red/`stopping` register. This is
|
|
2555
|
+
// chalk.cyan + chalk.gray — the same informational register Story 30.4's
|
|
2556
|
+
// one-time notice uses, and deliberately NOT chalk.yellow (this codebase's
|
|
2557
|
+
// warning colour) either.
|
|
2558
|
+
// 3. DIFFERENT VOCABULARY. The halt blocks open "ma-agents — stopping:" and
|
|
2559
|
+
// close "Nothing has been read or written on your behalf." Those phrases
|
|
2560
|
+
// are ABSENT here, and the test asserts their absence rather than merely
|
|
2561
|
+
// asserting this text is present.
|
|
2562
|
+
// 4. DIFFERENT CONSEQUENCE. 31.4 refuses to proceed. This returns a boolean
|
|
2563
|
+
// and the install continues; it cannot halt anything, because it is a
|
|
2564
|
+
// `console.log` and a manifest write and has no other effect.
|
|
2565
|
+
//
|
|
2566
|
+
// The command is its own constant so a test can bind AC2's "the exact command"
|
|
2567
|
+
// claim to the shipped string rather than to a copy of it, and so a mutant that
|
|
2568
|
+
// changes the command cannot survive by matching a looser regex.
|
|
2569
|
+
const LEGACY_SINGLE_STORE_SPLIT_COMMAND = 'ma-agents config layout';
|
|
2570
|
+
|
|
2571
|
+
const LEGACY_SINGLE_STORE_NOTICE_FACT_1 =
|
|
2572
|
+
' Note: this project uses the legacy single-store knowledge layout.';
|
|
2573
|
+
|
|
2574
|
+
const LEGACY_SINGLE_STORE_NOTICE_FACT_2 =
|
|
2575
|
+
' What that means: _bmad-output/project-layout.yaml predates the two-store layout, so the\n' +
|
|
2576
|
+
' system requirements store (product brief, PRD) and the software requirements store\n' +
|
|
2577
|
+
' (architecture, UX designs, epics) both resolve to the SAME one location:';
|
|
2578
|
+
|
|
2579
|
+
const LEGACY_SINGLE_STORE_NOTICE_FACT_3 =
|
|
2580
|
+
` Nothing has been changed or moved and every artifact path is exactly where it was. To\n` +
|
|
2581
|
+
` split the two stores so they can live in different places, run \`${LEGACY_SINGLE_STORE_SPLIT_COMMAND}\`.\n` +
|
|
2582
|
+
` Nothing is migrated unless you run it. This message is shown once.`;
|
|
2583
|
+
|
|
2584
|
+
const LEGACY_SINGLE_STORE_NOTICE_ID = 'legacy-single-store';
|
|
2585
|
+
|
|
2586
|
+
/**
|
|
2587
|
+
* AC2 + AC5 — print the legacy notice AT MOST ONCE, EVER, for this project.
|
|
2588
|
+
*
|
|
2589
|
+
* Idempotence uses Story 30.4's generic one-time-notice tracking verbatim
|
|
2590
|
+
* (`lib/profile.js#hasShownNotice` / `#markNoticeShown`, persisted under
|
|
2591
|
+
* `notices` in the project-root `.ma-agents.json`), rather than a second
|
|
2592
|
+
* mechanism that would have to be kept honest separately. AC5 names that pattern
|
|
2593
|
+
* explicitly.
|
|
2594
|
+
*
|
|
2595
|
+
* NON-BLOCKING BY CONSTRUCTION (AC2): this function prompts nothing, throws
|
|
2596
|
+
* nothing at its caller, exits nothing, and writes nothing under `_bmad-output/`.
|
|
2597
|
+
* It touches exactly one file, `.ma-agents.json`, and only to record that it has
|
|
2598
|
+
* spoken. A failure to record that is deliberately swallowed: an unwritable
|
|
2599
|
+
* manifest must not abort an install over an informational message. The cost of
|
|
2600
|
+
* swallowing is that the notice may print again next time — the safe direction,
|
|
2601
|
+
* and the only direction available, since the alternative is failing an install.
|
|
2602
|
+
*
|
|
2603
|
+
* MIGRATION IS OFFERED, NEVER PERFORMED (AC3): this is the entire offer. It
|
|
2604
|
+
* names `ma-agents config layout` — which since Story 31.5 asks the two
|
|
2605
|
+
* independent store questions and writes through Story 31.2's
|
|
2606
|
+
* `writeProjectLayoutYaml` — and does not itself call, schedule or arm anything.
|
|
2607
|
+
* Accepting the offer means the user runs that command; declining means they do
|
|
2608
|
+
* not, and a project on which they do not run it is left byte-identical (AC4),
|
|
2609
|
+
* which `writeProjectLayoutYaml`'s legacy-preservation branch is what actually
|
|
2610
|
+
* guarantees.
|
|
2611
|
+
*
|
|
2612
|
+
* @param {string} projectRoot
|
|
2613
|
+
* @returns {boolean} true if the notice printed on this call; false if the
|
|
2614
|
+
* project is not legacy, or the notice had already been shown.
|
|
2615
|
+
*/
|
|
2616
|
+
function showLegacySingleStoreNoticeOnce(projectRoot) {
|
|
2617
|
+
const detection = detectLegacySingleStore(projectRoot);
|
|
2618
|
+
if (!detection.legacy) return false;
|
|
2619
|
+
if (hasShownNotice(projectRoot, LEGACY_SINGLE_STORE_NOTICE_ID)) return false;
|
|
2620
|
+
|
|
2621
|
+
console.log(chalk.cyan(`\n${LEGACY_SINGLE_STORE_NOTICE_FACT_1}`));
|
|
2622
|
+
console.log(chalk.gray(LEGACY_SINGLE_STORE_NOTICE_FACT_2));
|
|
2623
|
+
console.log(chalk.gray(
|
|
2624
|
+
` ${summarizeStoreIdentity(detection.topology.softwareRequirementsStore)}`));
|
|
2625
|
+
console.log(chalk.gray(`${LEGACY_SINGLE_STORE_NOTICE_FACT_3}\n`));
|
|
2626
|
+
|
|
2627
|
+
try {
|
|
2628
|
+
markNoticeShown(projectRoot, LEGACY_SINGLE_STORE_NOTICE_ID);
|
|
2629
|
+
} catch (e) {
|
|
2630
|
+
// See the NON-BLOCKING note above. Said out loud rather than silently
|
|
2631
|
+
// dropped, so a user who sees the notice twice knows why.
|
|
2632
|
+
console.log(chalk.gray(
|
|
2633
|
+
` (could not record that this notice was shown: ${e.message} — it may appear again)`));
|
|
2634
|
+
}
|
|
2635
|
+
return true;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
// Enumerate exactly {mode, path, gitUrl} — see the NFR79 note at the top of
|
|
2639
|
+
// this section. `store` here is always a file-system-backend store.
|
|
2640
|
+
function serializeFileSystemStore(store, projectRoot) {
|
|
2641
|
+
const mode = assertWritableMode((store && store.mode) || 'same', STORE_MODES, 'knowledge-store');
|
|
2642
|
+
const rawPath = (store && store.path != null) ? store.path : '.';
|
|
2643
|
+
const portable = toPortablePath(rawPath, projectRoot);
|
|
2644
|
+
let out = ` backend: file-system\n`;
|
|
2645
|
+
out += ` mode: ${mode}\n`;
|
|
2646
|
+
out += ` path: ${yamlEscapeValue(portable.portable)}\n`;
|
|
2647
|
+
if (portable.isAbsolute) {
|
|
2648
|
+
out += ` # PORTABILITY: absolute path — other developers may need to reconfigure\n`;
|
|
2649
|
+
}
|
|
2650
|
+
if (mode === 'remote' && store.gitUrl) {
|
|
2651
|
+
out += ` gitUrl: ${yamlEscapeValue(store.gitUrl)}\n`;
|
|
2652
|
+
}
|
|
2653
|
+
return out;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
// Enumerate exactly {space_key, root_page: {title, id}} — nothing else is
|
|
2657
|
+
// ever read off `store`, so an accidental credential field on the in-memory
|
|
2658
|
+
// object (e.g. a `token`/`password` picked up upstream) cannot leak into the
|
|
2659
|
+
// committed file. This is what AC6's "no credential-shaped field can be
|
|
2660
|
+
// written" test proves, and what makes the file safe to commit (NFR79).
|
|
2661
|
+
function serializeConfluenceStore(store) {
|
|
2662
|
+
const rootPage = (store && store.rootPage) || {};
|
|
2663
|
+
// The line-break POLICY check lives here, not in yamlEscapeValue: this is the
|
|
2664
|
+
// layer that knows it is writing project-layout.yaml and which field is at
|
|
2665
|
+
// fault, so the message names both. Escaping already guarantees the emitted
|
|
2666
|
+
// file is parseable whatever these values contain (see yamlEscapeValue); this
|
|
2667
|
+
// guard exists because a multi-line Confluence space key or page title is
|
|
2668
|
+
// meaningless identity, not because it is dangerous.
|
|
2669
|
+
const spaceKey = assertSingleLineIdentity(store && store.spaceKey, 'space_key');
|
|
2670
|
+
const title = assertSingleLineIdentity(rootPage.title, 'root_page.title');
|
|
2671
|
+
const id = assertSingleLineIdentity(rootPage.id, 'root_page.id');
|
|
2672
|
+
let out = ` backend: confluence\n`;
|
|
2673
|
+
out += ` space_key: ${yamlEscapeValue(spaceKey)}\n`;
|
|
2674
|
+
out += ` root_page:\n`;
|
|
2675
|
+
out += ` title: ${title ? yamlEscapeValue(title) : 'null'}\n`;
|
|
2676
|
+
out += ` id: ${id ? yamlEscapeValue(id) : 'null'}\n`;
|
|
2677
|
+
return out;
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
// AC1 — the legal backends, from the one STORE_BACKENDS definition the read
|
|
2681
|
+
// half enforces too. Fail loud on anything else rather than silently coercing a
|
|
2682
|
+
// typo'd/unknown backend into file-system, which would write a DIFFERENT backend
|
|
2683
|
+
// than the caller asked for with no signal at all.
|
|
2684
|
+
//
|
|
2685
|
+
// `backend === undefined` remains LEGAL and falls through to the file-system
|
|
2686
|
+
// serializer: callers still pass the legacy `{mode, path}` shape (the installer's
|
|
2687
|
+
// `layout.knowledgebase`, and writeProjectLayoutYaml's own `{mode:'same'}`
|
|
2688
|
+
// default), and an absent backend there means "never stated", exactly as it does
|
|
2689
|
+
// on the read half. Only a STATED backend outside the enum is an error.
|
|
2690
|
+
function serializeStore(store, projectRoot) {
|
|
2691
|
+
const backend = store && store.backend;
|
|
2692
|
+
if (backend !== undefined && !STORE_BACKENDS.has(backend)) {
|
|
2693
|
+
throw new Error(
|
|
2694
|
+
`Invalid store backend ${describeValue(backend)} — must be ${legalBackendsForMessage()}`
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
if (backend === 'confluence') return serializeConfluenceStore(store);
|
|
2698
|
+
return serializeFileSystemStore(store, projectRoot);
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function serializeSprintManagement(sprint, projectRoot) {
|
|
2702
|
+
let out = `sprint_management:\n`;
|
|
2703
|
+
if (sprint && sprint.mode === 'jira') {
|
|
2704
|
+
out += ` mode: jira\n`;
|
|
2705
|
+
out += ` jira_url: ${yamlEscapeValue(sprint.jiraUrl)}\n`;
|
|
2706
|
+
out += ` jira_project_key: ${yamlEscapeValue(sprint.jiraProjectKey)}\n`;
|
|
2707
|
+
return out;
|
|
2708
|
+
}
|
|
2709
|
+
// SPRINT_MODES, not STORE_MODES: sprint management's legal set includes
|
|
2710
|
+
// `jira`. The two are interchangeable for the values that actually reach here
|
|
2711
|
+
// (the `jira` early return above shadows the only member they differ on), but
|
|
2712
|
+
// the enum decides what the ERROR MESSAGE enumerates - and with STORE_MODES an
|
|
2713
|
+
// invalid sprint mode was told the legal values were `same, local, remote`,
|
|
2714
|
+
// omitting the one mode the read half accepts and the Jira backend depends on.
|
|
2715
|
+
// The equivalence was also load-bearing on that early return's position, so a
|
|
2716
|
+
// later reorder would have made sprint `jira` unwritable.
|
|
2717
|
+
const mode = assertWritableMode((sprint && sprint.mode) || 'same', SPRINT_MODES, 'sprint_management');
|
|
2718
|
+
const rawPath = (sprint && sprint.path != null) ? sprint.path : '.';
|
|
2719
|
+
const portable = toPortablePath(rawPath, projectRoot);
|
|
2720
|
+
out += ` mode: ${mode}\n`;
|
|
2721
|
+
out += ` path: ${yamlEscapeValue(portable.portable)}\n`;
|
|
2722
|
+
if (portable.isAbsolute) {
|
|
2723
|
+
out += ` # PORTABILITY: absolute path — other developers may need to reconfigure\n`;
|
|
2724
|
+
}
|
|
2725
|
+
if (mode === 'remote' && sprint && sprint.gitUrl) {
|
|
2726
|
+
out += ` gitUrl: ${yamlEscapeValue(sprint.gitUrl)}\n`;
|
|
2727
|
+
}
|
|
2728
|
+
return out;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
/**
|
|
2732
|
+
* Writes the authoritative binding spine, `_bmad-output/project-layout.yaml`
|
|
2733
|
+
* (P5-9.1). AC2/AC3 (Story 31.2): written UNCONDITIONALLY on every call — the
|
|
2734
|
+
* pre-31.2 "both concerns are same-repo => skip, and unlink any stale file"
|
|
2735
|
+
* branch is gone. An installer run must never delete this file once it
|
|
2736
|
+
* exists; AC3's regression test pins that no code path here calls
|
|
2737
|
+
* `fs.unlinkSync`.
|
|
2738
|
+
*
|
|
2739
|
+
* Accepts the installer's existing `{knowledgebase, sprintManagement}` shape
|
|
2740
|
+
* (unchanged, since Story 31.5 — not 31.2 — splits the wizard into two
|
|
2741
|
+
* independent store questions) and maps it onto the new three-record schema:
|
|
2742
|
+
* both `system_requirements_store` and `software_requirements_store` are
|
|
2743
|
+
* written from `layout.knowledgebase` (both stores -> the one answered
|
|
2744
|
+
* location), so nothing regresses before 31.5 lands. A caller that already
|
|
2745
|
+
* has two independently-resolved stores (a future 31.5/31.13 caller) may
|
|
2746
|
+
* instead pass `systemRequirementsStore` / `softwareRequirementsStore`
|
|
2747
|
+
* directly and they take precedence.
|
|
2748
|
+
*/
|
|
2749
|
+
function writeProjectLayoutYaml(layout) {
|
|
2750
|
+
const projectRoot = process.cwd();
|
|
2751
|
+
const layoutPath = path.join(projectRoot, '_bmad-output', 'project-layout.yaml');
|
|
2752
|
+
|
|
2753
|
+
// Guard the directory creation. Pre-31.2 this function ran only in
|
|
2754
|
+
// multi-repo/Jira mode; AC2 makes it run on EVERY project install, which is
|
|
2755
|
+
// what made an unwritable `_bmad-output/` reachable — and it runs in Step 3.6,
|
|
2756
|
+
// AFTER the BMAD install has already completed, so an unhandled throw here
|
|
2757
|
+
// would abort a substantially-finished install. Mirrors the existing
|
|
2758
|
+
// non-fatal policy in writeRepoLayoutConfig(). Warn and return rather than
|
|
2759
|
+
// half-writing: a truncated authoritative binding is worse than none, and the
|
|
2760
|
+
// resolver treats an absent file as "unbound" (a recoverable state that
|
|
2761
|
+
// `ma-agents bind` fixes) rather than as corruption.
|
|
2762
|
+
try {
|
|
2763
|
+
fs.mkdirSync(path.join(projectRoot, '_bmad-output'), { recursive: true });
|
|
2764
|
+
} catch (e) {
|
|
2765
|
+
console.log(
|
|
2766
|
+
chalk.yellow(
|
|
2767
|
+
` Warning: could not create _bmad-output/ for project-layout.yaml: ${e.message}\n` +
|
|
2768
|
+
` The project is left UNBOUND — fix the path above, then re-run\n` +
|
|
2769
|
+
` \`${NAME} bind\` (or \`npx ${NAME} install\`) to write the binding.`
|
|
2770
|
+
)
|
|
2771
|
+
);
|
|
2772
|
+
return { outcome: LAYOUT_WRITE_FAILED_MKDIR, error: e };
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
// R-2 - THE NON-DESTRUCTION GUARD. AC2 makes this function run on every
|
|
2776
|
+
// install and every bind, so it is the single choke point through which every
|
|
2777
|
+
// overwrite of the authoritative binding passes. Putting the guard here rather
|
|
2778
|
+
// than at each call site is deliberate: 31.3, 31.4 and 31.13 add more callers,
|
|
2779
|
+
// and a guard they have to remember is a guard that will be forgotten - which
|
|
2780
|
+
// is precisely how the first fix round ended up destroying a `backend: notion`
|
|
2781
|
+
// binding and replacing it with `file-system / path: "."`.
|
|
2782
|
+
//
|
|
2783
|
+
// Only `unbound` (absent or empty file) permits a write. Every other reason
|
|
2784
|
+
// means there are bytes here that this build does not understand, and replacing
|
|
2785
|
+
// them would be unrecoverable data loss. Refuse, fail loud, and leave the file
|
|
2786
|
+
// exactly as it is (P5-9.3).
|
|
2787
|
+
const existing = resolveProjectTopology(projectRoot);
|
|
2788
|
+
if (!existing.ok && existing.reason !== BINDING_UNBOUND) {
|
|
2789
|
+
console.log(
|
|
2790
|
+
chalk.red(
|
|
2791
|
+
` Refusing to overwrite _bmad-output/project-layout.yaml - the existing binding is ` +
|
|
2792
|
+
`${existing.reason}:\n` +
|
|
2793
|
+
` ${existing.message}\n` +
|
|
2794
|
+
` The file has been left UNCHANGED. To recover: fix it by hand, or delete ` +
|
|
2795
|
+
`_bmad-output/project-layout.yaml and run \`${NAME} bind\` to write a fresh binding\n` +
|
|
2796
|
+
` (or re-run the installer, which does the same thing plus a full re-install).`
|
|
2797
|
+
)
|
|
2798
|
+
);
|
|
2799
|
+
return { outcome: LAYOUT_WRITE_REFUSED, reason: existing.reason, error: null };
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
// ── PRESERVATION. A record the caller did not supply is carried over from the
|
|
2803
|
+
// existing file rather than being stamped with a default or with another
|
|
2804
|
+
// record's value.
|
|
2805
|
+
//
|
|
2806
|
+
// This sits HERE, at the same choke point as the non-destruction guard above
|
|
2807
|
+
// and off the SAME read of the existing file, for the reason that guard is
|
|
2808
|
+
// here: 31.3, 31.4, 31.5 and 31.13 add more callers, and a preservation step
|
|
2809
|
+
// each of them has to remember is one that will be forgotten. It composes with
|
|
2810
|
+
// the guard rather than bypassing it — the guard has already returned for every
|
|
2811
|
+
// reason except `unbound`, so by this line `existing` is either an ok topology
|
|
2812
|
+
// (something to preserve) or an absent/empty file (nothing to lose). There is
|
|
2813
|
+
// no third case in which preservation could silently do nothing.
|
|
2814
|
+
//
|
|
2815
|
+
// Order of precedence per record:
|
|
2816
|
+
// 1. an explicit per-store argument (`systemRequirementsStore` /
|
|
2817
|
+
// `softwareRequirementsStore`) — the door Story 31.2 built, and what
|
|
2818
|
+
// Story 31.5's two-question wizard will pass;
|
|
2819
|
+
// 2. the legacy single `layout.knowledgebase` answer, subject to the
|
|
2820
|
+
// divergence rule below;
|
|
2821
|
+
// 3. the corresponding record already in the file;
|
|
2822
|
+
// 4. the single-repo default.
|
|
2823
|
+
const DEFAULT_RECORD = { mode: 'same', path: '.' };
|
|
2824
|
+
const existingTopology = existing.ok ? existing.topology : null;
|
|
2825
|
+
const existingSystem = existingTopology ? existingTopology.systemRequirementsStore : null;
|
|
2826
|
+
const existingSoftware = existingTopology ? existingTopology.softwareRequirementsStore : null;
|
|
2827
|
+
const existingStoresDiverge =
|
|
2828
|
+
!!(existingSystem && existingSoftware && storesDiverge(existingSystem, existingSoftware));
|
|
2829
|
+
const legacyStore = layout.knowledgebase || null;
|
|
2830
|
+
|
|
2831
|
+
// FR264: the legacy single-valued field resolves to the SOFTWARE requirements
|
|
2832
|
+
// store. Unconditionally — divergent or not, one answered location is that
|
|
2833
|
+
// store's new location.
|
|
2834
|
+
const softwareStore =
|
|
2835
|
+
layout.softwareRequirementsStore || legacyStore || existingSoftware || DEFAULT_RECORD;
|
|
2836
|
+
|
|
2837
|
+
// The system store is where the two defects met. While the committed stores
|
|
2838
|
+
// AGREE (every project that can exist before Story 31.5), the one answered
|
|
2839
|
+
// location maps onto BOTH — today's behaviour, which NFR82 and the existing
|
|
2840
|
+
// suites pin, and which is correct because there was only ever one question.
|
|
2841
|
+
// Once they DIVERGE, that same mapping is what silently destroyed a Confluence
|
|
2842
|
+
// space key, so the answered location goes to the software store only and the
|
|
2843
|
+
// system store is preserved verbatim.
|
|
2844
|
+
let systemStore;
|
|
2845
|
+
if (layout.systemRequirementsStore) {
|
|
2846
|
+
systemStore = layout.systemRequirementsStore;
|
|
2847
|
+
} else if (legacyStore && !existingStoresDiverge) {
|
|
2848
|
+
systemStore = legacyStore;
|
|
2849
|
+
} else if (existingSystem) {
|
|
2850
|
+
systemStore = existingSystem;
|
|
2851
|
+
} else {
|
|
2852
|
+
systemStore = legacyStore || DEFAULT_RECORD;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
const sprintManagement =
|
|
2856
|
+
layout.sprintManagement
|
|
2857
|
+
|| (existingTopology ? existingTopology.sprintManagement : null)
|
|
2858
|
+
|| DEFAULT_RECORD;
|
|
2859
|
+
|
|
2860
|
+
// Non-destructive AND non-blocking, but never silent: the user asked one
|
|
2861
|
+
// question and two stores exist, so say which one was left alone and how to
|
|
2862
|
+
// change it. Refusing the write instead would be defensible but would break
|
|
2863
|
+
// `ma-agents config layout` on exactly the projects Story 31.5 creates.
|
|
2864
|
+
if (existingStoresDiverge && legacyStore && !layout.systemRequirementsStore) {
|
|
2865
|
+
console.log(chalk.yellow(
|
|
2866
|
+
` Note: this project's two knowledge stores point at DIFFERENT locations, and only\n` +
|
|
2867
|
+
` one location was supplied. Per FR264 it was applied to the SOFTWARE requirements\n` +
|
|
2868
|
+
` store. The SYSTEM requirements store was left UNCHANGED:\n` +
|
|
2869
|
+
` ${summarizeStoreIdentity(existingSystem)}\n` +
|
|
2870
|
+
` To change it, edit the system_requirements_store record in\n` +
|
|
2871
|
+
` _bmad-output/project-layout.yaml.`
|
|
2872
|
+
));
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
// ── STORY 31.7 AC3/AC4 — LEGACY PRESERVATION. NOTHING MOVED ⇒ NOTHING WRITTEN.
|
|
2876
|
+
//
|
|
2877
|
+
// THIS IS A FIX TO A STORY 31.2 IMPLEMENTATION DEFECT, NOT A RESOLUTION OF ANY
|
|
2878
|
+
// REQUIREMENTS CONFLICT. Stated precisely, because an in-source rationale that
|
|
2879
|
+
// mis-states a merged story's AC is exactly what 31.2's R-6 guard exists to
|
|
2880
|
+
// prevent.
|
|
2881
|
+
//
|
|
2882
|
+
// STORY 31.2 AC5 ALREADY FORBADE THIS REWRITE, in its own words: a pre-F26
|
|
2883
|
+
// `project-layout.yaml` is read as both stores pointing at that one location
|
|
2884
|
+
// "with no error AND NO REWRITE". 31.2 AC2 and 31.2 AC5 do not collide and
|
|
2885
|
+
// never did — AC2 removes the "both concerns are same-repo ⇒ do not write"
|
|
2886
|
+
// CONDITIONAL SCOPE branch; AC5 carves out the LEGACY case. They are
|
|
2887
|
+
// complementary halves of one story, written together.
|
|
2888
|
+
//
|
|
2889
|
+
// What shipped was AC5's read half only. `resolveProjectTopology` classifies a
|
|
2890
|
+
// legacy file correctly, but "no rewrite" was never implemented at all: this
|
|
2891
|
+
// function emits `schema_version: PROJECT_LAYOUT_SCHEMA_VERSION`
|
|
2892
|
+
// unconditionally, and AC2 makes it run on EVERY install and bind — so the
|
|
2893
|
+
// first `ma-agents install` over a pre-F26 project silently converted its
|
|
2894
|
+
// single `knowledgebase:` record into `system_requirements_store:` +
|
|
2895
|
+
// `software_requirements_store:`, with no question asked and no way back. Two
|
|
2896
|
+
// of 31.2's OWN round-trip tests pinned that behaviour as correct (one asserted
|
|
2897
|
+
// `schema_version: 2` after a write that moved nothing, with the message "the
|
|
2898
|
+
// write upgrades the schema"), which is how a green suite carried the defect
|
|
2899
|
+
// through an initial review and three fix rounds. Both were amended by this
|
|
2900
|
+
// story — see `test/project-layout-two-store-round-trip.test.js`.
|
|
2901
|
+
//
|
|
2902
|
+
// Filed, with the account of why those review rounds missed it, in
|
|
2903
|
+
// `_bmad-output/implementation-artifacts/bug-31-2-ac5-no-rewrite-half-was-never-implemented.md`.
|
|
2904
|
+
//
|
|
2905
|
+
// 31.7's own ACs are satisfied by the same branch and independently require it:
|
|
2906
|
+
// AC3 forbids any install, bind or skill activation rewriting a legacy layout
|
|
2907
|
+
// into two stores without an explicit user choice; AC4 requires that declining
|
|
2908
|
+
// the split leaves the project BYTE-IDENTICAL; and AC5 requires the notice not
|
|
2909
|
+
// to nag across "repeated installs on an un-split legacy project", which
|
|
2910
|
+
// presupposes a project can STILL BE legacy after an install. Under the shipped
|
|
2911
|
+
// behaviour it never could be — the first install always split it — so AC5 was
|
|
2912
|
+
// unsatisfiable and AC4's byte-identity was false on the very first run.
|
|
2913
|
+
//
|
|
2914
|
+
// The rule is deliberately narrow: it applies ONLY to a file that is already
|
|
2915
|
+
// legacy, and only when NOTHING WOULD MOVE. A schema v2 file is untouched by
|
|
2916
|
+
// this branch and keeps today's behaviour, including its refreshed `generated:`
|
|
2917
|
+
// date — so no Story 31.2/31.3/31.5 behaviour changes.
|
|
2918
|
+
//
|
|
2919
|
+
// WHAT "NOTHING WOULD MOVE" MEANS, and why it is not a hand-rolled comparison:
|
|
2920
|
+
// `canonicalBindingBytes` is Story 31.3's existing canonicalisation of exactly
|
|
2921
|
+
// the three location records, through the SAME serializers that write this
|
|
2922
|
+
// file. It already excludes `schema_version`, the `generated:` date, comments,
|
|
2923
|
+
// key order and line endings — the full list is in the stamp contract above,
|
|
2924
|
+
// and the `schema_version` exclusion is documented there as being for this very
|
|
2925
|
+
// case. Reusing it means "nothing moved" here is the same question, answered by
|
|
2926
|
+
// the same code, as "no drift" in Story 31.4. A private field-by-field compare
|
|
2927
|
+
// would be a second definition of store identity, free to disagree.
|
|
2928
|
+
//
|
|
2929
|
+
// Consequence, and it is the intended one: a legacy project that answers the
|
|
2930
|
+
// wizard with the SAME location for both stores is also left byte-identical,
|
|
2931
|
+
// because those answers canonicalise to the existing bytes. A user only leaves
|
|
2932
|
+
// the legacy layout by actually splitting the stores — which is what "migration
|
|
2933
|
+
// is offered, never performed" means in practice.
|
|
2934
|
+
if (existingTopology && existingTopology.schemaVersion < PROJECT_LAYOUT_SCHEMA_VERSION) {
|
|
2935
|
+
let unchanged = false;
|
|
2936
|
+
try {
|
|
2937
|
+
const candidate = {
|
|
2938
|
+
systemRequirementsStore: systemStore,
|
|
2939
|
+
softwareRequirementsStore: softwareStore,
|
|
2940
|
+
sprintManagement,
|
|
2941
|
+
};
|
|
2942
|
+
unchanged = canonicalBindingBytes(candidate, projectRoot)
|
|
2943
|
+
=== canonicalBindingBytes(existingTopology, projectRoot);
|
|
2944
|
+
} catch {
|
|
2945
|
+
// A value one of the serializers refuses to write. NOT treated as
|
|
2946
|
+
// "unchanged" — that would silently swallow a real error and skip a write
|
|
2947
|
+
// the caller asked for. Fall through, so the same throw reaches the caller
|
|
2948
|
+
// from the real write below and is reported there, exactly as it is today.
|
|
2949
|
+
unchanged = false;
|
|
715
2950
|
}
|
|
2951
|
+
if (unchanged) {
|
|
2952
|
+
// Deliberately ONE terse line, and deliberately NOT a second copy of the
|
|
2953
|
+
// offer. This line prints on every install, in place of the
|
|
2954
|
+
// `Wrote _bmad-output/project-layout.yaml` line it substitutes for, so it
|
|
2955
|
+
// must stay a statement of what happened to the file. Repeating "run
|
|
2956
|
+
// `ma-agents config layout`" here would be the nagging AC5 forbids — the
|
|
2957
|
+
// offer is made once, by showLegacySingleStoreNoticeOnce, and that is the
|
|
2958
|
+
// only place it lives.
|
|
2959
|
+
console.log(chalk.gray(
|
|
2960
|
+
` _bmad-output/project-layout.yaml left unchanged (legacy single-store layout, nothing moved)`
|
|
2961
|
+
));
|
|
2962
|
+
return { outcome: LAYOUT_WRITE_UNCHANGED_LEGACY, error: null };
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
2967
|
+
let content = `# Generated by ma-agents — do not edit manually\n`;
|
|
2968
|
+
content += `# The authoritative, committed record of where this project's knowledge\n`;
|
|
2969
|
+
content += `# lives (P5-9.1). Read by the binding resolver (ma-agents bind / status)\n`;
|
|
2970
|
+
content += `# and regenerated into _bmad/bmm/config.yaml on install and bind (31.3).\n`;
|
|
2971
|
+
content += `schema_version: ${PROJECT_LAYOUT_SCHEMA_VERSION}\n`;
|
|
2972
|
+
content += `generated: "${date}"\n`;
|
|
2973
|
+
content += `system_requirements_store:\n${serializeStore(systemStore, projectRoot)}`;
|
|
2974
|
+
content += `software_requirements_store:\n${serializeStore(softwareStore, projectRoot)}`;
|
|
2975
|
+
content += serializeSprintManagement(sprintManagement, projectRoot);
|
|
2976
|
+
|
|
2977
|
+
// Same non-fatal policy for the write itself (EACCES / EROFS / ENOSPC).
|
|
2978
|
+
//
|
|
2979
|
+
// The serializers can still throw deliberately - the closed backend enum
|
|
2980
|
+
// (serializeStore) and the confluence identity guard (assertSingleLineIdentity).
|
|
2981
|
+
// Those are caller-visible data errors and are NOT swallowed here. The first
|
|
2982
|
+
// round justified that by claiming they "fire BEFORE any bytes are written";
|
|
2983
|
+
// that was true here but false for writeConfigField, and it also missed that an
|
|
2984
|
+
// uncaught throw from this function reaches main().catch and prints a raw stack
|
|
2985
|
+
// trace AFTER the BMAD install has completed. The throws are therefore kept, and
|
|
2986
|
+
// the two INSTALL-TIME call sites catch them and degrade to the same
|
|
2987
|
+
// warn-and-leave-UNBOUND outcome as an I/O failure. Path and gitUrl values can
|
|
2988
|
+
// no longer throw at all: they are escaped, not rejected.
|
|
2989
|
+
try {
|
|
2990
|
+
fs.writeFileSync(layoutPath, content, 'utf-8');
|
|
716
2991
|
} catch (e) {
|
|
717
|
-
console.log(
|
|
2992
|
+
console.log(
|
|
2993
|
+
chalk.yellow(
|
|
2994
|
+
` Warning: could not write _bmad-output/project-layout.yaml: ${e.message}\n` +
|
|
2995
|
+
` The project is left UNBOUND — fix the path above, then re-run\n` +
|
|
2996
|
+
` \`${NAME} bind\` (or \`npx ${NAME} install\`) to write the binding.`
|
|
2997
|
+
)
|
|
2998
|
+
);
|
|
2999
|
+
return { outcome: LAYOUT_WRITE_FAILED_WRITE, error: e };
|
|
3000
|
+
}
|
|
3001
|
+
console.log(chalk.gray(` Wrote _bmad-output/project-layout.yaml (system + software requirements stores, sprint management)`));
|
|
3002
|
+
return { outcome: LAYOUT_WRITE_WROTE, error: null };
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
/**
|
|
3006
|
+
* Install-time / reconfigure-wizard helper — pre-populates the wizard from
|
|
3007
|
+
* whatever binding record already exists. Returns the legacy
|
|
3008
|
+
* `{knowledgebase, sprintManagement}` shape every existing consumer
|
|
3009
|
+
* (`collectRepoLayout`, `writeRepoLayoutConfig`, `showCurrentLayout`,
|
|
3010
|
+
* `handleConfigLayout`) already expects, collapsing the two independently-
|
|
3011
|
+
* answered stores onto the single value that shape can hold — the
|
|
3012
|
+
* **software requirements store** is the collapse target, matching FR264/
|
|
3013
|
+
* P5-9.2's choice of the software-requirements store as the legacy-field
|
|
3014
|
+
* target — and ADDITIONALLY returns the full three-record `topology`, so the
|
|
3015
|
+
* collapse is a VIEW rather than a loss.
|
|
3016
|
+
*
|
|
3017
|
+
* STRATEGY (the read defect's AC4): preserve-on-read, option (a), not
|
|
3018
|
+
* refuse-to-collapse (b). Rationale:
|
|
3019
|
+
* - The four legacy consumers are on the install and reconfigure paths. Failing
|
|
3020
|
+
* the collapse would make `ma-agents install` and `ma-agents config layout`
|
|
3021
|
+
* refuse to run on any project with two divergent stores — i.e. on exactly
|
|
3022
|
+
* the projects Story 31.5 exists to create — which is a worse outcome than
|
|
3023
|
+
* the display imprecision it would be protecting against.
|
|
3024
|
+
* - Nothing here needs to fail loud, because nothing is lost: the collapsed
|
|
3025
|
+
* value is still exactly the software store (FR264), the full topology rides
|
|
3026
|
+
* alongside it, and the destructive half of the defect is fixed at the WRITE
|
|
3027
|
+
* choke point, where preservation cannot be forgotten by a future caller.
|
|
3028
|
+
* - The one case that genuinely needs a decision — divergent stores plus a
|
|
3029
|
+
* caller supplying only the single legacy value — is decided in
|
|
3030
|
+
* writeProjectLayoutYaml: apply it to the software store, preserve the system
|
|
3031
|
+
* store verbatim, and tell the user. Non-destructive, non-blocking, informative.
|
|
3032
|
+
*
|
|
3033
|
+
* NOT the AC8 resolver — `resolveProjectTopology()` above is. This function
|
|
3034
|
+
* additionally falls back to a bare `_bmad/bmm/config.yaml` (FR265's "has
|
|
3035
|
+
* only the FR117 single knowledgebase_path" legacy path, for a project that
|
|
3036
|
+
* predates project-layout.yaml's unconditional-write behaviour entirely).
|
|
3037
|
+
* That fallback is deliberately scoped to this install-time helper: it is
|
|
3038
|
+
* never called by `resolveProjectTopology()`, which per P5-9.1 depends on
|
|
3039
|
+
* project-layout.yaml alone.
|
|
3040
|
+
*/
|
|
3041
|
+
function readExistingLayout() {
|
|
3042
|
+
const projectRoot = process.cwd();
|
|
3043
|
+
const binding = resolveProjectTopology(projectRoot);
|
|
3044
|
+
if (binding.ok) {
|
|
3045
|
+
return {
|
|
3046
|
+
knowledgebase: binding.topology.softwareRequirementsStore,
|
|
3047
|
+
sprintManagement: binding.topology.sprintManagement,
|
|
3048
|
+
// ADDITIVE, non-lossy: the full three-record topology alongside the
|
|
3049
|
+
// collapsed legacy view. Every existing consumer reads only the two legacy
|
|
3050
|
+
// keys and is unaffected; showCurrentLayout uses this to render a divergent
|
|
3051
|
+
// pair instead of silently showing one of them. `null` when the topology
|
|
3052
|
+
// came from the config.yaml fallback below, so a consumer can tell
|
|
3053
|
+
// "no topology" from "topology with divergent stores" — an absent key would
|
|
3054
|
+
// read as the latter by accident.
|
|
3055
|
+
topology: binding.topology,
|
|
3056
|
+
};
|
|
3057
|
+
}
|
|
3058
|
+
// R-2: an `invalid` / `unreadable` binding is NOT "no binding". The previous
|
|
3059
|
+
// version caught the semantic throw here and fell through to the structural
|
|
3060
|
+
// path, which is how a bound-but-invalid project came to be reported as unbound.
|
|
3061
|
+
// Report it and stop - do NOT substitute config.yaml values, because per P5-9.1
|
|
3062
|
+
// topology comes from project-layout.yaml and nowhere else; quietly answering
|
|
3063
|
+
// from config.yaml would be the same conflation one layer down. The wizard
|
|
3064
|
+
// simply gets no pre-population, and writeProjectLayoutYaml refuses to
|
|
3065
|
+
// overwrite, so nothing is lost.
|
|
3066
|
+
if (binding.reason !== BINDING_UNBOUND) {
|
|
3067
|
+
console.log(chalk.yellow(
|
|
3068
|
+
` Warning: the committed binding in _bmad-output/project-layout.yaml is ` +
|
|
3069
|
+
`${binding.reason} and was NOT used:\n ${binding.message}`
|
|
3070
|
+
));
|
|
3071
|
+
return null;
|
|
718
3072
|
}
|
|
719
3073
|
|
|
720
3074
|
// Fallback: check config.yaml for path fields
|
|
@@ -737,6 +3091,10 @@ function readExistingLayout() {
|
|
|
737
3091
|
sprintManagement: spPath && spPath !== '.'
|
|
738
3092
|
? { mode: 'local', path: spPath }
|
|
739
3093
|
: { mode: 'same', path: '.' },
|
|
3094
|
+
// No project-layout.yaml exists on this path, so there is no committed
|
|
3095
|
+
// three-record topology to report. Stated explicitly rather than left
|
|
3096
|
+
// absent — see the note on the ok branch above.
|
|
3097
|
+
topology: null,
|
|
740
3098
|
};
|
|
741
3099
|
}
|
|
742
3100
|
}
|
|
@@ -770,6 +3128,30 @@ function ciCloneIfNeeded(destPath, gitUrl, clonedRepos) {
|
|
|
770
3128
|
}
|
|
771
3129
|
}
|
|
772
3130
|
|
|
3131
|
+
/**
|
|
3132
|
+
* The repository-layout wizard.
|
|
3133
|
+
*
|
|
3134
|
+
* RETURN SHAPE (Story 31.5). Always `{knowledgebase, sprintManagement}` — the
|
|
3135
|
+
* legacy pair every pre-31.5 consumer reads. The INTERACTIVE branch additionally
|
|
3136
|
+
* returns `systemRequirementsStore` and `softwareRequirementsStore`, the two
|
|
3137
|
+
* independently-answered records, and `knowledgebase` is then a VIEW of the
|
|
3138
|
+
* software store (FR264). `writeProjectLayoutYaml` prefers the explicit pair
|
|
3139
|
+
* when present, so two divergent stores are written as answered.
|
|
3140
|
+
*
|
|
3141
|
+
* THE `--yes` BRANCH IS DELIBERATELY UNCHANGED, and does NOT emit the explicit
|
|
3142
|
+
* pair. AC9 requires `--yes` to default both stores to `file-system` / current
|
|
3143
|
+
* repository with no prompts, and P2-10 makes multi-repo interactive-only
|
|
3144
|
+
* configuration — so there is no second store to answer here, only one legacy
|
|
3145
|
+
* location (from `MA_KNOWLEDGEBASE_PATH`, from an existing binding, or the
|
|
3146
|
+
* default). What that ONE location means for TWO stores is already decided, once,
|
|
3147
|
+
* at the write choke point: FR264 applies it to the software store, and a
|
|
3148
|
+
* divergent system store is preserved verbatim. Re-deciding it here would be a
|
|
3149
|
+
* second definition of that rule, in the branch with the least observability —
|
|
3150
|
+
* which is how `install --yes` came to destroy a committed Confluence store in
|
|
3151
|
+
* the first place. Leaving this branch byte-for-byte as it was is also what makes
|
|
3152
|
+
* the NFR82 claim checkable: the non-interactive path is not merely believed
|
|
3153
|
+
* unchanged, it is unchanged.
|
|
3154
|
+
*/
|
|
773
3155
|
async function collectRepoLayout(flags, existingLayout = null) {
|
|
774
3156
|
// CI/CD mode: env vars > existing layout > defaults
|
|
775
3157
|
if (flags.yes) {
|
|
@@ -822,50 +3204,466 @@ async function collectRepoLayout(flags, existingLayout = null) {
|
|
|
822
3204
|
};
|
|
823
3205
|
}
|
|
824
3206
|
|
|
825
|
-
|
|
826
|
-
|
|
3207
|
+
// ── INTERACTIVE. Story 31.5: THREE concerns, not two. The two knowledge
|
|
3208
|
+
// stores are asked independently — each gets its own backend question and its
|
|
3209
|
+
// own location question, and neither answer is read while resolving the other
|
|
3210
|
+
// (AC2/FR257). The sprint-management question is byte-for-byte the one FR112
|
|
3211
|
+
// has always asked.
|
|
3212
|
+
const layout = {
|
|
3213
|
+
knowledgebase: null,
|
|
3214
|
+
systemRequirementsStore: null,
|
|
3215
|
+
softwareRequirementsStore: null,
|
|
3216
|
+
sprintManagement: null,
|
|
3217
|
+
};
|
|
827
3218
|
|
|
828
|
-
for (const concern of
|
|
829
|
-
const
|
|
830
|
-
const modeChoices = [
|
|
831
|
-
{ title: 'Current repository (default)', value: 'same' },
|
|
832
|
-
{ title: 'Local path', value: 'local' },
|
|
833
|
-
{ title: 'Remote git repository', value: 'remote' },
|
|
834
|
-
...(concern === 'sprintManagement' ? [{ title: 'Jira', value: 'jira' }] : []),
|
|
835
|
-
];
|
|
836
|
-
// Pre-select existing mode if available
|
|
837
|
-
const initialIndex = existingMode ? modeChoices.findIndex(c => c.value === existingMode) : 0;
|
|
3219
|
+
for (const concern of REPO_LAYOUT_CONCERNS) {
|
|
3220
|
+
const existingRecord = existingRecordForConcern(existingLayout, concern);
|
|
838
3221
|
|
|
839
|
-
|
|
3222
|
+
if (concern.kind === 'sprint') {
|
|
3223
|
+
layout[concern.key] = await collectSprintManagementLocation(concern, existingRecord);
|
|
3224
|
+
continue;
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
// NON-DESTRUCTION, not a silent repoint. A project may already carry a
|
|
3228
|
+
// committed store on a backend this build's wizard cannot offer yet.
|
|
3229
|
+
// Asking anyway would offer only the backends that DO have a flow, and the
|
|
3230
|
+
// first Enter would replace a valid store with `file-system / same / "."` —
|
|
3231
|
+
// the exact destructive default that a wizard answer indistinguishable from
|
|
3232
|
+
// a real one already caused once in this epic. Preserve the record verbatim
|
|
3233
|
+
// and say so.
|
|
3234
|
+
//
|
|
3235
|
+
// Story 31.8: `confluence` now HAS a flow, so this block no longer fires for
|
|
3236
|
+
// any member of today's enum — every committed store is reconfigurable, and
|
|
3237
|
+
// `ma-agents config layout` over a Confluence project asks the real
|
|
3238
|
+
// questions instead of declining. That is proved from the other side: the
|
|
3239
|
+
// reconfiguration test in
|
|
3240
|
+
// `test/project-layout-confluence-configuration.test.js` asserts the wizard
|
|
3241
|
+
// ASKS (so this branch was not taken) and that accepting every
|
|
3242
|
+
// pre-population rewrites the committed file byte-identically (so agency is
|
|
3243
|
+
// preserved without an extra "keep it unchanged" question, which would have
|
|
3244
|
+
// meant a non-enum value in a closed-enum prompt — the shape the readiness
|
|
3245
|
+
// review already rejected for the configuration-pending state).
|
|
3246
|
+
//
|
|
3247
|
+
// The guard is KEPT rather than deleted: it is the contract that makes
|
|
3248
|
+
// `collect: null` a safe way to carry a future backend in the enum ahead of
|
|
3249
|
+
// its flow, and deleting it would make the next such member destructive on
|
|
3250
|
+
// the first Enter.
|
|
3251
|
+
const existingBackend = existingRecord ? backendOfStore(existingRecord) : null;
|
|
3252
|
+
if (existingBackend && !offerableStoreBackends().includes(existingBackend)) {
|
|
3253
|
+
console.log(chalk.yellow(
|
|
3254
|
+
` Your ${concern.label} is ${existingBackend}-backed. This installer cannot yet\n` +
|
|
3255
|
+
` reconfigure a ${existingBackend} store, so it has been left UNCHANGED:\n` +
|
|
3256
|
+
` ${summarizeStoreIdentity(existingRecord)}\n` +
|
|
3257
|
+
` To change it, edit _bmad-output/project-layout.yaml.`
|
|
3258
|
+
));
|
|
3259
|
+
layout[concern.key] = existingRecord;
|
|
3260
|
+
continue;
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
// AC3 — the BACKEND first, then that backend's location. Choices are derived
|
|
3264
|
+
// from STORE_BACKEND_WIZARD, so no value outside the closed enum can be
|
|
3265
|
+
// offered and a member with no wizard flow is not offered at all (AC5).
|
|
3266
|
+
//
|
|
3267
|
+
// MUTANT M22 IS NO LONGER EQUIVALENT — Story 31.8 made `confluence`
|
|
3268
|
+
// offerable, so `offerableStoreBackends()` now returns TWO members and the
|
|
3269
|
+
// pre-selection below is load-bearing. Replacing it with the literal `0`
|
|
3270
|
+
// pre-selects `file-system` for a project whose committed store is
|
|
3271
|
+
// Confluence, and one Enter repoints it — B-1 all over again. The covering
|
|
3272
|
+
// test is `test/project-layout-confluence-configuration.test.js`, section
|
|
3273
|
+
// "M22", which pins that an existing confluence store pre-selects the
|
|
3274
|
+
// confluence CHOICE (asserted by value through `choices[initial]`, not by
|
|
3275
|
+
// index, so a reordering of the enum cannot make the assertion pass for the
|
|
3276
|
+
// wrong backend) and that accepting the pre-selection leaves the committed
|
|
3277
|
+
// record byte-identical.
|
|
3278
|
+
const backendChoices = backendChoicesForStore();
|
|
3279
|
+
const initialBackend = existingBackend
|
|
3280
|
+
? backendChoices.findIndex((c) => c.value === existingBackend)
|
|
3281
|
+
: 0;
|
|
3282
|
+
const { backend } = await prompts({
|
|
840
3283
|
type: 'select',
|
|
841
|
-
name: '
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
3284
|
+
name: 'backend',
|
|
3285
|
+
// AC7 — the store's contents, stated in the question itself.
|
|
3286
|
+
message: `Which backend holds your ${concern.label} — ${concern.contents}?`,
|
|
3287
|
+
choices: backendChoices,
|
|
3288
|
+
initial: initialBackend >= 0 ? initialBackend : 0,
|
|
845
3289
|
});
|
|
846
3290
|
|
|
847
|
-
if (!
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
3291
|
+
if (!backend) process.exit(0);
|
|
3292
|
+
|
|
3293
|
+
const entry = STORE_BACKEND_WIZARD[backend];
|
|
3294
|
+
if (!entry || typeof entry.collect !== 'function') {
|
|
3295
|
+
// Unreachable while the choices come from the table (the value came OUT of
|
|
3296
|
+
// it), and deliberately a throw rather than a fallback to the file-system
|
|
3297
|
+
// flow: recording a file-system store for a backend the user chose is the
|
|
3298
|
+
// silent-repoint defect one layer in.
|
|
3299
|
+
throw new Error(
|
|
3300
|
+
`No wizard flow for knowledge-store backend ${describeValue(backend)} — ` +
|
|
3301
|
+
`STORE_BACKEND_WIZARD must name every member of STORE_BACKENDS ` +
|
|
3302
|
+
`(${legalBackendsForMessage()}). Refusing to guess a location shape.`
|
|
3303
|
+
);
|
|
857
3304
|
}
|
|
3305
|
+
|
|
3306
|
+
// The ANSWERED backend is passed to the flow, so the record it returns names
|
|
3307
|
+
// the backend it was chosen for rather than re-deriving one.
|
|
3308
|
+
layout[concern.key] = await entry.collect(concern, existingRecord, backend);
|
|
858
3309
|
}
|
|
859
3310
|
|
|
860
|
-
//
|
|
861
|
-
|
|
862
|
-
|
|
3311
|
+
// FR264 / P5-9.2 — the legacy single-valued view resolves to the SOFTWARE
|
|
3312
|
+
// requirements store. Every pre-31.5 consumer of this function's return value
|
|
3313
|
+
// (`generateRepoLayoutSection`, `writeRepoLayoutConfig`, and
|
|
3314
|
+
// `writeProjectLayoutYaml`'s legacy branch) reads `knowledgebase`, so it stays
|
|
3315
|
+
// — as a VIEW of a store that was independently answered, never as a third
|
|
3316
|
+
// source of truth. `writeProjectLayoutYaml` prefers the two explicit records
|
|
3317
|
+
// above it, so the view is never what decides what is written.
|
|
3318
|
+
layout.knowledgebase = layout.softwareRequirementsStore;
|
|
3319
|
+
|
|
3320
|
+
// Today's advisory, unchanged in wording and still keyed on the legacy pair
|
|
3321
|
+
// (software store vs sprint management). Guarded on the backend because a
|
|
3322
|
+
// non-file-system store has neither `mode` nor `path`, and `undefined ===
|
|
3323
|
+
// undefined` would otherwise make two unconfigured fields read as "the same
|
|
3324
|
+
// external repository".
|
|
3325
|
+
const software = layout.softwareRequirementsStore;
|
|
3326
|
+
const sprint = layout.sprintManagement;
|
|
3327
|
+
if (backendOfStore(software) === DEFAULT_STORE_BACKEND
|
|
3328
|
+
&& software.mode !== 'same' && sprint.mode !== 'same'
|
|
3329
|
+
&& software.path === sprint.path) {
|
|
863
3330
|
console.log(chalk.cyan(' Both concerns point to same external repository'));
|
|
864
3331
|
}
|
|
865
3332
|
|
|
866
3333
|
return layout;
|
|
867
3334
|
}
|
|
868
3335
|
|
|
3336
|
+
/**
|
|
3337
|
+
* Which already-committed record pre-populates this concern's questions.
|
|
3338
|
+
*
|
|
3339
|
+
* The full three-record `topology` is preferred when `readExistingLayout()`
|
|
3340
|
+
* supplied one, so a project whose two stores DIVERGE pre-populates each question
|
|
3341
|
+
* from its OWN store rather than from the collapsed legacy view — which would
|
|
3342
|
+
* show the software store's location as the system store's answer.
|
|
3343
|
+
*
|
|
3344
|
+
* The fallback is the legacy single `knowledgebase` value, which is what every
|
|
3345
|
+
* project created before Story 31.5 has: one answered location standing for both
|
|
3346
|
+
* stores. That is the same collapse `topologyFromLegacyLayout` applies, and it is
|
|
3347
|
+
* exact while the two stores agree.
|
|
3348
|
+
*/
|
|
3349
|
+
function existingRecordForConcern(existingLayout, concern) {
|
|
3350
|
+
if (!existingLayout) return null;
|
|
3351
|
+
const topology = existingLayout.topology || null;
|
|
3352
|
+
if (topology && topology[concern.key]) return topology[concern.key];
|
|
3353
|
+
if (concern.kind === 'sprint') return existingLayout.sprintManagement || null;
|
|
3354
|
+
return existingLayout.knowledgebase || null;
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
/**
|
|
3358
|
+
* The `file-system` backend's location flow (AC4). The FR113–FR116 semantics are
|
|
3359
|
+
* REUSED, not reimplemented: the mode choices come from `modeChoicesFor` (so the
|
|
3360
|
+
* store concerns offer STORE_MODES and never `jira`), and the local-path and
|
|
3361
|
+
* remote-clone flows are the existing `collectLocalPath` / `collectRemotePath`,
|
|
3362
|
+
* with their existing-directory confirmation, path validation and clone
|
|
3363
|
+
* behaviour, called once per store with that store's label.
|
|
3364
|
+
*
|
|
3365
|
+
* `backend` is stated explicitly on the returned record. It is redundant on the
|
|
3366
|
+
* write half — `serializeFileSystemStore` emits `backend: file-system`
|
|
3367
|
+
* unconditionally, so the committed bytes are identical either way (NFR82) — but
|
|
3368
|
+
* a record that names its own backend cannot be mistaken for one whose backend
|
|
3369
|
+
* was never asked about.
|
|
3370
|
+
*/
|
|
3371
|
+
async function collectFileSystemStoreLocation(concern, existingRecord, backend) {
|
|
3372
|
+
// Story 31.5 fix round. `backend` is the ANSWERED backend, threaded in
|
|
3373
|
+
// from the question, not a fourth spelling of the default. Writing
|
|
3374
|
+
// DEFAULT_STORE_BACKEND into the record was correct only by coincidence — the
|
|
3375
|
+
// table happens to wire this flow to the `file-system` key — and would have
|
|
3376
|
+
// recorded a backend the user did not choose the moment a second key shared
|
|
3377
|
+
// the flow. Fail loud rather than defaulting: a `backend`-less record is
|
|
3378
|
+
// exactly the "never asked about" shape stating it explicitly exists to rule out.
|
|
3379
|
+
if (!backend) {
|
|
3380
|
+
throw new Error(
|
|
3381
|
+
`collectFileSystemStoreLocation was called with no answered backend for ` +
|
|
3382
|
+
`${concern.label}. A location flow must record the backend it was chosen FOR; ` +
|
|
3383
|
+
`refusing to substitute ${describeValue(DEFAULT_STORE_BACKEND)}.`
|
|
3384
|
+
);
|
|
3385
|
+
}
|
|
3386
|
+
// MUTANT M21 — `modeChoicesFor(concern.key)` replaced by a constant.
|
|
3387
|
+
//
|
|
3388
|
+
// Story 31.8 was expected to resolve this and DID NOT, so the status is
|
|
3389
|
+
// recorded honestly rather than quietly closed. `modeChoicesFor` partitions
|
|
3390
|
+
// its argument exactly once — `'sprintManagement'` versus everything else — and
|
|
3391
|
+
// Story 31.8 deliberately gave `confluence` NO mode set (a confluence store has
|
|
3392
|
+
// no `mode` at all; its identity is a space plus a root page, and it never
|
|
3393
|
+
// reaches this function). So:
|
|
3394
|
+
//
|
|
3395
|
+
// - substituting either STORE concern's key here remains EQUIVALENT, and no
|
|
3396
|
+
// test can distinguish it, because both map to STORE_MODES. This is a
|
|
3397
|
+
// property of `modeChoicesFor`'s partition, not a gap in coverage, and it
|
|
3398
|
+
// will stay equivalent until some store concern is offered a different
|
|
3399
|
+
// mode set;
|
|
3400
|
+
// - substituting `'sprintManagement'` is NOT equivalent — it would silently
|
|
3401
|
+
// add `jira` to a knowledge store's mode choices — and IS covered, on BOTH
|
|
3402
|
+
// store concerns, by `test/project-layout-confluence-configuration.test.js`
|
|
3403
|
+
// section "M21" and by project-layout-enum-single-source.test.js.
|
|
3404
|
+
//
|
|
3405
|
+
// The reverse substitution in `collectSprintManagementLocation` (a store key
|
|
3406
|
+
// there would withdraw `jira`) is likewise pinned in
|
|
3407
|
+
// project-layout-enum-single-source.test.js.
|
|
3408
|
+
const modeChoices = modeChoicesFor(concern.key);
|
|
3409
|
+
const existingMode = existingRecord ? existingRecord.mode : null;
|
|
3410
|
+
const initialIndex = existingMode ? modeChoices.findIndex((c) => c.value === existingMode) : 0;
|
|
3411
|
+
|
|
3412
|
+
const { mode } = await prompts({
|
|
3413
|
+
type: 'select',
|
|
3414
|
+
name: 'mode',
|
|
3415
|
+
message: `Where is your ${concern.label} managed?`,
|
|
3416
|
+
choices: modeChoices,
|
|
3417
|
+
initial: initialIndex >= 0 ? initialIndex : 0,
|
|
3418
|
+
});
|
|
3419
|
+
|
|
3420
|
+
if (!mode) process.exit(0);
|
|
3421
|
+
|
|
3422
|
+
if (mode === 'same') return { backend, mode: 'same', path: '.' };
|
|
3423
|
+
if (mode === 'local') return { backend, ...(await collectLocalPath(concern.label)) };
|
|
3424
|
+
if (mode === 'remote') return { backend, ...(await collectRemotePath(concern.label)) };
|
|
3425
|
+
|
|
3426
|
+
// Unreachable: `mode` came out of `modeChoicesFor`, i.e. out of STORE_MODES.
|
|
3427
|
+
// Left as a throw rather than a `null` return, because returning null here is
|
|
3428
|
+
// what would put an unwritable record into the layout and surface as a
|
|
3429
|
+
// TypeError somewhere else entirely.
|
|
3430
|
+
throw new Error(
|
|
3431
|
+
`Unhandled knowledge-store mode ${describeValue(mode)} for ${concern.label} — ` +
|
|
3432
|
+
`expected one of ${[...STORE_MODES].join(', ')}.`
|
|
3433
|
+
);
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
// ─── Story 31.8 — the `confluence` backend's configuration flow ─────────────
|
|
3437
|
+
//
|
|
3438
|
+
// FR259 (which space) + FR260 (which root page, blank ⇒ generated and NAMED to
|
|
3439
|
+
// the user). Two text questions and nothing else. In particular:
|
|
3440
|
+
//
|
|
3441
|
+
// - NO credential question. Not a token, not a username, not a base URL that
|
|
3442
|
+
// could carry one, not a "stay signed in" flag. Authentication against
|
|
3443
|
+
// Confluence is entirely the MCP server's responsibility (NFR79, P5-9.6),
|
|
3444
|
+
// and ma-agents writes nothing about it to disk. The absence is the
|
|
3445
|
+
// requirement, so it is asserted as an absence — see
|
|
3446
|
+
// `test/project-layout-confluence-configuration.test.js`.
|
|
3447
|
+
// - NO reachability check. Collecting a space key and a page name is OFFLINE
|
|
3448
|
+
// configuration (NFR80): nothing here resolves a host, opens a socket, or
|
|
3449
|
+
// shells out. Whether the space exists is a RUNTIME question, answered by
|
|
3450
|
+
// Story 31.9's MCP routing; whether the root page exists is Story 31.10's,
|
|
3451
|
+
// which creates it. An install must not require network access.
|
|
3452
|
+
// - NO local mirror, cache or sync of any kind. FR266 is retired at P5-9.6.
|
|
3453
|
+
|
|
3454
|
+
/**
|
|
3455
|
+
* The name a blank root-page answer resolves to: `{project_name}` (AC3).
|
|
3456
|
+
*
|
|
3457
|
+
* TWO sources, in this order, and the second is not a permissive degradation of
|
|
3458
|
+
* the first — it is the SAME RULE, applied one layer earlier:
|
|
3459
|
+
*
|
|
3460
|
+
* 1. `project_name` from `_bmad/bmm/config.yaml`, read through
|
|
3461
|
+
* `bmad.readCanonicalBmadConfig` (the only sanctioned read path for
|
|
3462
|
+
* BMAD-canonical values in this codebase, so this is not a second spelling
|
|
3463
|
+
* of where that value lives). This is literally the `{project_name}` the
|
|
3464
|
+
* acceptance criteria and Story 31.10 name.
|
|
3465
|
+
* 2. The basename of the project root. On a FRESH install this wizard runs at
|
|
3466
|
+
* install Step 3.3, ABOVE the BMAD install at Step 3.5, so config.yaml does
|
|
3467
|
+
* not exist yet and source 1 is simply not available. Basename is what
|
|
3468
|
+
* bmad-method itself seeds `project_name` with
|
|
3469
|
+
* (`node_modules/bmad-method/tools/installer/ui.js` —
|
|
3470
|
+
* `project_name: path.basename(directory)`), so the two sources agree by
|
|
3471
|
+
* construction on every project whose `project_name` has not been hand-edited.
|
|
3472
|
+
*
|
|
3473
|
+
* Returns `null` — never `''` and never a fabricated placeholder — when neither
|
|
3474
|
+
* source yields a name (a project root with no basename, e.g. a filesystem
|
|
3475
|
+
* root). The caller then REQUIRES an explicit answer rather than silently
|
|
3476
|
+
* generating a page named after nothing, which is precisely the "user is left
|
|
3477
|
+
* guessing where the artifacts went" failure AC3 exists to prevent.
|
|
3478
|
+
*/
|
|
3479
|
+
function generatedRootPageTitle(projectRoot) {
|
|
3480
|
+
const root = path.resolve(projectRoot || process.cwd());
|
|
3481
|
+
let declared = '';
|
|
3482
|
+
try {
|
|
3483
|
+
const config = bmad.readCanonicalBmadConfig(root);
|
|
3484
|
+
if (config && typeof config.project_name === 'string') declared = config.project_name.trim();
|
|
3485
|
+
} catch (e) {
|
|
3486
|
+
// An unreadable/odd config.yaml is not a reason to abort configuration; the
|
|
3487
|
+
// basename below is a complete answer on its own. Nothing is guessed here —
|
|
3488
|
+
// the fallback is a DIFFERENT source, not a relaxed reading of this one.
|
|
3489
|
+
declared = '';
|
|
3490
|
+
}
|
|
3491
|
+
if (declared) return declared;
|
|
3492
|
+
const base = path.basename(root).trim();
|
|
3493
|
+
return base || null;
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
/**
|
|
3497
|
+
* The prompt-time half of the identity policy — the SAME policy the serializer
|
|
3498
|
+
* enforces, called through the same function (`assertSingleLineIdentity`) rather
|
|
3499
|
+
* than restated here. A second spelling of "what is a legal Confluence identity"
|
|
3500
|
+
* is the read/write-disagreement shape this epic has already fixed three times.
|
|
3501
|
+
*
|
|
3502
|
+
* Returns `true` or a message, which is the contract `prompts` expects. Telling
|
|
3503
|
+
* the user at the moment they type is strictly better than throwing at write
|
|
3504
|
+
* time, but it is the SAME rule, so a value that passes here cannot be one the
|
|
3505
|
+
* writer refuses (and vice versa).
|
|
3506
|
+
*/
|
|
3507
|
+
function validateConfluenceIdentity(value, fieldName, opts) {
|
|
3508
|
+
const required = !!(opts && opts.required);
|
|
3509
|
+
const s = value == null ? '' : String(value);
|
|
3510
|
+
if (!s.trim()) {
|
|
3511
|
+
if (!required) return true;
|
|
3512
|
+
return fieldName === 'space_key'
|
|
3513
|
+
? 'A Confluence space key is required — this store has no location without one.'
|
|
3514
|
+
: 'A root page name is required here: this project has no name to generate one from.';
|
|
3515
|
+
}
|
|
3516
|
+
try {
|
|
3517
|
+
assertSingleLineIdentity(s, fieldName);
|
|
3518
|
+
} catch (e) {
|
|
3519
|
+
return e.message;
|
|
3520
|
+
}
|
|
3521
|
+
return true;
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
/**
|
|
3525
|
+
* AC1–AC3. Asks the space (FR259), then the root page (FR260), and returns the
|
|
3526
|
+
* `{backend, spaceKey, rootPage:{title, id}}` record Story 31.2's schema sealed.
|
|
3527
|
+
*
|
|
3528
|
+
* OQ-4 (root-page identity: human-readable title vs opaque page id) resolves to
|
|
3529
|
+
* TITLE AT CONFIGURATION TIME, `id: null`. Not a preference — AC7 forces it:
|
|
3530
|
+
* turning a title into a page id requires asking Confluence, and this story must
|
|
3531
|
+
* complete with no network access. Story 31.2's schema reserves BOTH fields, so
|
|
3532
|
+
* this populates the reserved object without reopening it, and Story 31.10 — the
|
|
3533
|
+
* layer that actually talks to the MCP — is where an `id` can be learned and
|
|
3534
|
+
* filled in.
|
|
3535
|
+
*
|
|
3536
|
+
* CONSEQUENCE, handled here: on RECONFIGURATION a previously-learned `id` is
|
|
3537
|
+
* carried forward ONLY while the title is unchanged. Change the title and the id
|
|
3538
|
+
* is dropped, because a `{title, id}` pair naming two different pages is worse
|
|
3539
|
+
* than a pair with one field — it is a record that looks resolved and points
|
|
3540
|
+
* somewhere the user did not choose.
|
|
3541
|
+
*/
|
|
3542
|
+
async function collectConfluenceStoreLocation(concern, existingRecord, backend) {
|
|
3543
|
+
// Same fail-loud contract as the file-system flow: a location flow records the
|
|
3544
|
+
// backend it was chosen FOR. Substituting a default here would record a
|
|
3545
|
+
// backend the user did not answer.
|
|
3546
|
+
if (!backend) {
|
|
3547
|
+
throw new Error(
|
|
3548
|
+
`collectConfluenceStoreLocation was called with no answered backend for ` +
|
|
3549
|
+
`${concern.label}. A location flow must record the backend it was chosen FOR; ` +
|
|
3550
|
+
`refusing to substitute ${describeValue(DEFAULT_STORE_BACKEND)}.`
|
|
3551
|
+
);
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
// Pre-population comes from the existing record ONLY when that record is
|
|
3555
|
+
// itself a confluence store. A file-system record has no space key, and
|
|
3556
|
+
// reading `.spaceKey` off it would pre-fill `undefined`.
|
|
3557
|
+
const existing = (existingRecord && backendOfStore(existingRecord) === 'confluence')
|
|
3558
|
+
? existingRecord : null;
|
|
3559
|
+
const existingRootPage = (existing && existing.rootPage) || {};
|
|
3560
|
+
|
|
3561
|
+
// AC1 / FR259.
|
|
3562
|
+
const spaceAnswer = await prompts({
|
|
3563
|
+
type: 'text',
|
|
3564
|
+
name: 'spaceKey',
|
|
3565
|
+
message: `Which Confluence space holds your ${concern.label}?`,
|
|
3566
|
+
initial: (existing && existing.spaceKey) || '',
|
|
3567
|
+
validate: (value) => validateConfluenceIdentity(value, 'space_key', { required: true }),
|
|
3568
|
+
});
|
|
3569
|
+
// `undefined` is a CANCELLED prompt; `''` is a real (rejected) answer. The
|
|
3570
|
+
// select-based flows can use `if (!x)` for both because no legal choice is
|
|
3571
|
+
// falsy; a free-text answer cannot, and collapsing the two would turn an empty
|
|
3572
|
+
// string into a silent `process.exit(0)` mid-install.
|
|
3573
|
+
if (spaceAnswer.spaceKey === undefined) process.exit(0);
|
|
3574
|
+
const spaceKey = String(spaceAnswer.spaceKey).trim();
|
|
3575
|
+
if (!spaceKey) {
|
|
3576
|
+
// Unreachable interactively — `validate` above refuses to submit a blank
|
|
3577
|
+
// answer. Kept as a throw rather than a fallback because the fallback shape
|
|
3578
|
+
// ("record it anyway, empty") is a store that resolves nowhere, which is the
|
|
3579
|
+
// permissive degradation P5-9 exists to prevent.
|
|
3580
|
+
throw new Error(
|
|
3581
|
+
`No Confluence space was given for ${concern.label}. A confluence store's space key ` +
|
|
3582
|
+
`is its location; refusing to record a store with none.`
|
|
3583
|
+
);
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
// AC2 / FR260, and AC3's generated name — computed BEFORE the question so the
|
|
3587
|
+
// question can NAME it. "Leave it blank and something will be generated" is
|
|
3588
|
+
// the shape that leaves a user guessing; naming it up front and again after
|
|
3589
|
+
// the answer is what AC3 asks for.
|
|
3590
|
+
const generated = generatedRootPageTitle(process.cwd());
|
|
3591
|
+
const existingTitle = String(existingRootPage.title || '').trim();
|
|
3592
|
+
const rootAnswer = await prompts({
|
|
3593
|
+
type: 'text',
|
|
3594
|
+
name: 'rootPageTitle',
|
|
3595
|
+
message: `Which Confluence page should your ${concern.label} artifacts be created under?`
|
|
3596
|
+
+ (generated ? ` (leave blank to use a page named "${generated}", after your project)` : ''),
|
|
3597
|
+
initial: existingTitle,
|
|
3598
|
+
validate: (value) => validateConfluenceIdentity(value, 'root_page.title', { required: !generated }),
|
|
3599
|
+
});
|
|
3600
|
+
if (rootAnswer.rootPageTitle === undefined) process.exit(0);
|
|
3601
|
+
const answeredTitle = String(rootAnswer.rootPageTitle == null ? '' : rootAnswer.rootPageTitle).trim();
|
|
3602
|
+
|
|
3603
|
+
let title;
|
|
3604
|
+
if (answeredTitle) {
|
|
3605
|
+
title = answeredTitle;
|
|
3606
|
+
} else {
|
|
3607
|
+
if (!generated) {
|
|
3608
|
+
// Unreachable interactively (validate makes the answer required in exactly
|
|
3609
|
+
// this case). Fail loud rather than write `title: null` and leave 31.10 to
|
|
3610
|
+
// invent a name at the moment it can no longer ask.
|
|
3611
|
+
throw new Error(
|
|
3612
|
+
`No root page was given for ${concern.label}, and no project name could be derived ` +
|
|
3613
|
+
`to generate one from. Re-run and name the page explicitly.`
|
|
3614
|
+
);
|
|
3615
|
+
}
|
|
3616
|
+
title = generated;
|
|
3617
|
+
// AC3 — the user is TOLD the name, in the same words the record now carries,
|
|
3618
|
+
// so the announcement and the committed file cannot disagree. The creation
|
|
3619
|
+
// itself is Story 31.10's MCP write, so this says "will be created", not
|
|
3620
|
+
// "was created".
|
|
3621
|
+
console.log(chalk.cyan(
|
|
3622
|
+
` No root page given for your ${concern.label}. ma-agents will use a page named\n` +
|
|
3623
|
+
` "${title}" (named after your project) in the "${spaceKey}" space, and create it\n` +
|
|
3624
|
+
` there the first time artifacts are published.`
|
|
3625
|
+
));
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
// The id survives only while it still identifies the answered page — see the
|
|
3629
|
+
// OQ-4 note above.
|
|
3630
|
+
const carriedId = String(existingRootPage.id || '').trim();
|
|
3631
|
+
const id = (carriedId && existingTitle && existingTitle === title) ? carriedId : null;
|
|
3632
|
+
|
|
3633
|
+
return { backend, spaceKey, rootPage: { title, id } };
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
/**
|
|
3637
|
+
* FR112's sprint-management question, unchanged (AC1). Extracted from the old
|
|
3638
|
+
* two-concern loop verbatim — same message, same choice list (SPRINT_MODES, so
|
|
3639
|
+
* `jira` is still offered), same pre-selection, same four branches.
|
|
3640
|
+
*/
|
|
3641
|
+
async function collectSprintManagementLocation(concern, existingRecord) {
|
|
3642
|
+
const modeChoices = modeChoicesFor(concern.key);
|
|
3643
|
+
const existingMode = existingRecord ? existingRecord.mode : null;
|
|
3644
|
+
const initialIndex = existingMode ? modeChoices.findIndex((c) => c.value === existingMode) : 0;
|
|
3645
|
+
|
|
3646
|
+
const { mode } = await prompts({
|
|
3647
|
+
type: 'select',
|
|
3648
|
+
name: 'mode',
|
|
3649
|
+
message: `Where is your ${concern.label} managed?`,
|
|
3650
|
+
choices: modeChoices,
|
|
3651
|
+
initial: initialIndex >= 0 ? initialIndex : 0,
|
|
3652
|
+
});
|
|
3653
|
+
|
|
3654
|
+
if (!mode) process.exit(0);
|
|
3655
|
+
|
|
3656
|
+
if (mode === 'same') return { mode: 'same', path: '.' };
|
|
3657
|
+
if (mode === 'local') return await collectLocalPath(concern.label);
|
|
3658
|
+
if (mode === 'remote') return await collectRemotePath(concern.label);
|
|
3659
|
+
if (mode === 'jira') return await collectJiraConfig();
|
|
3660
|
+
|
|
3661
|
+
throw new Error(
|
|
3662
|
+
`Unhandled sprint_management mode ${describeValue(mode)} — ` +
|
|
3663
|
+
`expected one of ${[...SPRINT_MODES].join(', ')}.`
|
|
3664
|
+
);
|
|
3665
|
+
}
|
|
3666
|
+
|
|
869
3667
|
// --- BMad module selection ---
|
|
870
3668
|
|
|
871
3669
|
/**
|
|
@@ -1193,6 +3991,55 @@ async function reconcileCustomMarketplaceOnUpdate({ projectRoot }) {
|
|
|
1193
3991
|
|
|
1194
3992
|
// --- Install wizard ---
|
|
1195
3993
|
|
|
3994
|
+
/**
|
|
3995
|
+
* Story 31.12 AC5/AC6 (FR250 degradation path) — what a global install says
|
|
3996
|
+
* about BMAD.
|
|
3997
|
+
*
|
|
3998
|
+
* Story 31.1's spike returned a FAIL verdict against a live bmad-method@6.10.0:
|
|
3999
|
+
* there is no user-level install target and, decisively, no module-resolution
|
|
4000
|
+
* search order that could find one (`findBmadDir` is a single `path.join` with
|
|
4001
|
+
* no upward walk, `resolve_config.py` hard-fails on a missing project-local
|
|
4002
|
+
* `_bmad/`, ~211 `{project-root}/_bmad/…` literals across 46 skills, no
|
|
4003
|
+
* `BMAD_HOME` escape hatch, and `bmad install --global` errors as an unknown
|
|
4004
|
+
* option). Upstream `bmad-code-org/BMAD-METHOD#1728` would change that, but it
|
|
4005
|
+
* is OPEN and not in 6.10.0 — it is the watch trigger, not a shipped feature.
|
|
4006
|
+
*
|
|
4007
|
+
* So the filter below and the `installScope === 'project'` guard on the BMAD
|
|
4008
|
+
* integration block are CORRECT, not a defect to be lifted. What changes here is
|
|
4009
|
+
* only what the user is told. AC6 makes the tone testable: a global install with
|
|
4010
|
+
* project-scoped BMAD is a SUPPORTED CONFIGURATION, not a degraded one, so this
|
|
4011
|
+
* is not a warning and not an error. The superseded text said "Warning:",
|
|
4012
|
+
* "skipped", and "Run again without --global" — it framed a working mode as a
|
|
4013
|
+
* mistake to undo. It is therefore also no longer rendered through
|
|
4014
|
+
* `chalk.yellow`, this codebase's warning colour (readiness review F-12).
|
|
4015
|
+
*
|
|
4016
|
+
* AC5 fixes the CONTENT at exactly three facts, one per constant below:
|
|
4017
|
+
* (i) BMAD remains project-scoped;
|
|
4018
|
+
* (ii) the command that installs BMAD into a project;
|
|
4019
|
+
* (iii) the knowledge-store configuration is unaffected.
|
|
4020
|
+
*
|
|
4021
|
+
* WORDING CONSTRAINT on fact (ii): `cd <project> && ma-agents install` reaches
|
|
4022
|
+
* the BMAD install only when BOTH an IDE-category agent is selected (the
|
|
4023
|
+
* `bmadTools.length > 0` guard, built from `category === 'ide'`) AND the user
|
|
4024
|
+
* accepts the "BMAD-METHOD not detected. Would you like to install it?" confirm.
|
|
4025
|
+
* Declining leaves BMAD uninstalled. The text therefore says the installer
|
|
4026
|
+
* *offers* — it must never imply one invocation guarantees a BMAD install.
|
|
4027
|
+
*
|
|
4028
|
+
* Pinned by test/global-install-scope.test.js ("AC5/AC6" section), which asserts
|
|
4029
|
+
* the three facts AND the absence of the alarming framing, against the output of
|
|
4030
|
+
* a real `install --yes --global` run.
|
|
4031
|
+
*/
|
|
4032
|
+
const GLOBAL_BMAD_SCOPE_NOTICE_FACT_1 =
|
|
4033
|
+
' Note: BMAD-METHOD is installed per project rather than globally, so these BMAD agents are not part of a global install:';
|
|
4034
|
+
|
|
4035
|
+
const GLOBAL_BMAD_SCOPE_NOTICE_FACT_2 =
|
|
4036
|
+
" To set BMAD up in a project, run `ma-agents install` from that project's root with an IDE\n" +
|
|
4037
|
+
' agent selected; the installer then offers to install BMAD-METHOD into that project.';
|
|
4038
|
+
|
|
4039
|
+
const GLOBAL_BMAD_SCOPE_NOTICE_FACT_3 =
|
|
4040
|
+
' Your knowledge-store configuration is unaffected: each project records its own knowledgebase\n' +
|
|
4041
|
+
' and sprint bindings in its own _bmad-output/project-layout.yaml.';
|
|
4042
|
+
|
|
1196
4043
|
async function installWizard(preselectedSkill, preselectedAgents, customPath, forceFlag, yesFlag = false, cliScope, bmadModulesFlag = '', bmadModulesPrompt = false, bmadCustomSourceFlag = '') {
|
|
1197
4044
|
const skills = listSkills();
|
|
1198
4045
|
const agents = listAgents();
|
|
@@ -1403,13 +4250,9 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
|
|
|
1403
4250
|
|
|
1404
4251
|
console.log(chalk.cyan(`Using profile: ${resolvedProfile} (from ${profileSource})`));
|
|
1405
4252
|
|
|
1406
|
-
//
|
|
1407
|
-
//
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
// Step 2.5: Repository layout (preserve existing config on update)
|
|
1411
|
-
const existingLayout = isUpdate ? readExistingLayout() : null;
|
|
1412
|
-
const repoLayout = await collectRepoLayout({ yes: yesFlag }, existingLayout);
|
|
4253
|
+
// Resolution of the profile stays here, unchanged, after agent selection. Its
|
|
4254
|
+
// PERSISTENCE moved down to Step 3.2, below the scope question — see the note
|
|
4255
|
+
// there (FR251 / Story 31.11 AC3).
|
|
1413
4256
|
|
|
1414
4257
|
// Step 3: Scope (Skip if update, yesFlag, CLI --global flag, or path already set)
|
|
1415
4258
|
if (!isUpdate && !installPath && !yesFlag && !cliScope) {
|
|
@@ -1437,16 +4280,109 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
|
|
|
1437
4280
|
}
|
|
1438
4281
|
}
|
|
1439
4282
|
|
|
1440
|
-
// Step 3.
|
|
4283
|
+
// Step 3.2: Persist the profile resolved at Step 2.4 (Story 21.1 AC #9 —
|
|
4284
|
+
// "before any agent install step", which this still is: Step 5 is the install
|
|
4285
|
+
// and Step 3.5's BMAD integration, the only other profile reader, is below).
|
|
4286
|
+
//
|
|
4287
|
+
// FR251 / Story 31.11 AC3 — the persist target is `process.cwd()`, i.e. THE
|
|
4288
|
+
// PROJECT ROOT. A global install has no project in scope, so writing
|
|
4289
|
+
// `.ma-agents.json` there was project-scoped work done on behalf of an install
|
|
4290
|
+
// that will never read it back: every consumer of the persisted profile
|
|
4291
|
+
// (`updateAgentInstructions` / `stampExtraInstructionTemplates` in
|
|
4292
|
+
// lib/installer.js, and lib/bmad.js) sits behind a `scope === 'project'` gate,
|
|
4293
|
+
// so under global scope the file was written, never read, and left behind in a
|
|
4294
|
+
// directory the user only happened to be standing in. It is now written under
|
|
4295
|
+
// the same predicate as every other project-scoped step.
|
|
4296
|
+
if (installScope === 'project') {
|
|
4297
|
+
setProfile(projectRootForProfile, resolvedProfile);
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
// Step 3.3: Repository layout — read the committed binding UNCONDITIONALLY.
|
|
4301
|
+
//
|
|
4302
|
+
// FR251 / Story 31.11 AC3 — ORDERING. This was Step 2.5, ABOVE the scope
|
|
4303
|
+
// question, so `collectRepoLayout` asked "Where is your knowledgebase
|
|
4304
|
+
// managed?" and "Where is your sprint management managed?" before anything
|
|
4305
|
+
// knew whether a project was in scope at all. A global install therefore
|
|
4306
|
+
// prompted for knowledge-store locations it then threw away — and with
|
|
4307
|
+
// MA_KNOWLEDGEBASE_GIT_URL set it went further and CLONED a repository
|
|
4308
|
+
// (`ciCloneIfNeeded`), i.e. reached the network, on an install that has no
|
|
4309
|
+
// project (NFR77).
|
|
4310
|
+
//
|
|
4311
|
+
// The collection now runs BELOW the scope question, under the SAME predicate
|
|
4312
|
+
// as all three of its consumers (`installScope === 'project'` at Step 3.6,
|
|
4313
|
+
// at the `.agents/skills` reconcile, and at the Step 6 project-context
|
|
4314
|
+
// update). One predicate, four sites: a global install collects nothing,
|
|
4315
|
+
// prompts nothing, clones nothing, and writes nothing under any project root.
|
|
4316
|
+
//
|
|
4317
|
+
// What did NOT move: the scope QUESTION itself (AC1) — its guard, its three
|
|
4318
|
+
// choices and its wording are byte-identical to before this story, and `git
|
|
4319
|
+
// diff` shows them as context, not as a change. THIS block moved down past it.
|
|
4320
|
+
//
|
|
4321
|
+
// B-1. This was `isUpdate ? readExistingLayout() : null`. `isUpdate` is true
|
|
4322
|
+
// ONLY for the interactive "Update (add/remove skills)" menu choice — it is
|
|
4323
|
+
// false for EVERY `--yes` run and false on a fresh clone. So on the two paths
|
|
4324
|
+
// that matter most (CI and a colleague cloning a bound repo) `existingLayout`
|
|
4325
|
+
// was `null`, collectRepoLayout fell through to its bare single-repo default
|
|
4326
|
+
// `{knowledgebase:{same,.}, sprintManagement:{same,.}}`, and that default
|
|
4327
|
+
// reached writeProjectLayoutYaml INDISTINGUISHABLE FROM A REAL ANSWER. Result:
|
|
4328
|
+
// `ma-agents install --yes` over a committed Confluence binding silently
|
|
4329
|
+
// replaced the software requirements store with `file-system / same / "."` —
|
|
4330
|
+
// space key and root page gone — and over a NON-divergent Confluence pair it
|
|
4331
|
+
// destroyed both stores without printing a single word about it. A default
|
|
4332
|
+
// that degrades to permissive instead of failing loud; the same shape this
|
|
4333
|
+
// epic has now produced five times.
|
|
4334
|
+
//
|
|
4335
|
+
// Reading unconditionally is the whole fix, because everything downstream is
|
|
4336
|
+
// already built for it: collectRepoLayout with a non-null existingLayout and
|
|
4337
|
+
// no env vars returns it VERBATIM (see the `if (existingLayout) return
|
|
4338
|
+
// existingLayout;` branch), and writeProjectLayoutYaml's preservation step
|
|
4339
|
+
// then carries every record the caller did not supply. It also makes the
|
|
4340
|
+
// install path agree with `handleConfigLayout`, which has always read
|
|
4341
|
+
// unconditionally.
|
|
4342
|
+
//
|
|
4343
|
+
// Restricting preservation to `confluence`-backed stores was considered and
|
|
4344
|
+
// REJECTED: two `file-system` stores in different locations is FR258's normal
|
|
4345
|
+
// case, so a Confluence-only rule would still collapse `system: local /a` +
|
|
4346
|
+
// `software: local /b` on every `install --yes`.
|
|
4347
|
+
//
|
|
4348
|
+
// Consequence deliberately accepted: on a project with a legacy
|
|
4349
|
+
// `knowledgebase_path` in `_bmad/bmm/config.yaml` but NO
|
|
4350
|
+
// `_bmad-output/project-layout.yaml`, readExistingLayout's FR265 config.yaml
|
|
4351
|
+
// fallback is now reachable from `install` (it previously was not). A `--yes`
|
|
4352
|
+
// reinstall there used to reset the layout to `same/.`; it now keeps the path
|
|
4353
|
+
// config.yaml already records. That is the DEFECT `test/config-lost-on-
|
|
4354
|
+
// update.test.js` exists to prevent — none of its 13 tests pins the reset as
|
|
4355
|
+
// desired — and it satisfies NFR82 because the resolved artifact paths are the
|
|
4356
|
+
// ones config.yaml already resolves to.
|
|
4357
|
+
//
|
|
4358
|
+
// NOTE the split below. `readExistingLayout()` stays UNCONDITIONAL — that is
|
|
4359
|
+
// B-1's fix above and it is deliberately not weakened by anything in Story
|
|
4360
|
+
// 31.11. It is a pure read: it writes nothing, prompts nothing, and therefore
|
|
4361
|
+
// cannot violate FR251 whatever the scope. The FR251 surface is
|
|
4362
|
+
// `collectRepoLayout`, which PROMPTS ("Where is your knowledgebase managed?" /
|
|
4363
|
+
// "Where is your sprint management managed?") and, with MA_KNOWLEDGEBASE_GIT_URL
|
|
4364
|
+
// set, CLONES (`ciCloneIfNeeded`) — knowledge-store prompting and network
|
|
4365
|
+
// access on an install that has no project. Only that call is gated.
|
|
4366
|
+
const existingLayout = readExistingLayout();
|
|
4367
|
+
let repoLayout = null;
|
|
4368
|
+
if (installScope === 'project') {
|
|
4369
|
+
repoLayout = await collectRepoLayout({ yes: yesFlag }, existingLayout);
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
// Step 3.4: BMAD agents are project-scoped, so a global install does not carry
|
|
4373
|
+
// them. Story 31.12 (FR250 degradation path) KEEPS this filter — under the
|
|
4374
|
+
// Story 31.1 FAIL verdict it is the correct behaviour — and changes only what
|
|
4375
|
+
// the user is told about it. See GLOBAL_BMAD_SCOPE_NOTICE_FACT_1..3 above.
|
|
1441
4376
|
if (installScope === 'global') {
|
|
1442
4377
|
const bmadAgentIds = selectedAgentIds.filter(id => {
|
|
1443
4378
|
const agent = agents.find(a => a.id === id);
|
|
1444
4379
|
return agent && agent.category === 'bmad';
|
|
1445
4380
|
});
|
|
1446
4381
|
if (bmadAgentIds.length > 0) {
|
|
1447
|
-
console.log(chalk.
|
|
1448
|
-
console.log(chalk.
|
|
1449
|
-
console.log(chalk.gray(
|
|
4382
|
+
console.log(chalk.cyan(`\n${GLOBAL_BMAD_SCOPE_NOTICE_FACT_1}`));
|
|
4383
|
+
console.log(chalk.cyan(` ${bmadAgentIds.join(', ')}`));
|
|
4384
|
+
console.log(chalk.gray(GLOBAL_BMAD_SCOPE_NOTICE_FACT_2));
|
|
4385
|
+
console.log(chalk.gray(GLOBAL_BMAD_SCOPE_NOTICE_FACT_3));
|
|
1450
4386
|
selectedAgentIds = selectedAgentIds.filter(id => !bmadAgentIds.includes(id));
|
|
1451
4387
|
}
|
|
1452
4388
|
}
|
|
@@ -1612,10 +4548,72 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
|
|
|
1612
4548
|
}
|
|
1613
4549
|
}
|
|
1614
4550
|
|
|
1615
|
-
// Step 3.6: Write
|
|
4551
|
+
// Step 3.6: Write the committed binding, then GENERATE the per-machine config
|
|
4552
|
+
// from it (after BMAD install creates config.yaml).
|
|
4553
|
+
//
|
|
4554
|
+
// FR251 / Story 31.11: BOTH writes stay inside this one `installScope ===
|
|
4555
|
+
// 'project'` gate. They are project-scoped work, and a global install must
|
|
4556
|
+
// perform none of it. Do not add a second, ungated call site.
|
|
1616
4557
|
if (installScope === 'project') {
|
|
1617
|
-
|
|
1618
|
-
|
|
4558
|
+
// Story 31.3 AC1/AC2 — ORDER IS LOAD-BEARING, and it is the reverse of what
|
|
4559
|
+
// it was. `config.yaml` is now GENERATED FROM `project-layout.yaml`, so the
|
|
4560
|
+
// authoritative file must exist and be current before the derived one is
|
|
4561
|
+
// produced. Generating first would have derived this install's config from
|
|
4562
|
+
// the PREVIOUS install's binding — off by one run, and invisible.
|
|
4563
|
+
//
|
|
4564
|
+
// The order also carries the divergence case: when the two committed stores
|
|
4565
|
+
// point at different places and the wizard supplied only the single legacy
|
|
4566
|
+
// answer, `writeProjectLayoutYaml` applies it to the software store and
|
|
4567
|
+
// PRESERVES the system store (FR264). Generating afterwards means
|
|
4568
|
+
// `config.yaml` reflects the preserved system store rather than the
|
|
4569
|
+
// collapsed answer.
|
|
4570
|
+
//
|
|
4571
|
+
// R-4: Step 3.6 runs AFTER the BMAD install has completed. An uncaught throw
|
|
4572
|
+
// from here reaches main().catch, which prints the raw Error (stack trace,
|
|
4573
|
+
// exit 1) on a substantially-finished install - the exact failure mode the
|
|
4574
|
+
// directory/write guards inside writeProjectLayoutYaml exist to prevent.
|
|
4575
|
+
// Legal-but-unusual path characters no longer throw at all now that they are
|
|
4576
|
+
// escaped rather than rejected; this catches what remains (the closed backend
|
|
4577
|
+
// enum, the confluence identity guard) and degrades to the same
|
|
4578
|
+
// warn-and-leave-UNBOUND outcome.
|
|
4579
|
+
try {
|
|
4580
|
+
writeProjectLayoutYaml(repoLayout);
|
|
4581
|
+
} catch (e) {
|
|
4582
|
+
console.log(chalk.yellow(
|
|
4583
|
+
` Warning: could not write _bmad-output/project-layout.yaml: ${e.message}\n` +
|
|
4584
|
+
` The project is left UNBOUND - correct the above, then run\n` +
|
|
4585
|
+
` \`${NAME} bind\` to write the binding without a full re-install.`
|
|
4586
|
+
));
|
|
4587
|
+
}
|
|
4588
|
+
// `fallbackLayout` covers only the case where the binding could not be
|
|
4589
|
+
// written at all (the warning above fired), so the install still leaves a
|
|
4590
|
+
// usable config. When the binding exists it wins outright — see
|
|
4591
|
+
// generateBmmConfig.
|
|
4592
|
+
generateBmmConfig({ projectRoot: process.cwd(), fallbackLayout: repoLayout });
|
|
4593
|
+
|
|
4594
|
+
// Story 31.15 — the SECOND output of this same one-way pass. Deliberately
|
|
4595
|
+
// here and not earlier: bmad-method's installer restores an unmanifested
|
|
4596
|
+
// `_bmad/custom/*.toml` verbatim from its pre-update backup, and `config.yaml`
|
|
4597
|
+
// is exempted from that restore while these files are not, so a STALE hook
|
|
4598
|
+
// comes back. Running after `installBmad()` (:4089/:4104) is what overwrites
|
|
4599
|
+
// it. Moving this call ahead of the BMAD install is a defect, not a refactor.
|
|
4600
|
+
generateConfluencePublishHooks({ projectRoot: process.cwd(), fallbackLayout: repoLayout });
|
|
4601
|
+
|
|
4602
|
+
// Story 31.7 (FR265) AC2/AC5 — tell the user, ONCE and non-blockingly, that
|
|
4603
|
+
// this project is on the legacy single-store layout and name the command
|
|
4604
|
+
// that splits it. No-op on a two-store project, on an unbound one, on a
|
|
4605
|
+
// binding this build refuses to read, and on every run after the first.
|
|
4606
|
+
//
|
|
4607
|
+
// AFTER the two writes above, deliberately: the notice describes the state
|
|
4608
|
+
// the install LEAVES the project in, and under the legacy-preservation branch
|
|
4609
|
+
// in `writeProjectLayoutYaml` that state is still legacy. Printing before
|
|
4610
|
+
// would risk describing a layout the install was about to change.
|
|
4611
|
+
//
|
|
4612
|
+
// Inside the `installScope === 'project'` gate for the same FR251 reason the
|
|
4613
|
+
// Demerzel notice is (Story 31.11 AC3): the shown-once flag is per-project
|
|
4614
|
+
// state in the project-root `.ma-agents.json`, and a global install has no
|
|
4615
|
+
// project to stamp it into.
|
|
4616
|
+
showLegacySingleStoreNoticeOnce(process.cwd());
|
|
1619
4617
|
}
|
|
1620
4618
|
|
|
1621
4619
|
// Step 4: Confirm
|
|
@@ -1696,10 +4694,20 @@ async function installWizard(preselectedSkill, preselectedAgents, customPath, fo
|
|
|
1696
4694
|
|
|
1697
4695
|
// Story 30.4 (FR245/FR246) — idempotent one-time Demerzel deprecation
|
|
1698
4696
|
// notice. No-op after the first time it has ever printed for this project.
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
4697
|
+
//
|
|
4698
|
+
// FR251 / Story 31.11 AC3 — "for this project" is the operative phrase: the
|
|
4699
|
+
// shown-once flag is persisted into the PROJECT ROOT `.ma-agents.json`
|
|
4700
|
+
// (`lib/profile.js#markNoticeShown`). A global install has no project, so it
|
|
4701
|
+
// must not stamp a per-project flag into whatever directory the user happened
|
|
4702
|
+
// to run from. Deliberately accepted: a user who ONLY ever installs globally
|
|
4703
|
+
// does not see this notice until they install into a project, at which point
|
|
4704
|
+
// it prints — once — against the project it actually concerns.
|
|
4705
|
+
if (installScope === 'project') {
|
|
4706
|
+
try {
|
|
4707
|
+
showDemerzelDeprecationNoticeOnce(process.cwd());
|
|
4708
|
+
} catch (err) {
|
|
4709
|
+
console.log(chalk.yellow(`\n Deprecation notice skipped: ${err.message}`));
|
|
4710
|
+
}
|
|
1703
4711
|
}
|
|
1704
4712
|
|
|
1705
4713
|
// Install requested skills
|
|
@@ -1851,7 +4859,14 @@ async function handleInstall(args) {
|
|
|
1851
4859
|
}
|
|
1852
4860
|
|
|
1853
4861
|
console.log(chalk.cyan(`Using profile: ${resolvedProfile} (from ${profileSource})`));
|
|
1854
|
-
|
|
4862
|
+
// FR251 / Story 31.11 AC3 — same gate as the wizard's Step 3.2. `scope` here
|
|
4863
|
+
// comes straight from `parseFlags` (`--global` → 'global'), so it is already
|
|
4864
|
+
// known at this point and no reordering is needed on this path. A direct
|
|
4865
|
+
// `ma-agents install <skill> <agent> --global` must not stamp
|
|
4866
|
+
// `.ma-agents.json` into whatever directory it was run from either.
|
|
4867
|
+
if (scope === 'project') {
|
|
4868
|
+
setProfile(projectRootForProfile, resolvedProfile);
|
|
4869
|
+
}
|
|
1855
4870
|
|
|
1856
4871
|
// Full args → direct install (Task 3.5)
|
|
1857
4872
|
const targetAgents = agentFlag ? [agentFlag] : agentIds;
|
|
@@ -1869,10 +4884,15 @@ async function handleInstall(args) {
|
|
|
1869
4884
|
|
|
1870
4885
|
// Story 30.4 (FR245/FR246) — idempotent one-time Demerzel deprecation
|
|
1871
4886
|
// notice. No-op after the first time it has ever printed for this project.
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
4887
|
+
// FR251 / Story 31.11 AC3 — project-scoped, for the reason given at the
|
|
4888
|
+
// wizard's copy of this block: the shown-once flag lands in the project-root
|
|
4889
|
+
// `.ma-agents.json`.
|
|
4890
|
+
if (scope === 'project') {
|
|
4891
|
+
try {
|
|
4892
|
+
showDemerzelDeprecationNoticeOnce(process.cwd());
|
|
4893
|
+
} catch (err) {
|
|
4894
|
+
console.log(chalk.yellow(`\n Deprecation notice skipped: ${err.message}`));
|
|
4895
|
+
}
|
|
1876
4896
|
}
|
|
1877
4897
|
|
|
1878
4898
|
try {
|
|
@@ -1926,6 +4946,19 @@ async function handleUninstall(args) {
|
|
|
1926
4946
|
// --- Config layout command ---
|
|
1927
4947
|
|
|
1928
4948
|
function showCurrentLayout() {
|
|
4949
|
+
// R-2: distinguish "unbound" from "bound but unreadable/invalid" BEFORE falling
|
|
4950
|
+
// back to the single-repo default. Reporting a `backend: notion` project as
|
|
4951
|
+
// `Single-repo layout (default)` is the conflation the resolver contract
|
|
4952
|
+
// forbids, and it is what made the destructive overwrite look reasonable.
|
|
4953
|
+
const binding = resolveProjectTopology(process.cwd());
|
|
4954
|
+
if (!binding.ok && binding.reason !== BINDING_UNBOUND) {
|
|
4955
|
+
console.log(chalk.red(`\n Repository layout: binding is ${binding.reason}`));
|
|
4956
|
+
console.log(chalk.red(` ${binding.message}`));
|
|
4957
|
+
console.log(chalk.gray(' This project IS bound - the binding just cannot be honoured. Nothing'));
|
|
4958
|
+
console.log(chalk.gray(' will be overwritten until it is fixed.'));
|
|
4959
|
+
return;
|
|
4960
|
+
}
|
|
4961
|
+
|
|
1929
4962
|
const layout = readExistingLayout();
|
|
1930
4963
|
if (!layout) {
|
|
1931
4964
|
console.log(chalk.cyan('\n Single-repo layout (default)'));
|
|
@@ -1935,18 +4968,45 @@ function showCurrentLayout() {
|
|
|
1935
4968
|
}
|
|
1936
4969
|
|
|
1937
4970
|
console.log(chalk.bold.cyan('\n Current Repository Layout:\n'));
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
4971
|
+
|
|
4972
|
+
const topology = layout.topology || null;
|
|
4973
|
+
const system = topology ? topology.systemRequirementsStore : null;
|
|
4974
|
+
const software = topology ? topology.softwareRequirementsStore : null;
|
|
4975
|
+
|
|
4976
|
+
if (system && software && storesDiverge(system, software)) {
|
|
4977
|
+
// The two committed stores point at different locations — the FR257 case.
|
|
4978
|
+
// Render BOTH, each named after the record it came from and each through its
|
|
4979
|
+
// own backend's field list, so the user can see at a glance which store has
|
|
4980
|
+
// which backend. Collapsing them onto one `Knowledgebase:` block hid a
|
|
4981
|
+
// confluence system store completely, and it is the block the user reads
|
|
4982
|
+
// immediately before answering the reconfigure wizard.
|
|
4983
|
+
printIdentityBlock('System requirements store', storeIdentityFields(system));
|
|
4984
|
+
printIdentityBlock('Software requirements store', storeIdentityFields(software));
|
|
4985
|
+
} else {
|
|
4986
|
+
// Non-divergent — which is every project that can exist before Story 31.5,
|
|
4987
|
+
// and the config.yaml-fallback case where there is no topology at all.
|
|
4988
|
+
// Exactly today's single block under today's label (display AC2).
|
|
4989
|
+
printIdentityBlock('Knowledgebase', storeIdentityFields(layout.knowledgebase));
|
|
1946
4990
|
}
|
|
4991
|
+
printIdentityBlock('Sprint Management', sprintIdentityFields(layout.sprintManagement));
|
|
1947
4992
|
console.log('');
|
|
1948
4993
|
}
|
|
1949
4994
|
|
|
4995
|
+
/**
|
|
4996
|
+
* Prints one `Label:` heading and its identity fields. The `padEnd(8) + ' '`
|
|
4997
|
+
* reproduces today's alignment byte-for-byte — `Mode:` and `Path:` followed by
|
|
4998
|
+
* four spaces, `Git URL:` by one — so a file-system store's output is unchanged
|
|
4999
|
+
* (display AC2). Values go through renderIdentityValue, which cannot emit the
|
|
5000
|
+
* literal `undefined` (display AC3/AC4).
|
|
5001
|
+
*/
|
|
5002
|
+
function printIdentityBlock(label, fields) {
|
|
5003
|
+
console.log(chalk.white(` ${label}:`));
|
|
5004
|
+
for (const field of fields) {
|
|
5005
|
+
if (field.omitWhenAbsent && (field.value == null || field.value === '')) continue;
|
|
5006
|
+
console.log(chalk.gray(` ${(field.label + ':').padEnd(8)} ${renderIdentityValue(field.value)}`));
|
|
5007
|
+
}
|
|
5008
|
+
}
|
|
5009
|
+
|
|
1950
5010
|
async function handleConfigLayout(args) {
|
|
1951
5011
|
const { yesFlag } = parseFlags(args);
|
|
1952
5012
|
const showOnly = args.includes('--show');
|
|
@@ -1963,9 +5023,22 @@ async function handleConfigLayout(args) {
|
|
|
1963
5023
|
const existingLayout = readExistingLayout();
|
|
1964
5024
|
const newLayout = await collectRepoLayout({ yes: yesFlag }, existingLayout);
|
|
1965
5025
|
|
|
1966
|
-
// Write
|
|
1967
|
-
|
|
1968
|
-
|
|
5026
|
+
// Write the committed binding FIRST, then regenerate the derived config from
|
|
5027
|
+
// it — same order, and for the same reason, as Step 3.6 of the installer
|
|
5028
|
+
// (Story 31.3 AC1/AC2: config.yaml is generated from project-layout.yaml, so
|
|
5029
|
+
// the authoritative file must be current before the derived one is produced).
|
|
5030
|
+
try {
|
|
5031
|
+
writeProjectLayoutYaml(newLayout);
|
|
5032
|
+
} catch (e) {
|
|
5033
|
+
console.log(chalk.yellow(
|
|
5034
|
+
` Warning: could not write _bmad-output/project-layout.yaml: ${e.message}\n` +
|
|
5035
|
+
` The binding was left unchanged.`
|
|
5036
|
+
));
|
|
5037
|
+
}
|
|
5038
|
+
generateBmmConfig({ projectRoot: process.cwd(), fallbackLayout: newLayout });
|
|
5039
|
+
// Story 31.15. A backend flipped away from `confluence` here is exactly the
|
|
5040
|
+
// AC3(d) removal path: the block goes, the rest of the file stays.
|
|
5041
|
+
generateConfluencePublishHooks({ projectRoot: process.cwd(), fallbackLayout: newLayout });
|
|
1969
5042
|
|
|
1970
5043
|
// Update project-context.md
|
|
1971
5044
|
const outputPath = path.join(process.cwd(), '_bmad-output', 'project-context.md');
|
|
@@ -1983,6 +5056,459 @@ async function handleConfigLayout(args) {
|
|
|
1983
5056
|
console.log(chalk.bold.green('\n Layout reconfigured!\n'));
|
|
1984
5057
|
}
|
|
1985
5058
|
|
|
5059
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
5060
|
+
// Story 31.13 — `ma-agents bind` (FR252) and scope reporting in `status` (FR253)
|
|
5061
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
5062
|
+
|
|
5063
|
+
/**
|
|
5064
|
+
* AC3's decision procedure, as a PURE function.
|
|
5065
|
+
*
|
|
5066
|
+
* Separated from the prompting so "the offer never runs bind without the user's
|
|
5067
|
+
* choice" is provable rather than promised: this function decides WHETHER to
|
|
5068
|
+
* offer and writes nothing, and `offerBind` below is the only thing that can
|
|
5069
|
+
* run bind — and only on an explicit affirmative answer.
|
|
5070
|
+
*
|
|
5071
|
+
* KEYED ON `resolveProjectTopology` FIRST (the Story 31.3/31.4 contract). The
|
|
5072
|
+
* three outcomes are deliberately distinct, because `absent` and `malformed` are
|
|
5073
|
+
* NOT interchangeable:
|
|
5074
|
+
* - `unbound` → OFFER. This is AC3's case: no committed binding at all.
|
|
5075
|
+
* - `ok` → do not offer. The project is bound; there is nothing to bind.
|
|
5076
|
+
* - anything else (`unreadable` / `invalid` / `too-new`) → do NOT offer, and
|
|
5077
|
+
* say why. A binding this build cannot read is a DIFFERENT FACT from a
|
|
5078
|
+
* project that has none (Story 31.4's default-deny rule), and offering to
|
|
5079
|
+
* write a fresh binding over it is the destructive-overwrite shape Story
|
|
5080
|
+
* 31.2's guard exists to refuse. Bind itself refuses the same way.
|
|
5081
|
+
*
|
|
5082
|
+
* @returns {{offer: boolean, reason: string, message: string}}
|
|
5083
|
+
*/
|
|
5084
|
+
function bindOfferDecision(projectRoot) {
|
|
5085
|
+
const binding = resolveProjectTopology(projectRoot);
|
|
5086
|
+
if (binding.ok) {
|
|
5087
|
+
return {
|
|
5088
|
+
offer: false,
|
|
5089
|
+
reason: 'already-bound',
|
|
5090
|
+
message: 'This project already has a committed knowledge binding.',
|
|
5091
|
+
};
|
|
5092
|
+
}
|
|
5093
|
+
if (binding.reason !== BINDING_UNBOUND) {
|
|
5094
|
+
return {
|
|
5095
|
+
offer: false,
|
|
5096
|
+
reason: binding.reason,
|
|
5097
|
+
message:
|
|
5098
|
+
`_bmad-output/project-layout.yaml is ${binding.reason}: ${binding.message}. ` +
|
|
5099
|
+
`This project IS bound — the binding just cannot be read — so binding it afresh ` +
|
|
5100
|
+
`would overwrite it. Fix it by hand, or delete it and run \`ma-agents bind\`.`,
|
|
5101
|
+
};
|
|
5102
|
+
}
|
|
5103
|
+
return {
|
|
5104
|
+
offer: true,
|
|
5105
|
+
reason: BINDING_UNBOUND,
|
|
5106
|
+
message:
|
|
5107
|
+
'This project has no _bmad-output/project-layout.yaml, so nothing states where its ' +
|
|
5108
|
+
'knowledge stores live.',
|
|
5109
|
+
};
|
|
5110
|
+
}
|
|
5111
|
+
|
|
5112
|
+
/**
|
|
5113
|
+
* AC3 — the automatic offer, at the CLI. Asks; runs bind ONLY on an explicit yes.
|
|
5114
|
+
*
|
|
5115
|
+
* WHAT THIS IS AND IS NOT. The offer that reaches a globally-installed SKILL at
|
|
5116
|
+
* activation time is TEXT, and it lives in the Story 31.4 preflight embedded in
|
|
5117
|
+
* every shipped skill document (Step P2 — "Run `npx ma-agents install` … or
|
|
5118
|
+
* `ma-agents bind` …"). That block is the surface an agent actually loads when a
|
|
5119
|
+
* skill activates, which is why the offer belongs there and not in a file nothing
|
|
5120
|
+
* is directed to read. This story's contribution to that half is that the command
|
|
5121
|
+
* the text names NOW EXISTS. `test/project-layout-bind.test.js` pins both halves.
|
|
5122
|
+
*
|
|
5123
|
+
* This function is the EXECUTABLE half: the same decision, callable, so the
|
|
5124
|
+
* "never without the user's choice" property is a property of code rather than of
|
|
5125
|
+
* prose. `yes` is honoured as a REFUSAL to offer, not as an acceptance — a
|
|
5126
|
+
* non-interactive run has no user to choose, and auto-accepting would be exactly
|
|
5127
|
+
* the thing AC3 forbids.
|
|
5128
|
+
*
|
|
5129
|
+
* @returns {Promise<{ran: boolean, reason: string}>}
|
|
5130
|
+
*/
|
|
5131
|
+
async function offerBind({ projectRoot = process.cwd(), yes = false } = {}) {
|
|
5132
|
+
const decision = bindOfferDecision(projectRoot);
|
|
5133
|
+
if (!decision.offer) {
|
|
5134
|
+
return { ran: false, reason: decision.reason };
|
|
5135
|
+
}
|
|
5136
|
+
|
|
5137
|
+
console.log(chalk.cyan(`\n ${decision.message}`));
|
|
5138
|
+
|
|
5139
|
+
if (yes) {
|
|
5140
|
+
// Non-interactive. State the remedy and STOP. Running bind here would be an
|
|
5141
|
+
// offer that accepted itself.
|
|
5142
|
+
console.log(chalk.gray(' Run `ma-agents bind` in this project to create one. Not doing it for you.'));
|
|
5143
|
+
return { ran: false, reason: 'declined-non-interactive' };
|
|
5144
|
+
}
|
|
5145
|
+
|
|
5146
|
+
const { doBind } = await prompts({
|
|
5147
|
+
type: 'confirm',
|
|
5148
|
+
name: 'doBind',
|
|
5149
|
+
message: 'Bind this project now (answer the knowledge-store and sprint-management questions)?',
|
|
5150
|
+
initial: false,
|
|
5151
|
+
});
|
|
5152
|
+
|
|
5153
|
+
if (doBind !== true) {
|
|
5154
|
+
console.log(chalk.gray(' Not bound. Run `ma-agents bind` whenever you are ready.'));
|
|
5155
|
+
return { ran: false, reason: 'declined' };
|
|
5156
|
+
}
|
|
5157
|
+
|
|
5158
|
+
// N3 - THE ROOT THE OFFER DECIDED ON MUST BE THE ROOT THAT GETS BOUND.
|
|
5159
|
+
//
|
|
5160
|
+
// `bindOfferDecision` above keys on `projectRoot`; `handleBind` re-derives its
|
|
5161
|
+
// own root from `process.cwd()`, and so do `writeProjectLayoutYaml`,
|
|
5162
|
+
// `readExistingLayout` and `collectRepoLayout`. Plumbing `projectRoot` through
|
|
5163
|
+
// would therefore fix one of four and leave the write itself pointed at the cwd
|
|
5164
|
+
// - worse than the disagreement it set out to remove. So the agreement is
|
|
5165
|
+
// CHECKED rather than assumed: deciding to offer for one project and then
|
|
5166
|
+
// binding a different one is a silent wrong-project bind, and until the fix
|
|
5167
|
+
// above it would have reported `Bound.` and exited 0 while doing it.
|
|
5168
|
+
//
|
|
5169
|
+
// Unreachable from today's only caller (`status` passes `process.cwd()`). It is
|
|
5170
|
+
// pinned by a test so that a second caller cannot reintroduce it quietly - a
|
|
5171
|
+
// latent trap with a failing-if-touched test is a decision, not a trap.
|
|
5172
|
+
const cwd = process.cwd();
|
|
5173
|
+
if (path.resolve(projectRoot) !== path.resolve(cwd)) {
|
|
5174
|
+
console.log(chalk.red(
|
|
5175
|
+
` Not binding: this offer was decided for\n` +
|
|
5176
|
+
` ${projectRoot}\n` +
|
|
5177
|
+
` but \`${NAME} bind\` would bind\n` +
|
|
5178
|
+
` ${cwd}\n` +
|
|
5179
|
+
` Run \`${NAME} bind\` from inside the project you mean.`
|
|
5180
|
+
));
|
|
5181
|
+
return { ran: false, reason: 'project-root-mismatch' };
|
|
5182
|
+
}
|
|
5183
|
+
|
|
5184
|
+
const result = await handleBind([]);
|
|
5185
|
+
return { ran: true, reason: 'accepted', result };
|
|
5186
|
+
}
|
|
5187
|
+
|
|
5188
|
+
/**
|
|
5189
|
+
* `ma-agents bind` — write a project's committed binding WITHOUT installing
|
|
5190
|
+
* anything (FR252).
|
|
5191
|
+
*
|
|
5192
|
+
* AC1: collects exactly the three project-scoped answers, and no others, by
|
|
5193
|
+
* calling `collectRepoLayout` — Story 31.5's and 31.8's collection flow itself,
|
|
5194
|
+
* not a copy of it. The three questions ARE `REPO_LAYOUT_CONCERNS`; a second copy
|
|
5195
|
+
* of the store questions living here is the defect this story is most likely to
|
|
5196
|
+
* introduce, so there is deliberately no question text in this function at all.
|
|
5197
|
+
*
|
|
5198
|
+
* AC2: installs nothing. Nothing below touches `installSkill`, any agent skills
|
|
5199
|
+
* directory, or the BMAD installer. The only thing written under `_bmad/` is the
|
|
5200
|
+
* generated `config.yaml`, which P5-9.3 requires to fire on bind.
|
|
5201
|
+
*
|
|
5202
|
+
* AC4/NFR78: on an already-bound project this REPORTS the bindings and changes
|
|
5203
|
+
* nothing unless the user explicitly says they want to edit an answer.
|
|
5204
|
+
*
|
|
5205
|
+
* AC6: regenerates `config.yaml` from the layout, and touches neither
|
|
5206
|
+
* `_bmad/config.user.toml` nor `_bmad/custom/config.user.toml` (P5-9.4) — it
|
|
5207
|
+
* calls `generateBmmConfig`, whose only write target is `_bmad/bmm/config.yaml`.
|
|
5208
|
+
*/
|
|
5209
|
+
async function handleBind(args = []) {
|
|
5210
|
+
const { yesFlag } = parseFlags(args);
|
|
5211
|
+
const projectRoot = process.cwd();
|
|
5212
|
+
|
|
5213
|
+
console.log(chalk.bold.cyan(`\n ${NAME} bind — project knowledge binding\n`));
|
|
5214
|
+
|
|
5215
|
+
// ── Step 1. The Story 31.3 rule: key on `resolveProjectTopology` FIRST.
|
|
5216
|
+
const binding = resolveProjectTopology(projectRoot);
|
|
5217
|
+
|
|
5218
|
+
if (!binding.ok && binding.reason !== BINDING_UNBOUND) {
|
|
5219
|
+
// NOT a bind. The same refusal `writeProjectLayoutYaml` would make, made
|
|
5220
|
+
// BEFORE any question is asked, so the user is not walked through three
|
|
5221
|
+
// prompts whose answers will then be thrown away.
|
|
5222
|
+
console.log(chalk.red(
|
|
5223
|
+
` Refusing to bind — the existing binding in _bmad-output/project-layout.yaml is ` +
|
|
5224
|
+
`${binding.reason}:\n` +
|
|
5225
|
+
` ${binding.message}\n` +
|
|
5226
|
+
` The file has been left UNCHANGED. Fix it by hand, or delete ` +
|
|
5227
|
+
`_bmad-output/project-layout.yaml\n` +
|
|
5228
|
+
` and run \`${NAME} bind\` again to write a fresh binding.`
|
|
5229
|
+
));
|
|
5230
|
+
process.exitCode = 1;
|
|
5231
|
+
return { bound: false, reason: binding.reason };
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
// ── Step 2. AC4 — an already-bound project REPORTS and changes nothing unless
|
|
5235
|
+
// the user explicitly asks to edit an answer.
|
|
5236
|
+
let editAnswers = true;
|
|
5237
|
+
if (binding.ok) {
|
|
5238
|
+
console.log(chalk.white(' This project is already bound. Its committed bindings are:'));
|
|
5239
|
+
showCurrentLayout();
|
|
5240
|
+
|
|
5241
|
+
if (yesFlag) {
|
|
5242
|
+
// Non-interactive: there is no user to say "edit". Explicitly NOT an
|
|
5243
|
+
// implicit yes — the default on the interactive prompt below is also `no`,
|
|
5244
|
+
// so both paths agree.
|
|
5245
|
+
editAnswers = false;
|
|
5246
|
+
} else {
|
|
5247
|
+
const { edit } = await prompts({
|
|
5248
|
+
type: 'confirm',
|
|
5249
|
+
name: 'edit',
|
|
5250
|
+
message: 'Change any of these answers?',
|
|
5251
|
+
initial: false,
|
|
5252
|
+
});
|
|
5253
|
+
editAnswers = edit === true;
|
|
5254
|
+
}
|
|
5255
|
+
|
|
5256
|
+
if (!editAnswers) {
|
|
5257
|
+
console.log(chalk.gray(' Bindings left unchanged — no answer was edited.'));
|
|
5258
|
+
}
|
|
5259
|
+
}
|
|
5260
|
+
|
|
5261
|
+
// ── Step 3. Collect, but ONLY when there is something to collect.
|
|
5262
|
+
//
|
|
5263
|
+
// AC4's "changes nothing" is enforced by NOT WRITING, not by writing the same
|
|
5264
|
+
// bytes and hoping they match. `writeProjectLayoutYaml` refreshes the
|
|
5265
|
+
// `generated:` date on every write, so a re-write is byte-identical only while
|
|
5266
|
+
// the two runs fall on the same calendar day — which is a coincidence, not a
|
|
5267
|
+
// guarantee, and is exactly the "prove the negative by byte-identity" trap this
|
|
5268
|
+
// epic keeps falling into. The committed binding is therefore not written at
|
|
5269
|
+
// all on a no-edit re-run.
|
|
5270
|
+
let layoutForContext = null;
|
|
5271
|
+
if (editAnswers) {
|
|
5272
|
+
const existingLayout = readExistingLayout();
|
|
5273
|
+
const newLayout = await collectRepoLayout({ yes: yesFlag }, existingLayout);
|
|
5274
|
+
|
|
5275
|
+
// Same order as install Step 3.6 and `handleConfigLayout`: the authoritative
|
|
5276
|
+
// file first, the derived one after (Story 31.3 AC1/AC2).
|
|
5277
|
+
// THE FIX (Story 31.13 fix round, BLOCKING). `writeProjectLayoutYaml` now
|
|
5278
|
+
// returns a named outcome, so this function can finally tell "wrote it" from
|
|
5279
|
+
// "could not create the directory". Before it did, every exit of that callee
|
|
5280
|
+
// returned `undefined`, it SWALLOWED both I/O failures itself, and the catch
|
|
5281
|
+
// below was therefore DEAD CODE on exactly the paths it was written for.
|
|
5282
|
+
// Steps 4-6 and the `Bound.` line then ran unconditionally: a bind whose
|
|
5283
|
+
// authoritative write had failed printed `Bound.`, exited 0, generated a
|
|
5284
|
+
// `config.yaml` full of `.` defaults, and registered the project.
|
|
5285
|
+
//
|
|
5286
|
+
// The outcome is checked with `projectLayoutWriteIsBound`, which enumerates
|
|
5287
|
+
// the BOUND outcomes and denies everything else - so Story 31.7's
|
|
5288
|
+
// legacy-skip ("nothing moved", the project IS bound by the file already
|
|
5289
|
+
// there) stays a success, and a sixth outcome added later fails closed.
|
|
5290
|
+
let writeOutcome;
|
|
5291
|
+
try {
|
|
5292
|
+
writeOutcome = writeProjectLayoutYaml(newLayout);
|
|
5293
|
+
} catch (e) {
|
|
5294
|
+
// Still reachable, and no longer dead: the serializers throw DELIBERATELY
|
|
5295
|
+
// (the closed backend enum, the confluence identity guard). A throw is a
|
|
5296
|
+
// different fact from a returned failure, but it has the same consequence
|
|
5297
|
+
// - nothing reached disk - so it is mapped onto the same outcome.
|
|
5298
|
+
console.log(chalk.yellow(
|
|
5299
|
+
` Warning: could not write _bmad-output/project-layout.yaml: ${e.message}`
|
|
5300
|
+
));
|
|
5301
|
+
writeOutcome = { outcome: LAYOUT_WRITE_FAILED_WRITE, error: e };
|
|
5302
|
+
}
|
|
5303
|
+
|
|
5304
|
+
if (!projectLayoutWriteIsBound(writeOutcome)) {
|
|
5305
|
+
// ABORT. Every step below this line acts on a binding that is not on disk.
|
|
5306
|
+
//
|
|
5307
|
+
// Generating `config.yaml` here is what produced a config with NO
|
|
5308
|
+
// `project-layout.yaml` behind it - precisely the drifted state Story
|
|
5309
|
+
// 31.4's guard exists to catch (verified: that guard HALTS on it with
|
|
5310
|
+
// `halt:binding-missing`) - while this command reported success. And
|
|
5311
|
+
// because `recordBoundProject` replaces an `unreadable` registry
|
|
5312
|
+
// WHOLESALE, registering a never-bound project could also discard the
|
|
5313
|
+
// records of every project that genuinely is bound.
|
|
5314
|
+
const why = (writeOutcome && writeOutcome.outcome) || 'unknown';
|
|
5315
|
+
console.log(chalk.red(
|
|
5316
|
+
`\n NOT BOUND (${why}). _bmad-output/project-layout.yaml was not written, so\n` +
|
|
5317
|
+
` nothing else was changed either: _bmad/bmm/config.yaml was NOT regenerated\n` +
|
|
5318
|
+
` and this project was NOT recorded. Fix the cause reported above, then run\n` +
|
|
5319
|
+
` \`${NAME} bind\` again.`
|
|
5320
|
+
));
|
|
5321
|
+
process.exitCode = 1;
|
|
5322
|
+
return { bound: false, reason: why };
|
|
5323
|
+
}
|
|
5324
|
+
layoutForContext = newLayout;
|
|
5325
|
+
} else {
|
|
5326
|
+
layoutForContext = readExistingLayout();
|
|
5327
|
+
}
|
|
5328
|
+
|
|
5329
|
+
// ── Step 4. AC6 — regenerate `config.yaml` from the binding.
|
|
5330
|
+
//
|
|
5331
|
+
// This runs on the no-edit path TOO, and that is load-bearing rather than
|
|
5332
|
+
// sloppy: Story 31.4's staleness HALT tells the user "Run `ma-agents bind` to
|
|
5333
|
+
// regenerate `_bmad/bmm/config.yaml` from the committed binding". A bind that
|
|
5334
|
+
// skipped generation whenever the binding was unchanged would leave that
|
|
5335
|
+
// instruction false in the one situation it is given in — a config that has
|
|
5336
|
+
// drifted from a binding nobody edited.
|
|
5337
|
+
//
|
|
5338
|
+
// It does not break AC4's byte-identity either: `generateBmmConfig` derives
|
|
5339
|
+
// every field from the committed binding and carries no timestamp, so
|
|
5340
|
+
// regenerating from an unchanged binding reproduces the same bytes (NFR81's
|
|
5341
|
+
// determinism). AC4 is proved on that basis, not on a promise that nothing ran.
|
|
5342
|
+
//
|
|
5343
|
+
// `fallbackLayout` is supplied for the unbound-and-unwritable case only; when a
|
|
5344
|
+
// committed binding exists it wins outright inside `generateBmmConfig`.
|
|
5345
|
+
generateBmmConfig({ projectRoot, fallbackLayout: layoutForContext });
|
|
5346
|
+
// Story 31.15. `bind` is also the documented remedy for §4.6 R3's out-of-band
|
|
5347
|
+
// case — a user who runs bmad-method's own installer directly gets the stale
|
|
5348
|
+
// block restored, and this is the command that regenerates it.
|
|
5349
|
+
generateConfluencePublishHooks({ projectRoot, fallbackLayout: layoutForContext });
|
|
5350
|
+
|
|
5351
|
+
// ── Step 5. AC1/AC5 — project-context.md.
|
|
5352
|
+
//
|
|
5353
|
+
// AC5 is a hard NEVER-OVERWRITE, stronger than the installer's behaviour: the
|
|
5354
|
+
// installer updates an existing file's manifest paths in place, bind does not
|
|
5355
|
+
// touch it at all. "Left untouched" means untouched.
|
|
5356
|
+
const contextPath = path.join(projectRoot, '_bmad-output', 'project-context.md');
|
|
5357
|
+
if (fs.existsSync(contextPath)) {
|
|
5358
|
+
console.log(chalk.gray(
|
|
5359
|
+
' _bmad-output/project-context.md already exists — left untouched, generation skipped.'
|
|
5360
|
+
));
|
|
5361
|
+
} else {
|
|
5362
|
+
try {
|
|
5363
|
+
// Agents are DISCOVERED, never installed. `getStatus` reads the manifests
|
|
5364
|
+
// that are already on disk at both scopes; bind installs nothing (AC2).
|
|
5365
|
+
const installedAgents = getStatus([], '', 'all').map((e) => e.agent);
|
|
5366
|
+
const seen = new Set();
|
|
5367
|
+
const uniqueAgents = installedAgents.filter((a) => {
|
|
5368
|
+
if (!a || seen.has(a.id)) return false;
|
|
5369
|
+
seen.add(a.id);
|
|
5370
|
+
return true;
|
|
5371
|
+
});
|
|
5372
|
+
const content = await generateProjectContext(projectRoot, uniqueAgents, layoutForContext);
|
|
5373
|
+
fs.mkdirSync(path.join(projectRoot, '_bmad-output'), { recursive: true });
|
|
5374
|
+
fs.writeFileSync(contextPath, content, 'utf-8');
|
|
5375
|
+
console.log(chalk.gray(' Wrote _bmad-output/project-context.md'));
|
|
5376
|
+
} catch (e) {
|
|
5377
|
+
console.log(chalk.yellow(` project-context.md generation skipped: ${e.message}`));
|
|
5378
|
+
}
|
|
5379
|
+
}
|
|
5380
|
+
|
|
5381
|
+
// ── Step 6. AC8 — record this project in the PER-MACHINE registry.
|
|
5382
|
+
//
|
|
5383
|
+
// LAST, and non-fatally. The registry is a display cache for `status` and
|
|
5384
|
+
// nothing else reads it; a failure here must never turn a successful bind into
|
|
5385
|
+
// a failed one, and losing the file entirely costs `status` output and nothing
|
|
5386
|
+
// more. See the OQ-1 decision recorded in lib/bound-projects.js.
|
|
5387
|
+
const recorded = recordBoundProject(projectRoot);
|
|
5388
|
+
if (!recorded.ok) {
|
|
5389
|
+
console.log(chalk.gray(
|
|
5390
|
+
` (could not update the per-machine bound-project record: ${recorded.message} — ` +
|
|
5391
|
+
`this affects \`${NAME} status\` output only)`
|
|
5392
|
+
));
|
|
5393
|
+
}
|
|
5394
|
+
|
|
5395
|
+
console.log(chalk.bold.green('\n Bound.\n'));
|
|
5396
|
+
return { bound: true, reason: editAnswers ? 'written' : 'unchanged' };
|
|
5397
|
+
}
|
|
5398
|
+
|
|
5399
|
+
/**
|
|
5400
|
+
* AC7 — which scope(s) ma-agents is actually installed at on this machine.
|
|
5401
|
+
*
|
|
5402
|
+
* Derived from WHERE a manifest with skills was found, not from what a manifest
|
|
5403
|
+
* says about itself: the path is a fact, a recorded field is a claim. (The claim
|
|
5404
|
+
* is separately kept honest — `ensureManifest` now back-fills `scope` on its
|
|
5405
|
+
* migrate branch, which it previously set only when creating a manifest.)
|
|
5406
|
+
*
|
|
5407
|
+
* @returns {{project: boolean, global: boolean, label: string}}
|
|
5408
|
+
*/
|
|
5409
|
+
function detectInstallScopes() {
|
|
5410
|
+
let project = false;
|
|
5411
|
+
let global = false;
|
|
5412
|
+
for (const entry of getStatus([], '', 'all')) {
|
|
5413
|
+
if (entry.scope === 'global') global = true;
|
|
5414
|
+
if (entry.scope === 'project') project = true;
|
|
5415
|
+
}
|
|
5416
|
+
let label;
|
|
5417
|
+
if (project && global) label = 'project + global';
|
|
5418
|
+
else if (global) label = 'global';
|
|
5419
|
+
else if (project) label = 'project';
|
|
5420
|
+
else label = 'none detected';
|
|
5421
|
+
return { project, global, label };
|
|
5422
|
+
}
|
|
5423
|
+
|
|
5424
|
+
/**
|
|
5425
|
+
* AC7 — join the per-machine bound-project record against what is actually on
|
|
5426
|
+
* disk at each recorded root.
|
|
5427
|
+
*
|
|
5428
|
+
* The health of each entry is decided by `resolveProjectTopology` — the SAME
|
|
5429
|
+
* resolver every other consumer uses — so "readable" here means readable to the
|
|
5430
|
+
* thing that will actually read it, not to a private re-implementation. A project
|
|
5431
|
+
* that was bound and has since been moved or deleted therefore shows up as
|
|
5432
|
+
* `missing`/`unreadable` rather than being listed as healthy.
|
|
5433
|
+
*
|
|
5434
|
+
* @returns {Array<{root: string, boundAt: string, ok: boolean, state: string, detail: string}>}
|
|
5435
|
+
*/
|
|
5436
|
+
function describeBoundProjects() {
|
|
5437
|
+
const registry = readBoundProjects();
|
|
5438
|
+
if (!registry.ok) return [];
|
|
5439
|
+
return registry.projects.map((p) => {
|
|
5440
|
+
if (!fs.existsSync(p.root)) {
|
|
5441
|
+
return {
|
|
5442
|
+
root: p.root,
|
|
5443
|
+
boundAt: p.boundAt,
|
|
5444
|
+
ok: false,
|
|
5445
|
+
state: 'missing',
|
|
5446
|
+
detail: 'the project root no longer exists (moved or deleted)',
|
|
5447
|
+
};
|
|
5448
|
+
}
|
|
5449
|
+
const binding = resolveProjectTopology(p.root);
|
|
5450
|
+
if (binding.ok) {
|
|
5451
|
+
return {
|
|
5452
|
+
root: p.root,
|
|
5453
|
+
boundAt: p.boundAt,
|
|
5454
|
+
ok: true,
|
|
5455
|
+
state: 'bound',
|
|
5456
|
+
detail: 'readable _bmad-output/project-layout.yaml',
|
|
5457
|
+
};
|
|
5458
|
+
}
|
|
5459
|
+
return {
|
|
5460
|
+
root: p.root,
|
|
5461
|
+
boundAt: p.boundAt,
|
|
5462
|
+
ok: false,
|
|
5463
|
+
state: binding.reason,
|
|
5464
|
+
detail: binding.message,
|
|
5465
|
+
};
|
|
5466
|
+
});
|
|
5467
|
+
}
|
|
5468
|
+
|
|
5469
|
+
/**
|
|
5470
|
+
* AC7's rendering. Split out from `showStatus` so the scope report can be
|
|
5471
|
+
* exercised without driving the whole skills listing.
|
|
5472
|
+
*/
|
|
5473
|
+
function printScopeReport(scopes) {
|
|
5474
|
+
console.log(chalk.bold(`\n Install scope: ${scopes.label}`));
|
|
5475
|
+
|
|
5476
|
+
if (!scopes.global) return;
|
|
5477
|
+
|
|
5478
|
+
const registry = readBoundProjects();
|
|
5479
|
+
if (!registry.ok) {
|
|
5480
|
+
// AC8 — a lost or damaged registry is a DISPLAY degradation and says so. It
|
|
5481
|
+
// is never reported as a broken install, and the two reasons are not
|
|
5482
|
+
// conflated: "nothing has been bound on this machine" and "there is a record
|
|
5483
|
+
// here this build cannot read" are different facts.
|
|
5484
|
+
const line = registry.reason === 'absent'
|
|
5485
|
+
? ` No projects recorded as bound on this machine (${registry.path}).`
|
|
5486
|
+
: ` The per-machine bound-project record could not be read: ${registry.message}`;
|
|
5487
|
+
console.log(chalk.gray(line));
|
|
5488
|
+
console.log(chalk.gray(
|
|
5489
|
+
' This affects this listing only — every project still resolves its own binding\n' +
|
|
5490
|
+
' from _bmad-output/project-layout.yaml at its root.'
|
|
5491
|
+
));
|
|
5492
|
+
return;
|
|
5493
|
+
}
|
|
5494
|
+
|
|
5495
|
+
const entries = describeBoundProjects();
|
|
5496
|
+
if (entries.length === 0) {
|
|
5497
|
+
console.log(chalk.gray(` No projects recorded as bound on this machine (${registry.path}).`));
|
|
5498
|
+
return;
|
|
5499
|
+
}
|
|
5500
|
+
|
|
5501
|
+
console.log(chalk.white('\n Bound projects on this machine:'));
|
|
5502
|
+
for (const e of entries) {
|
|
5503
|
+
if (e.ok) {
|
|
5504
|
+
console.log(chalk.green(` [bound] ${e.root}`));
|
|
5505
|
+
} else {
|
|
5506
|
+
console.log(chalk.yellow(` [${e.state}] ${e.root}`));
|
|
5507
|
+
console.log(chalk.gray(` ${e.detail}`));
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5510
|
+
}
|
|
5511
|
+
|
|
1986
5512
|
// --- Profile reconfigure (Story 21.10) ---
|
|
1987
5513
|
async function handleReconfigure(args) {
|
|
1988
5514
|
try {
|
|
@@ -2009,6 +5535,443 @@ async function handleReconfigure(args) {
|
|
|
2009
5535
|
}
|
|
2010
5536
|
}
|
|
2011
5537
|
|
|
5538
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
5539
|
+
// STORY 31.10 (P5-9.7; completes FR255, FR256, FR260) — THE CONFLUENCE PAGE
|
|
5540
|
+
// TREE, AND THE ONE FIELD THIS LAYER LEARNS.
|
|
5541
|
+
//
|
|
5542
|
+
// The mapping, the closed operation set and the idempotent write live in
|
|
5543
|
+
// `lib/confluence-page-tree.js`, which requires nothing — no `fs`, no `net` — so
|
|
5544
|
+
// it cannot write a local copy and cannot become the mirror FR266 retired. What
|
|
5545
|
+
// lives HERE is the part that needs the project on disk:
|
|
5546
|
+
//
|
|
5547
|
+
// - resolving which directory a store's markdown artifacts are read FROM
|
|
5548
|
+
// (Story 31.6's routing table §2, one rule, applied per store);
|
|
5549
|
+
// - enumerating those artifacts and handing their bytes to the planner;
|
|
5550
|
+
// - `ma-agents confluence-plan`, which prints the enumerated operations an
|
|
5551
|
+
// agent must perform through the Confluence MCP — this process cannot
|
|
5552
|
+
// perform them itself, because the MCP lives in the agent's tool context and
|
|
5553
|
+
// not in Node;
|
|
5554
|
+
// - `recordLearnedRootPageId`, the OQ-4 write-back.
|
|
5555
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
5556
|
+
|
|
5557
|
+
const confluencePageTree = require('../lib/confluence-page-tree');
|
|
5558
|
+
|
|
5559
|
+
/**
|
|
5560
|
+
* Which `_bmad-output/project-layout.yaml` record backs a publishing store, and
|
|
5561
|
+
* what the user-facing label is. Keyed by the store ids the page-tree module
|
|
5562
|
+
* defines, so a store added there without a record here fails at load rather
|
|
5563
|
+
* than at the moment a user runs the command.
|
|
5564
|
+
*/
|
|
5565
|
+
const CONFLUENCE_STORE_RECORDS = Object.freeze({
|
|
5566
|
+
[confluencePageTree.SYSTEM_REQUIREMENTS]: Object.freeze({
|
|
5567
|
+
topologyKey: 'systemRequirementsStore',
|
|
5568
|
+
layoutKey: 'systemRequirementsStore',
|
|
5569
|
+
cliName: 'system',
|
|
5570
|
+
}),
|
|
5571
|
+
[confluencePageTree.SOFTWARE_REQUIREMENTS]: Object.freeze({
|
|
5572
|
+
topologyKey: 'softwareRequirementsStore',
|
|
5573
|
+
layoutKey: 'softwareRequirementsStore',
|
|
5574
|
+
cliName: 'software',
|
|
5575
|
+
}),
|
|
5576
|
+
});
|
|
5577
|
+
|
|
5578
|
+
for (const storeId of confluencePageTree.PUBLISHING_STORES) {
|
|
5579
|
+
if (!CONFLUENCE_STORE_RECORDS[storeId]) {
|
|
5580
|
+
throw new Error(
|
|
5581
|
+
`bin/cli.js: publishing store ${JSON.stringify(storeId)} has no CONFLUENCE_STORE_RECORDS ` +
|
|
5582
|
+
`entry, so its binding record cannot be located.`
|
|
5583
|
+
);
|
|
5584
|
+
}
|
|
5585
|
+
}
|
|
5586
|
+
|
|
5587
|
+
/**
|
|
5588
|
+
* The artifacts directory a store's markdown is read from.
|
|
5589
|
+
*
|
|
5590
|
+
* ONE rule, stated once, exactly as Story 31.6's routing table §2 states it for
|
|
5591
|
+
* all three destinations: the store's path when it is not `.`; otherwise the
|
|
5592
|
+
* canonical local planning folder. A Confluence-backed store records no path at
|
|
5593
|
+
* all (NFR82 — resolved artifact paths must not move), so it takes the `.` branch
|
|
5594
|
+
* and reads from `_bmad-output/planning-artifacts`, which is where the markdown a
|
|
5595
|
+
* publish PUBLISHES actually sits.
|
|
5596
|
+
*
|
|
5597
|
+
* This is the publish direction and only the publish direction. Nothing is read
|
|
5598
|
+
* back from Confluence into this directory, nothing here is reconciled against
|
|
5599
|
+
* anything there, and no file in it is written by any part of Story 31.10.
|
|
5600
|
+
*/
|
|
5601
|
+
function storeArtifactsDirectory(store, projectRoot) {
|
|
5602
|
+
const root = path.resolve(projectRoot || process.cwd());
|
|
5603
|
+
const stored = store && typeof store.path === 'string' ? store.path.trim() : '';
|
|
5604
|
+
if (stored && stored !== '.') return resolveStoredPath(stored, root);
|
|
5605
|
+
return path.join(root, '_bmad-output', 'planning-artifacts');
|
|
5606
|
+
}
|
|
5607
|
+
|
|
5608
|
+
/**
|
|
5609
|
+
* Every `.md` file under `dir`, relative to `dir`, POSIX-separated, with its
|
|
5610
|
+
* bytes. Sorted, so a plan is byte-identical on two machines.
|
|
5611
|
+
*
|
|
5612
|
+
* Reads only. Creates nothing — a missing directory yields an empty list rather
|
|
5613
|
+
* than being created, because creating it would be this story writing locally.
|
|
5614
|
+
*/
|
|
5615
|
+
function collectStoreArtifactCandidates(dir) {
|
|
5616
|
+
const out = [];
|
|
5617
|
+
const walk = (abs, rel) => {
|
|
5618
|
+
let entries;
|
|
5619
|
+
try {
|
|
5620
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
5621
|
+
} catch (e) {
|
|
5622
|
+
return;
|
|
5623
|
+
}
|
|
5624
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
5625
|
+
if (entry.name.startsWith('.')) continue;
|
|
5626
|
+
const childAbs = path.join(abs, entry.name);
|
|
5627
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
5628
|
+
if (entry.isDirectory()) {
|
|
5629
|
+
walk(childAbs, childRel);
|
|
5630
|
+
} else if (/\.md$/i.test(entry.name)) {
|
|
5631
|
+
let body;
|
|
5632
|
+
try {
|
|
5633
|
+
body = fs.readFileSync(childAbs, 'utf-8');
|
|
5634
|
+
} catch (e) {
|
|
5635
|
+
continue;
|
|
5636
|
+
}
|
|
5637
|
+
out.push({ path: childRel, body });
|
|
5638
|
+
}
|
|
5639
|
+
}
|
|
5640
|
+
};
|
|
5641
|
+
if (fs.existsSync(dir)) walk(dir, '');
|
|
5642
|
+
return out;
|
|
5643
|
+
}
|
|
5644
|
+
|
|
5645
|
+
/** The outcomes of an OQ-4 write-back, as values. Same discipline as LAYOUT_WRITE_*. */
|
|
5646
|
+
const ROOT_ID_RECORDED = 'root-id-recorded';
|
|
5647
|
+
const ROOT_ID_UNCHANGED = 'root-id-unchanged';
|
|
5648
|
+
const ROOT_ID_TITLE_MOVED = 'root-id-title-moved';
|
|
5649
|
+
const ROOT_ID_NOT_CONFLUENCE = 'root-id-not-a-confluence-store';
|
|
5650
|
+
const ROOT_ID_NO_BINDING = 'root-id-no-binding';
|
|
5651
|
+
const ROOT_ID_WRITE_FAILED = 'root-id-write-failed';
|
|
5652
|
+
|
|
5653
|
+
/**
|
|
5654
|
+
* OQ-4's write half. Story 31.8 records the root page as a human-readable TITLE
|
|
5655
|
+
* with `id: null`, because turning a title into an id requires asking Confluence
|
|
5656
|
+
* and 31.8 must complete with no network. THIS is the layer that asked, so this
|
|
5657
|
+
* is the layer that fills the id in.
|
|
5658
|
+
*
|
|
5659
|
+
* THE RULE, settled at 31.8 and not re-derived here: a learned id is carried
|
|
5660
|
+
* forward ONLY while the title is unchanged, and dropped on retitle — a
|
|
5661
|
+
* `{title, id}` pair naming two different pages looks resolved and points where
|
|
5662
|
+
* the user did not choose. The read half of that rule lives in
|
|
5663
|
+
* `collectConfluenceStoreLocation`; this is the write half, and it refuses
|
|
5664
|
+
* (ROOT_ID_TITLE_MOVED) rather than stamping an id onto a title it does not
|
|
5665
|
+
* belong to.
|
|
5666
|
+
*
|
|
5667
|
+
* Returns an OUTCOME, never void. Story 31.13's blocking defect was a caller that
|
|
5668
|
+
* printed "Bound." because its callee swallowed a failed write and returned
|
|
5669
|
+
* nothing; a function whose failure is invisible to its caller is the shape, not
|
|
5670
|
+
* the instance.
|
|
5671
|
+
*/
|
|
5672
|
+
function recordLearnedRootPageId(opts) {
|
|
5673
|
+
const o = opts || {};
|
|
5674
|
+
const projectRoot = path.resolve(o.projectRoot || process.cwd());
|
|
5675
|
+
const storeId = o.storeId;
|
|
5676
|
+
const record = CONFLUENCE_STORE_RECORDS[storeId];
|
|
5677
|
+
if (!record) {
|
|
5678
|
+
throw new Error(
|
|
5679
|
+
`recordLearnedRootPageId: ${JSON.stringify(storeId)} is not a publishing store.`
|
|
5680
|
+
);
|
|
5681
|
+
}
|
|
5682
|
+
const learnedId = String(o.id == null ? '' : o.id).trim();
|
|
5683
|
+
const publishedTitle = String(o.title == null ? '' : o.title).trim();
|
|
5684
|
+
if (!learnedId || !publishedTitle) {
|
|
5685
|
+
throw new Error(
|
|
5686
|
+
`recordLearnedRootPageId: both the page title and the learned id are required; ` +
|
|
5687
|
+
`refusing to record ${JSON.stringify({ title: o.title, id: o.id })}.`
|
|
5688
|
+
);
|
|
5689
|
+
}
|
|
5690
|
+
|
|
5691
|
+
const resolved = resolveProjectTopology(projectRoot);
|
|
5692
|
+
if (!resolved.ok) {
|
|
5693
|
+
return { outcome: ROOT_ID_NO_BINDING, reason: resolved.reason, message: resolved.message };
|
|
5694
|
+
}
|
|
5695
|
+
const store = resolved.topology[record.topologyKey];
|
|
5696
|
+
if (backendOfStore(store) !== 'confluence') {
|
|
5697
|
+
return { outcome: ROOT_ID_NOT_CONFLUENCE, storeId };
|
|
5698
|
+
}
|
|
5699
|
+
const currentTitle = String((store.rootPage && store.rootPage.title) || '').trim();
|
|
5700
|
+
if (currentTitle !== publishedTitle) {
|
|
5701
|
+
// The binding was retitled between the plan and the write-back. Stamping the
|
|
5702
|
+
// id here would produce exactly the record OQ-4 forbids.
|
|
5703
|
+
return { outcome: ROOT_ID_TITLE_MOVED, storeId, recordedTitle: currentTitle, publishedTitle };
|
|
5704
|
+
}
|
|
5705
|
+
const currentId = String((store.rootPage && store.rootPage.id) || '').trim();
|
|
5706
|
+
if (currentId === learnedId) return { outcome: ROOT_ID_UNCHANGED, storeId, id: learnedId };
|
|
5707
|
+
|
|
5708
|
+
const updated = { ...store, rootPage: { title: currentTitle, id: learnedId } };
|
|
5709
|
+
const origCwd = process.cwd();
|
|
5710
|
+
let write;
|
|
5711
|
+
try {
|
|
5712
|
+
process.chdir(projectRoot);
|
|
5713
|
+
write = writeProjectLayoutYaml({ [record.layoutKey]: updated });
|
|
5714
|
+
} finally {
|
|
5715
|
+
process.chdir(origCwd);
|
|
5716
|
+
}
|
|
5717
|
+
// `projectLayoutWriteIsBound` takes the RESULT OBJECT, not the outcome string —
|
|
5718
|
+
// it reads `.outcome` itself. Handing it the string makes every write look
|
|
5719
|
+
// failed, which is the same observability defect Story 31.13 fixed, pointing the
|
|
5720
|
+
// other way. Pinned by test, both directions.
|
|
5721
|
+
if (!projectLayoutWriteIsBound(write)) {
|
|
5722
|
+
return { outcome: ROOT_ID_WRITE_FAILED, storeId, write };
|
|
5723
|
+
}
|
|
5724
|
+
return { outcome: ROOT_ID_RECORDED, storeId, id: learnedId, title: currentTitle, write };
|
|
5725
|
+
}
|
|
5726
|
+
|
|
5727
|
+
/**
|
|
5728
|
+
* `ma-agents confluence-plan [--store system|software]`
|
|
5729
|
+
*
|
|
5730
|
+
* Prints the page tree a publish would produce and the enumerated operations it
|
|
5731
|
+
* would perform. It performs NONE of them: the Confluence MCP lives in the
|
|
5732
|
+
* agent's tool context, not in this process, so this command's job is to state
|
|
5733
|
+
* the plan exactly and let the agent execute it through the MCP Story 31.9
|
|
5734
|
+
* detects — under Story 31.9's fail-loud gate, which applies to every operation
|
|
5735
|
+
* in this story.
|
|
5736
|
+
*
|
|
5737
|
+
* It writes nothing, anywhere. Not the plan, not a cache, not a report file.
|
|
5738
|
+
*/
|
|
5739
|
+
async function handleConfluencePlan(args = []) {
|
|
5740
|
+
const projectRoot = process.cwd();
|
|
5741
|
+
const requested = (() => {
|
|
5742
|
+
const i = args.indexOf('--store');
|
|
5743
|
+
return i !== -1 && args[i + 1] ? String(args[i + 1]).trim().toLowerCase() : null;
|
|
5744
|
+
})();
|
|
5745
|
+
|
|
5746
|
+
const resolved = resolveProjectTopology(projectRoot);
|
|
5747
|
+
if (!resolved.ok) {
|
|
5748
|
+
console.error(chalk.red(
|
|
5749
|
+
` ma-agents — stopping: this project has no readable knowledge binding (${resolved.reason}).\n` +
|
|
5750
|
+
` ${resolved.message}`
|
|
5751
|
+
));
|
|
5752
|
+
process.exit(1);
|
|
5753
|
+
}
|
|
5754
|
+
|
|
5755
|
+
const storeIds = confluencePageTree.PUBLISHING_STORES.filter((id) =>
|
|
5756
|
+
!requested || CONFLUENCE_STORE_RECORDS[id].cliName === requested || id === requested);
|
|
5757
|
+
if (storeIds.length === 0) {
|
|
5758
|
+
console.error(chalk.red(
|
|
5759
|
+
` Unknown store ${JSON.stringify(requested)}. Legal values: ` +
|
|
5760
|
+
confluencePageTree.PUBLISHING_STORES
|
|
5761
|
+
.map((id) => CONFLUENCE_STORE_RECORDS[id].cliName).join(', ') + '.'
|
|
5762
|
+
));
|
|
5763
|
+
process.exit(1);
|
|
5764
|
+
}
|
|
5765
|
+
|
|
5766
|
+
// PASS 1 — build every plan before printing any of them, so the cross-store
|
|
5767
|
+
// pre-flight below can see them together. A per-store loop that printed as it
|
|
5768
|
+
// went could only ever check one plan against itself.
|
|
5769
|
+
const built = [];
|
|
5770
|
+
for (const storeId of storeIds) {
|
|
5771
|
+
const record = CONFLUENCE_STORE_RECORDS[storeId];
|
|
5772
|
+
const store = resolved.topology[record.topologyKey];
|
|
5773
|
+
const backend = backendOfStore(store);
|
|
5774
|
+
const label = confluencePageTree.labelForStore(storeId);
|
|
5775
|
+
|
|
5776
|
+
// The gateway check, in the position §2.6 defines: before any read, any write
|
|
5777
|
+
// and any enumeration. Same three branches as Story 31.9's shipped block, and
|
|
5778
|
+
// for the same reasons — this is not a second mechanism.
|
|
5779
|
+
if (backend === DEFAULT_STORE_BACKEND) {
|
|
5780
|
+
if (requested) {
|
|
5781
|
+
console.log(chalk.gray(
|
|
5782
|
+
` The ${label} is ${DEFAULT_STORE_BACKEND}-backed. There is no Confluence page tree ` +
|
|
5783
|
+
`for it, and nothing to publish.`
|
|
5784
|
+
));
|
|
5785
|
+
}
|
|
5786
|
+
continue;
|
|
5787
|
+
}
|
|
5788
|
+
if (backend !== 'confluence') {
|
|
5789
|
+
console.error(chalk.red(
|
|
5790
|
+
` ma-agents — stopping: unrecognised knowledge-store backend.\n` +
|
|
5791
|
+
` Store: the ${label}. Backend: ${JSON.stringify(backend)}. The backends this build ` +
|
|
5792
|
+
`understands are ${legalBackendsForMessage()}.\n` +
|
|
5793
|
+
` Run \`${NAME} bind\` to regenerate the binding, or re-run the installer.\n` +
|
|
5794
|
+
` Nothing has been read or written on your behalf. Stopping rather than guessing.`
|
|
5795
|
+
));
|
|
5796
|
+
process.exit(1);
|
|
5797
|
+
}
|
|
5798
|
+
|
|
5799
|
+
const dir = storeArtifactsDirectory(store, projectRoot);
|
|
5800
|
+
const candidates = collectStoreArtifactCandidates(dir);
|
|
5801
|
+
let selection;
|
|
5802
|
+
let plan;
|
|
5803
|
+
try {
|
|
5804
|
+
selection = confluencePageTree.artifactsForStore(storeId, candidates);
|
|
5805
|
+
plan = confluencePageTree.planStorePublication({
|
|
5806
|
+
storeId,
|
|
5807
|
+
spaceKey: store.spaceKey,
|
|
5808
|
+
rootPage: store.rootPage,
|
|
5809
|
+
artifacts: selection.selected,
|
|
5810
|
+
});
|
|
5811
|
+
} catch (e) {
|
|
5812
|
+
console.error(chalk.red(` ma-agents — stopping: ${e.message}`));
|
|
5813
|
+
process.exit(1);
|
|
5814
|
+
}
|
|
5815
|
+
built.push({ storeId, label, selection, plan });
|
|
5816
|
+
}
|
|
5817
|
+
|
|
5818
|
+
// THE CROSS-STORE PRE-FLIGHT. Nothing stops both stores from being bound to
|
|
5819
|
+
// one Confluence space, and page identity is (space, title) — a space is not
|
|
5820
|
+
// owned by a store. In this very repository the two plans claim ten titles in
|
|
5821
|
+
// common. Checked here, where both plans exist, and BEFORE a single page is
|
|
5822
|
+
// printed as publishable.
|
|
5823
|
+
try {
|
|
5824
|
+
confluencePageTree.assertNoCrossStoreTitleClashes(built.map((b) => b.plan));
|
|
5825
|
+
} catch (e) {
|
|
5826
|
+
console.error(chalk.red(` ma-agents — stopping: ${e.message}`));
|
|
5827
|
+
process.exit(1);
|
|
5828
|
+
}
|
|
5829
|
+
|
|
5830
|
+
// PASS 2 — print.
|
|
5831
|
+
let planned = 0;
|
|
5832
|
+
for (const { storeId, label, selection, plan } of built) {
|
|
5833
|
+
planned++;
|
|
5834
|
+
const rootNode = plan.nodes[0];
|
|
5835
|
+
console.log(chalk.bold.cyan(`\n Confluence page tree — ${label}`));
|
|
5836
|
+
console.log(` Space: ${plan.spaceKey}`);
|
|
5837
|
+
console.log(` Root page: "${rootNode.title}"` +
|
|
5838
|
+
(rootNode.id ? ` (id ${rootNode.id})` : ' (id not yet known — it is resolved, and created if absent, by the first operation below)'));
|
|
5839
|
+
console.log(` Artifacts: ${selection.selected.length} selected, ${selection.excluded.length} not this store's`);
|
|
5840
|
+
console.log(chalk.gray(
|
|
5841
|
+
`\n Operations — the complete set this story performs, and no others:\n` +
|
|
5842
|
+
` ${confluencePageTree.CONFLUENCE_PAGE_OPERATIONS.join(', ')}\n` +
|
|
5843
|
+
` Perform them through the Confluence MCP, in the order below, parents first.\n` +
|
|
5844
|
+
` resolve_page FIRST for every page: found -> update_page, absent -> create_page.\n` +
|
|
5845
|
+
` That is what makes a re-run update the existing pages instead of duplicating them,\n` +
|
|
5846
|
+
` so their history and comments survive. If any write does not report success, STOP\n` +
|
|
5847
|
+
` and report the failure — do not continue and do not report a publish.\n` +
|
|
5848
|
+
(rootNode.id
|
|
5849
|
+
? ` The root page's id is already recorded, so it is addressed by id and NOT looked up.\n`
|
|
5850
|
+
: ` Afterwards, record the id Confluence gave the root page so later runs address it\n` +
|
|
5851
|
+
` directly instead of looking it up again:\n` +
|
|
5852
|
+
` ${NAME} confluence-record-root-page --store ${CONFLUENCE_STORE_RECORDS[storeId].cliName} ` +
|
|
5853
|
+
`--title "${rootNode.title}" --id <the id create_page returned>\n`)
|
|
5854
|
+
));
|
|
5855
|
+
for (const node of plan.nodes) {
|
|
5856
|
+
const depth = node.key === '' ? 0 : node.key.split('/').length;
|
|
5857
|
+
const parent = node.parentKey === null
|
|
5858
|
+
? '(space root)'
|
|
5859
|
+
: `"${(plan.nodes.find((n) => n.key === node.parentKey) || {}).title}"`;
|
|
5860
|
+
console.log(
|
|
5861
|
+
` ${' '.repeat(depth)}${node.role === confluencePageTree.ROLE_ARTIFACT ? '*' : '+'} ` +
|
|
5862
|
+
`"${node.title}" under ${parent}` +
|
|
5863
|
+
(node.sourcePath ? chalk.gray(` <- ${node.sourcePath}`) : chalk.gray(' (container page, no content)'))
|
|
5864
|
+
);
|
|
5865
|
+
}
|
|
5866
|
+
if (selection.excluded.length) {
|
|
5867
|
+
console.log(chalk.gray(`\n Not published by this store:`));
|
|
5868
|
+
for (const ex of selection.excluded) {
|
|
5869
|
+
console.log(chalk.gray(` - ${ex.path} (${ex.reason}${ex.owner ? `: ${confluencePageTree.labelForStore(ex.owner)}` : ''})`));
|
|
5870
|
+
}
|
|
5871
|
+
}
|
|
5872
|
+
}
|
|
5873
|
+
|
|
5874
|
+
if (planned === 0) {
|
|
5875
|
+
console.log(chalk.gray(
|
|
5876
|
+
` No knowledge store in this project is Confluence-backed, so there is no page tree ` +
|
|
5877
|
+
`to publish.`
|
|
5878
|
+
));
|
|
5879
|
+
}
|
|
5880
|
+
console.log('');
|
|
5881
|
+
}
|
|
5882
|
+
|
|
5883
|
+
/**
|
|
5884
|
+
* `ma-agents confluence-record-root-page --store <system|software> --title <t> --id <id>`
|
|
5885
|
+
*
|
|
5886
|
+
* OQ-4's write-back, as a command an agent can actually run — and the reason
|
|
5887
|
+
* `recordLearnedRootPageId` is product behaviour rather than dead code.
|
|
5888
|
+
*
|
|
5889
|
+
* The division of labour this story is built on: this process cannot reach the
|
|
5890
|
+
* Confluence MCP (it lives in the agent's tool context), so `confluence-plan`
|
|
5891
|
+
* states the operations and the AGENT performs them. That leaves one fact
|
|
5892
|
+
* stranded on the agent's side — the id Confluence assigned the root page — and
|
|
5893
|
+
* this command is how it comes back. Without it the id is learned by whoever
|
|
5894
|
+
* published and then forgotten, and every subsequent run re-resolves the root
|
|
5895
|
+
* page by title, which is exactly what OQ-4 recorded the field to avoid.
|
|
5896
|
+
*
|
|
5897
|
+
* Prints the OUTCOME by name, including the refusals. A write-back that quietly
|
|
5898
|
+
* did nothing would be Story 31.13's defect: the caller must be able to tell
|
|
5899
|
+
* "recorded" from "refused because the binding was retitled".
|
|
5900
|
+
*/
|
|
5901
|
+
async function handleConfluenceRecordRootPage(args = []) {
|
|
5902
|
+
const flag = (name) => {
|
|
5903
|
+
const i = args.indexOf(`--${name}`);
|
|
5904
|
+
return i !== -1 && args[i + 1] ? String(args[i + 1]).trim() : '';
|
|
5905
|
+
};
|
|
5906
|
+
const requested = flag('store').toLowerCase();
|
|
5907
|
+
const title = flag('title');
|
|
5908
|
+
const id = flag('id');
|
|
5909
|
+
|
|
5910
|
+
const storeId = confluencePageTree.PUBLISHING_STORES.find(
|
|
5911
|
+
(s) => CONFLUENCE_STORE_RECORDS[s].cliName === requested || s === requested);
|
|
5912
|
+
if (!storeId) {
|
|
5913
|
+
console.error(chalk.red(
|
|
5914
|
+
` ma-agents — stopping: --store must name a publishing store. Legal values: ` +
|
|
5915
|
+
confluencePageTree.PUBLISHING_STORES
|
|
5916
|
+
.map((s) => CONFLUENCE_STORE_RECORDS[s].cliName).join(', ') + '.'
|
|
5917
|
+
));
|
|
5918
|
+
process.exit(1);
|
|
5919
|
+
}
|
|
5920
|
+
if (!title || !id) {
|
|
5921
|
+
console.error(chalk.red(
|
|
5922
|
+
` ma-agents — stopping: both --title and --id are required.\n` +
|
|
5923
|
+
` They must be the root page's title and the page id Confluence returned for it. ` +
|
|
5924
|
+
`Recording an id against the wrong title is the record OQ-4 exists to prevent.`
|
|
5925
|
+
));
|
|
5926
|
+
process.exit(1);
|
|
5927
|
+
}
|
|
5928
|
+
|
|
5929
|
+
let result;
|
|
5930
|
+
try {
|
|
5931
|
+
result = recordLearnedRootPageId({ projectRoot: process.cwd(), storeId, title, id });
|
|
5932
|
+
} catch (e) {
|
|
5933
|
+
console.error(chalk.red(` ma-agents — stopping: ${e.message}`));
|
|
5934
|
+
process.exit(1);
|
|
5935
|
+
}
|
|
5936
|
+
|
|
5937
|
+
const label = confluencePageTree.labelForStore(storeId);
|
|
5938
|
+
if (result.outcome === ROOT_ID_RECORDED) {
|
|
5939
|
+
console.log(chalk.green(
|
|
5940
|
+
` Recorded: the ${label}'s root page "${result.title}" is page ${result.id}.\n` +
|
|
5941
|
+
` Later publishes address it by that id and skip the root-page lookup.`
|
|
5942
|
+
));
|
|
5943
|
+
} else if (result.outcome === ROOT_ID_UNCHANGED) {
|
|
5944
|
+
console.log(chalk.gray(` Already recorded: the ${label}'s root page is page ${result.id}. Nothing written.`));
|
|
5945
|
+
} else if (result.outcome === ROOT_ID_TITLE_MOVED) {
|
|
5946
|
+
console.error(chalk.red(
|
|
5947
|
+
` ma-agents — stopping: the ${label}'s root page is recorded as ` +
|
|
5948
|
+
`"${result.recordedTitle}", but the id offered is for "${result.publishedTitle}".\n` +
|
|
5949
|
+
` A {title, id} pair naming two different pages looks resolved and points where you did ` +
|
|
5950
|
+
`not choose, so nothing was written. Re-run \`${NAME} confluence-plan\` and publish the ` +
|
|
5951
|
+
`root page the binding actually names.`
|
|
5952
|
+
));
|
|
5953
|
+
process.exit(1);
|
|
5954
|
+
} else if (result.outcome === ROOT_ID_NOT_CONFLUENCE) {
|
|
5955
|
+
console.error(chalk.red(
|
|
5956
|
+
` ma-agents — stopping: the ${label} is not Confluence-backed, so it has no page id to record.`
|
|
5957
|
+
));
|
|
5958
|
+
process.exit(1);
|
|
5959
|
+
} else if (result.outcome === ROOT_ID_NO_BINDING) {
|
|
5960
|
+
console.error(chalk.red(
|
|
5961
|
+
` ma-agents — stopping: this project has no readable knowledge binding (${result.reason}).\n` +
|
|
5962
|
+
` ${result.message}`
|
|
5963
|
+
));
|
|
5964
|
+
process.exit(1);
|
|
5965
|
+
} else {
|
|
5966
|
+
// ROOT_ID_WRITE_FAILED, and anything a later outcome adds. Never silent.
|
|
5967
|
+
console.error(chalk.red(
|
|
5968
|
+
` ma-agents — stopping: the id was NOT recorded (${result.outcome}). ` +
|
|
5969
|
+
`_bmad-output/project-layout.yaml is unchanged, so a later run will try again.`
|
|
5970
|
+
));
|
|
5971
|
+
process.exit(1);
|
|
5972
|
+
}
|
|
5973
|
+
}
|
|
5974
|
+
|
|
2012
5975
|
// --- Interactive mode ---
|
|
2013
5976
|
|
|
2014
5977
|
async function interactiveMode() {
|
|
@@ -2033,7 +5996,7 @@ async function interactiveMode() {
|
|
|
2033
5996
|
}
|
|
2034
5997
|
|
|
2035
5998
|
if (action === 'status') {
|
|
2036
|
-
showStatus([]);
|
|
5999
|
+
await showStatus([]);
|
|
2037
6000
|
process.exit(0);
|
|
2038
6001
|
}
|
|
2039
6002
|
|
|
@@ -2079,7 +6042,26 @@ async function main() {
|
|
|
2079
6042
|
await handleUninstall(args.slice(1));
|
|
2080
6043
|
break;
|
|
2081
6044
|
case 'status':
|
|
2082
|
-
showStatus(args.slice(1));
|
|
6045
|
+
await showStatus(args.slice(1));
|
|
6046
|
+
break;
|
|
6047
|
+
// Story 31.13 (FR252). Story 31.4's preflight — embedded in every shipped
|
|
6048
|
+
// skill document — instructs the user to run this command in five separate
|
|
6049
|
+
// HALT messages. Until this case existed, all five named a command that did
|
|
6050
|
+
// not dispatch.
|
|
6051
|
+
case 'bind':
|
|
6052
|
+
await handleBind(args.slice(1));
|
|
6053
|
+
break;
|
|
6054
|
+
// Story 31.10 (P5-9.7). Prints the page tree and the enumerated operations a
|
|
6055
|
+
// publish performs; performs none of them, because the Confluence MCP lives
|
|
6056
|
+
// in the agent's tool context and not in this process.
|
|
6057
|
+
case 'confluence-plan':
|
|
6058
|
+
await handleConfluencePlan(args.slice(1));
|
|
6059
|
+
break;
|
|
6060
|
+
// OQ-4's write-back. The agent performs the operations, so the agent is the
|
|
6061
|
+
// only party that learns the root page's id; this is how that id comes back
|
|
6062
|
+
// into the binding instead of being learned and forgotten every run.
|
|
6063
|
+
case 'confluence-record-root-page':
|
|
6064
|
+
await handleConfluenceRecordRootPage(args.slice(1));
|
|
2083
6065
|
break;
|
|
2084
6066
|
case 'list':
|
|
2085
6067
|
case 'list-skills':
|
|
@@ -2141,4 +6123,99 @@ if (require.main === module) {
|
|
|
2141
6123
|
});
|
|
2142
6124
|
}
|
|
2143
6125
|
|
|
2144
|
-
module.exports = { parseFlags, selectBmadModules, promptCustomMarketplace, reconcileCustomMarketplaceOnUpdate, resolveBaseBmadOk, collectRepoLayout, readExistingLayout, writeRepoLayoutConfig, writeProjectLayoutYaml, writeConfigField, normalizePath, toPortablePath, resolveStoredPath, ciCloneIfNeeded, showCurrentLayout, handleConfigLayout, yamlEscapeValue
|
|
6126
|
+
module.exports = { parseFlags, selectBmadModules, promptCustomMarketplace, reconcileCustomMarketplaceOnUpdate, resolveBaseBmadOk, collectRepoLayout, readExistingLayout, writeRepoLayoutConfig, writeProjectLayoutYaml, writeConfigField, normalizePath, toPortablePath, resolveStoredPath, ciCloneIfNeeded, showCurrentLayout, handleConfigLayout, yamlEscapeValue, yamlUnescapeDoubleQuoted, resolveProjectTopology, parseLayoutYaml, PROJECT_LAYOUT_SCHEMA_VERSION, BINDING_UNBOUND, BINDING_UNREADABLE, BINDING_INVALID, BINDING_TOO_NEW,
|
|
6127
|
+
// Story 31.13 fix round - the outcome of an authoritative write, as a value.
|
|
6128
|
+
// Exported for the same reason the BINDING_* reasons are: a test must branch on
|
|
6129
|
+
// the outcome BY NAME, so "skipped because the layout is legacy" and "failed to
|
|
6130
|
+
// write" can never be collapsed back into one. `projectLayoutWriteIsBound` is
|
|
6131
|
+
// exported so the bound/unbound partition is asserted against the SHIPPED
|
|
6132
|
+
// predicate rather than against a copy of it in a test.
|
|
6133
|
+
LAYOUT_WRITE_WROTE, LAYOUT_WRITE_UNCHANGED_LEGACY, LAYOUT_WRITE_REFUSED,
|
|
6134
|
+
LAYOUT_WRITE_FAILED_MKDIR, LAYOUT_WRITE_FAILED_WRITE, projectLayoutWriteIsBound,
|
|
6135
|
+
// Story 31.3 — the config.yaml generator and its stamp. `generateBmmConfig` is
|
|
6136
|
+
// the entry point Story 31.13's `ma-agents bind` calls; `computeProjectLayoutStamp`
|
|
6137
|
+
// and `readProjectLayoutStamp` are the PAIR Story 31.4's drift guard consumes, so
|
|
6138
|
+
// that 31.4 depends on two functions rather than on a hash format it re-implements.
|
|
6139
|
+
// `canonicalBindingBytes` is exported so a test can assert WHAT the stamp covers,
|
|
6140
|
+
// not merely that two digests differ. `STAMP_ABSENT` / `STAMP_MALFORMED` are
|
|
6141
|
+
// exported for the same reason `BINDING_*` are: 31.4 must branch on the reason
|
|
6142
|
+
// by NAME, so the two failure states can never be collapsed back into one.
|
|
6143
|
+
generateBmmConfig, computeProjectLayoutStamp, readProjectLayoutStamp, canonicalBindingBytes,
|
|
6144
|
+
STAMP_ABSENT, STAMP_MALFORMED,
|
|
6145
|
+
topologyFromLegacyLayout, PROJECT_LAYOUT_STAMP_FIELD, PROJECT_LAYOUT_STAMP_PREFIX,
|
|
6146
|
+
// Story 31.12 (FR250) — exported so the tone/content test binds to the SHIPPED
|
|
6147
|
+
// strings rather than to copies of them, which could drift apart silently.
|
|
6148
|
+
GLOBAL_BMAD_SCOPE_NOTICE_FACT_1, GLOBAL_BMAD_SCOPE_NOTICE_FACT_2, GLOBAL_BMAD_SCOPE_NOTICE_FACT_3,
|
|
6149
|
+
// Story 31.7 (FR265) — legacy detection, and the notice bound to its SHIPPED
|
|
6150
|
+
// strings for the same reason as the 31.12 constants above. The split command
|
|
6151
|
+
// is its own export so AC2's "the exact command" claim is checkable against
|
|
6152
|
+
// one string rather than against a regex over prose.
|
|
6153
|
+
detectLegacySingleStore, showLegacySingleStoreNoticeOnce,
|
|
6154
|
+
LEGACY_SINGLE_STORE_NOTICE_ID, LEGACY_SINGLE_STORE_SPLIT_COMMAND, LEGACY_TWO_STORE,
|
|
6155
|
+
LEGACY_SINGLE_STORE_NOTICE_FACT_1, LEGACY_SINGLE_STORE_NOTICE_FACT_2,
|
|
6156
|
+
LEGACY_SINGLE_STORE_NOTICE_FACT_3,
|
|
6157
|
+
// COPIES, not the live Sets. These are the single source of truth for what a
|
|
6158
|
+
// store's `mode`/`backend` may be, consumed by the reader, the writer and the
|
|
6159
|
+
// wizard alike; handing out the real objects would let any consumer `.add()`
|
|
6160
|
+
// a member and silently widen all three at once. Copies are inert.
|
|
6161
|
+
STORE_MODES: new Set(STORE_MODES), SPRINT_MODES: new Set(SPRINT_MODES),
|
|
6162
|
+
STORE_BACKENDS: new Set(STORE_BACKENDS),
|
|
6163
|
+
MODE_PROMPT_TITLES, modeChoicesFor, legalBackendsForMessage,
|
|
6164
|
+
// The per-backend identity view and everything derived from it. Exported so the
|
|
6165
|
+
// suite can pin that STORE_BACKEND_VIEWS names exactly STORE_BACKENDS — a
|
|
6166
|
+
// backend with no view would otherwise be discovered by a user, at the moment
|
|
6167
|
+
// the renderer throws. The table itself is frozen (entries included), so
|
|
6168
|
+
// handing out the real object cannot let a consumer redefine what identifies a
|
|
6169
|
+
// store.
|
|
6170
|
+
STORE_BACKEND_VIEWS, DEFAULT_STORE_BACKEND, backendOfStore, storeIdentityFields,
|
|
6171
|
+
sprintIdentityFields, storesDiverge, renderIdentityValue, summarizeStoreIdentity,
|
|
6172
|
+
IDENTITY_NOT_CONFIGURED,
|
|
6173
|
+
// Story 31.5 — the per-backend WIZARD view and the concern table. Exported for
|
|
6174
|
+
// the same reason STORE_BACKEND_VIEWS is: the suite pins that these keys are
|
|
6175
|
+
// exactly STORE_BACKENDS, so a backend added to the enum without a wizard flow
|
|
6176
|
+
// fails here rather than at the moment a user picks it. Both tables are frozen
|
|
6177
|
+
// entries included, so handing out the real objects is inert.
|
|
6178
|
+
STORE_BACKEND_WIZARD, REPO_LAYOUT_CONCERNS, offerableStoreBackends,
|
|
6179
|
+
backendChoicesForStore,
|
|
6180
|
+
// Story 31.8 — the confluence configuration flow and the two pure functions it
|
|
6181
|
+
// is built from. `generatedRootPageTitle` is exported because AC3's promise
|
|
6182
|
+
// ("named for the project, and you are told the name") is only checkable if
|
|
6183
|
+
// the test can compute the SAME name the wizard announces and the record
|
|
6184
|
+
// carries, from the same function, rather than restating the rule.
|
|
6185
|
+
// `validateConfluenceIdentity` is exported for the same reason the prompt
|
|
6186
|
+
// carries it: it is the policy the serializer enforces, and a test must be able
|
|
6187
|
+
// to show the two agree rather than that each exists.
|
|
6188
|
+
collectConfluenceStoreLocation, generatedRootPageTitle, validateConfluenceIdentity,
|
|
6189
|
+
// Story 31.13 — `ma-agents bind` (FR252) and scope reporting in `status`
|
|
6190
|
+
// (FR253). `bindOfferDecision` is exported separately from `offerBind` because
|
|
6191
|
+
// AC3's "never runs bind without the user's choice" is only checkable if the
|
|
6192
|
+
// DECISION can be exercised without the prompting: a test that had to answer a
|
|
6193
|
+
// prompt to learn what was decided could not distinguish "did not offer" from
|
|
6194
|
+
// "offered and was declined". `describeBoundProjects` and `detectInstallScopes`
|
|
6195
|
+
// are exported so AC7 can be asserted on data rather than on scraped output.
|
|
6196
|
+
handleBind, offerBind, bindOfferDecision, detectInstallScopes, describeBoundProjects,
|
|
6197
|
+
printScopeReport, boundProjectsPath,
|
|
6198
|
+
// Story 31.10 (P5-9.7) — the on-disk half of the Confluence page tree. The
|
|
6199
|
+
// mapping itself is `lib/confluence-page-tree.js` and is exported from there;
|
|
6200
|
+
// these three are the parts that need the project on disk.
|
|
6201
|
+
// `storeArtifactsDirectory` is exported so the suite can measure the §2
|
|
6202
|
+
// directory-derivation rule against the SHIPPED function rather than restating
|
|
6203
|
+
// it; `recordLearnedRootPageId` and its outcome constants are exported for the
|
|
6204
|
+
// reason the LAYOUT_WRITE_* reasons are — a test must branch on the outcome BY
|
|
6205
|
+
// NAME so "recorded" and "refused because the title moved" can never collapse
|
|
6206
|
+
// into one.
|
|
6207
|
+
storeArtifactsDirectory, collectStoreArtifactCandidates, recordLearnedRootPageId,
|
|
6208
|
+
// Story 31.15 — the publish trigger. `generateConfluencePublishHooks` is the
|
|
6209
|
+
// entry point all three generation sites call; `printPublishHookCollisionReport`
|
|
6210
|
+
// is AC12(c)'s detection surface, exported separately so a test can prove the
|
|
6211
|
+
// report FIRES on a collision without having to scrape `status` output around
|
|
6212
|
+
// it. The marker discipline, the TOML reader and the skill->store table are in
|
|
6213
|
+
// `lib/confluence-publish-hook.js` and exported from there.
|
|
6214
|
+
generateConfluencePublishHooks, printPublishHookCollisionReport,
|
|
6215
|
+
// Fix round 1 — R10's detector, exported separately for the same reason
|
|
6216
|
+
// `printPublishHookCollisionReport` is: a test must be able to prove it FIRES
|
|
6217
|
+
// without scraping the whole `status` output around it.
|
|
6218
|
+
printUserLayerScalarOverrideReport,
|
|
6219
|
+
handleConfluencePlan, handleConfluenceRecordRootPage, CONFLUENCE_STORE_RECORDS,
|
|
6220
|
+
ROOT_ID_RECORDED, ROOT_ID_UNCHANGED, ROOT_ID_TITLE_MOVED, ROOT_ID_NOT_CONFLUENCE,
|
|
6221
|
+
ROOT_ID_NO_BINDING, ROOT_ID_WRITE_FAILED };
|