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.
Files changed (86) hide show
  1. package/README.md +208 -1
  2. package/bin/cli.js +4258 -181
  3. package/docs/architecture.md +11 -0
  4. package/lib/bmad-extension/skills/add-sprint/SKILL.md +112 -0
  5. package/lib/bmad-extension/skills/add-to-sprint/SKILL.md +112 -0
  6. package/lib/bmad-extension/skills/bmad-dev-epic/SKILL.md +112 -0
  7. package/lib/bmad-extension/skills/bmad-dev-story/workflow.md +112 -0
  8. package/lib/bmad-extension/skills/bmad-knowledge/SKILL.md +112 -0
  9. package/lib/bmad-extension/skills/bmad-sprint-planning/workflow.md +161 -0
  10. package/lib/bmad-extension/skills/bmad-sprint-status/workflow.md +112 -0
  11. package/lib/bmad-extension/skills/cleanup-done/SKILL.md +112 -0
  12. package/lib/bmad-extension/skills/close-sprint/SKILL.md +112 -0
  13. package/lib/bmad-extension/skills/generate-backlog/SKILL.md +163 -1
  14. package/lib/bmad-extension/skills/ma-agent-cyber/customize.toml +31 -0
  15. package/lib/bmad-extension/skills/ma-agent-devops/customize.toml +31 -0
  16. package/lib/bmad-extension/skills/ma-agent-sqa/customize.toml +32 -0
  17. package/lib/bmad-extension/skills/ma-agent-sre/customize.toml +31 -0
  18. package/lib/bmad-extension/skills/mil498-ocd/prompts/01-discover-project-artifacts.md +2 -2
  19. package/lib/bmad-extension/skills/mil498-sdd/prompts/01-discover-project-artifacts.md +2 -2
  20. package/lib/bmad-extension/skills/mil498-sdp/prompts/01-discover-project-artifacts.md +2 -2
  21. package/lib/bmad-extension/skills/mil498-srs/prompts/01-discover-project-artifacts.md +2 -2
  22. package/lib/bmad-extension/skills/mil498-ssdd/prompts/01-discover-project-artifacts.md +2 -2
  23. package/lib/bmad-extension/skills/mil498-sss/prompts/01-discover-project-artifacts.md +2 -2
  24. package/lib/bmad-extension/skills/mil498-std/prompts/01-discover-project-artifacts.md +2 -2
  25. package/lib/bmad-extension/skills/modify-sprint/SKILL.md +112 -0
  26. package/lib/bmad-extension/skills/prioritize-backlog/SKILL.md +163 -1
  27. package/lib/bmad-extension/skills/remove-from-sprint/SKILL.md +112 -0
  28. package/lib/bmad-extension/skills/sprint-status-view/SKILL.md +112 -0
  29. package/lib/bmad-extension/skills/sqa-audit/SKILL.md +6 -2
  30. package/lib/bmad-extension/skills/sqa-ieee12207/SKILL.md +2 -2
  31. package/lib/bmad-extension/skills/sqa-requirements-quality/SKILL.md +1 -1
  32. package/lib/bmad-extension/workflows/add-sprint/workflow.md +112 -0
  33. package/lib/bmad-extension/workflows/add-to-sprint/workflow.md +112 -0
  34. package/lib/bmad-extension/workflows/modify-sprint/workflow.md +112 -0
  35. package/lib/bmad-extension/workflows/remove-from-sprint/workflow.md +112 -0
  36. package/lib/bmad-extension/workflows/sprint-status-view/workflow.md +112 -0
  37. package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +1 -1
  38. package/lib/bmad-extension-plugin/skills/add-sprint/SKILL.md +112 -0
  39. package/lib/bmad-extension-plugin/skills/add-to-sprint/SKILL.md +112 -0
  40. package/lib/bmad-extension-plugin/skills/bmad-dev-epic/SKILL.md +112 -0
  41. package/lib/bmad-extension-plugin/skills/bmad-dev-story/workflow.md +112 -0
  42. package/lib/bmad-extension-plugin/skills/bmad-knowledge/SKILL.md +112 -0
  43. package/lib/bmad-extension-plugin/skills/bmad-sprint-planning/workflow.md +161 -0
  44. package/lib/bmad-extension-plugin/skills/bmad-sprint-status/workflow.md +112 -0
  45. package/lib/bmad-extension-plugin/skills/cleanup-done/SKILL.md +112 -0
  46. package/lib/bmad-extension-plugin/skills/close-sprint/SKILL.md +112 -0
  47. package/lib/bmad-extension-plugin/skills/generate-backlog/SKILL.md +163 -1
  48. package/lib/bmad-extension-plugin/skills/ma-agent-cyber/customize.toml +31 -0
  49. package/lib/bmad-extension-plugin/skills/ma-agent-devops/customize.toml +31 -0
  50. package/lib/bmad-extension-plugin/skills/ma-agent-sqa/customize.toml +32 -0
  51. package/lib/bmad-extension-plugin/skills/ma-agent-sre/customize.toml +31 -0
  52. package/lib/bmad-extension-plugin/skills/mil498-ocd/prompts/01-discover-project-artifacts.md +2 -2
  53. package/lib/bmad-extension-plugin/skills/mil498-sdd/prompts/01-discover-project-artifacts.md +2 -2
  54. package/lib/bmad-extension-plugin/skills/mil498-sdp/prompts/01-discover-project-artifacts.md +2 -2
  55. package/lib/bmad-extension-plugin/skills/mil498-srs/prompts/01-discover-project-artifacts.md +2 -2
  56. package/lib/bmad-extension-plugin/skills/mil498-ssdd/prompts/01-discover-project-artifacts.md +2 -2
  57. package/lib/bmad-extension-plugin/skills/mil498-sss/prompts/01-discover-project-artifacts.md +2 -2
  58. package/lib/bmad-extension-plugin/skills/mil498-std/prompts/01-discover-project-artifacts.md +2 -2
  59. package/lib/bmad-extension-plugin/skills/modify-sprint/SKILL.md +112 -0
  60. package/lib/bmad-extension-plugin/skills/prioritize-backlog/SKILL.md +163 -1
  61. package/lib/bmad-extension-plugin/skills/remove-from-sprint/SKILL.md +112 -0
  62. package/lib/bmad-extension-plugin/skills/sprint-status-view/SKILL.md +112 -0
  63. package/lib/bmad-extension-plugin/skills/sqa-audit/SKILL.md +6 -2
  64. package/lib/bmad-extension-plugin/skills/sqa-ieee12207/SKILL.md +2 -2
  65. package/lib/bmad-extension-plugin/skills/sqa-requirements-quality/SKILL.md +1 -1
  66. package/lib/bmad.js +155 -5
  67. package/lib/bound-projects.js +265 -0
  68. package/lib/confluence-page-tree.js +925 -0
  69. package/lib/confluence-publish-hook.js +1781 -0
  70. package/lib/custom-marketplace.js +28 -18
  71. package/lib/installer.js +357 -10
  72. package/lib/store-backends.js +64 -0
  73. package/package.json +2 -2
  74. package/skills/add-sprint/SKILL.md +112 -0
  75. package/skills/add-to-sprint/SKILL.md +112 -0
  76. package/skills/bmad-knowledge/SKILL.md +112 -0
  77. package/skills/bmad-sprint-planning/SKILL.md +176 -3
  78. package/skills/bmad-sprint-status/SKILL.md +112 -0
  79. package/skills/cleanup-done/SKILL.md +112 -0
  80. package/skills/close-sprint/SKILL.md +112 -0
  81. package/skills/generate-backlog/SKILL.md +164 -2
  82. package/skills/modify-sprint/SKILL.md +112 -0
  83. package/skills/prioritize-backlog/SKILL.md +163 -1
  84. package/skills/remove-from-sprint/SKILL.md +112 -0
  85. package/skills/sprint-status-view/SKILL.md +112 -0
  86. 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
- const { getProfile, setProfile, resolveProfile, getBmadModules, setBmadModules, showDemerzelDeprecationNoticeOnce, getCustomMarketplaceSelection } = require('../lib/profile');
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 (!v) return '""';
603
+ if (v == null || v === '') return '""';
502
604
  const s = String(v);
503
- if (/[:"#\[\]{}&*!|>%@`]/.test(s) || s !== s.trim() || s === '') {
504
- return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
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 '"' + s + '"';
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
- return content.replace(regex, newLine);
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
- function writeRepoLayoutConfig(layout) {
554
- const configPath = path.join(process.cwd(), '_bmad', 'bmm', 'config.yaml');
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(process.cwd(), '_bmad', 'bmm'), { recursive: true });
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
- const projectRoot = process.cwd();
563
- const kbPortable = toPortablePath(layout.knowledgebase.path, projectRoot);
564
- const kbPath = kbPortable.portable;
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
- const spMode = layout.sprintManagement.mode;
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
- if (spMode === 'jira') {
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', layout.sprintManagement.jiraUrl);
579
- content = writeConfigField(content, 'jira_project_key', layout.sprintManagement.jiraProjectKey);
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', implArtifacts);
584
- fs.writeFileSync(configPath, content, 'utf-8');
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 spPortable = toPortablePath(layout.sprintManagement.path, projectRoot);
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', implArtifacts);
596
- fs.writeFileSync(configPath, content, 'utf-8');
597
- console.log(chalk.gray(` Config: knowledgebase_path="${kbPath}", sprint_backend=file-system, sprint_management_path="${spPath}"`));
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
- function writeProjectLayoutYaml(layout) {
605
- const layoutPath = path.join(process.cwd(), '_bmad-output', 'project-layout.yaml');
606
- // Only skip writing when BOTH concerns are truly same-repo (jira requires config recording)
607
- const bothSame = layout.knowledgebase.mode === 'same' && layout.sprintManagement.mode === 'same';
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
- if (bothSame) {
610
- // Single-repo mode: delete stale file if exists
611
- if (fs.existsSync(layoutPath)) {
612
- fs.unlinkSync(layoutPath);
613
- console.log(chalk.blue(' Removed stale project-layout.yaml (now in single-repo mode)'));
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
- // Multi-repo or Jira mode: generate project-layout.yaml
619
- fs.mkdirSync(path.join(process.cwd(), '_bmad-output'), { recursive: true });
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 date = new Date().toISOString().slice(0, 10);
622
- let content = `# Generated by ma-agents — do not edit manually\n`;
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
- const projectRoot = process.cwd();
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
- // Knowledgebase section
629
- const kbPortable = toPortablePath(layout.knowledgebase.path, projectRoot);
630
- content += `knowledgebase:\n`;
631
- content += ` mode: ${layout.knowledgebase.mode}\n`;
632
- content += ` path: ${yamlEscapeValue(kbPortable.portable)}\n`;
633
- if (kbPortable.isAbsolute) {
634
- content += ` # PORTABILITY: absolute path — other developers may need to reconfigure\n`;
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
- if (layout.knowledgebase.mode === 'remote' && layout.knowledgebase.gitUrl) {
637
- content += ` gitUrl: ${yamlEscapeValue(layout.knowledgebase.gitUrl)}\n`;
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
- // Sprint management section
641
- content += `sprint_management:\n`;
642
- content += ` mode: ${layout.sprintManagement.mode}\n`;
643
- if (layout.sprintManagement.mode === 'jira') {
644
- content += ` jira_url: ${yamlEscapeValue(layout.sprintManagement.jiraUrl)}\n`;
645
- content += ` jira_project_key: ${yamlEscapeValue(layout.sprintManagement.jiraProjectKey)}\n`;
646
- } else {
647
- const spPortable = toPortablePath(layout.sprintManagement.path, projectRoot);
648
- content += ` path: ${yamlEscapeValue(spPortable.portable)}\n`;
649
- if (spPortable.isAbsolute) {
650
- content += ` # PORTABILITY: absolute path — other developers may need to reconfigure\n`;
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
- if (layout.sprintManagement.mode === 'remote' && layout.sprintManagement.gitUrl) {
653
- content += ` gitUrl: ${yamlEscapeValue(layout.sprintManagement.gitUrl)}\n`;
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
- fs.writeFileSync(layoutPath, content, 'utf-8');
658
- console.log(chalk.gray(` Created _bmad-output/project-layout.yaml`));
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
- function readExistingLayout() {
671
- const projectRoot = process.cwd();
672
- // Try project-layout.yaml first (authoritative source written by Story 16.2)
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
- const content = fs.readFileSync(layoutPath, 'utf-8');
677
- const layout = { knowledgebase: null, sprintManagement: null };
678
-
679
- // Parse each concern section from the simple YAML
680
- for (const [section, key] of [['knowledgebase', 'knowledgebase'], ['sprint_management', 'sprintManagement']]) {
681
- const sectionMatch = content.match(new RegExp(`^${section}:\\s*$`, 'm'));
682
- if (!sectionMatch) continue;
683
-
684
- const afterSection = content.slice(sectionMatch.index + sectionMatch[0].length);
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
- // Only return if we parsed at least one valid concern
709
- if (layout.knowledgebase || layout.sprintManagement) {
710
- // Fill in defaults for any missing section
711
- if (!layout.knowledgebase) layout.knowledgebase = { mode: 'same', path: '.' };
712
- if (!layout.sprintManagement) layout.sprintManagement = { mode: 'same', path: '.' };
713
- return layout;
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(chalk.yellow(` Warning: could not read project-layout.yaml: ${e.message}`));
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
- const layout = { knowledgebase: null, sprintManagement: null };
826
- const labels = { knowledgebase: 'knowledgebase', sprintManagement: 'sprint management' };
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 ['knowledgebase', 'sprintManagement']) {
829
- const existingMode = existingLayout && existingLayout[concern] ? existingLayout[concern].mode : null;
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
- const { mode } = await prompts({
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: 'mode',
842
- message: `Where is your ${labels[concern]} managed?`,
843
- choices: modeChoices,
844
- initial: initialIndex >= 0 ? initialIndex : 0
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 (!mode) process.exit(0);
848
-
849
- if (mode === 'same') {
850
- layout[concern] = { mode: 'same', path: '.' };
851
- } else if (mode === 'local') {
852
- layout[concern] = await collectLocalPath(labels[concern]);
853
- } else if (mode === 'remote') {
854
- layout[concern] = await collectRemotePath(labels[concern]);
855
- } else if (mode === 'jira') {
856
- layout[concern] = await collectJiraConfig();
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
- // Check if both concerns point to same external path
861
- if (layout.knowledgebase.mode !== 'same' && layout.sprintManagement.mode !== 'same'
862
- && layout.knowledgebase.path === layout.sprintManagement.path) {
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
- // Persist BEFORE any agent install step (AC #9). setProfile bootstraps the
1407
- // manifest via ensureManifest when absent.
1408
- setProfile(projectRootForProfile, resolvedProfile);
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.4: Filter out BMAD agents for global scope (they require project-level install)
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.yellow(`\n Warning: BMAD agents require project-level installation and will be skipped for global scope:`));
1448
- console.log(chalk.yellow(` ${bmadAgentIds.join(', ')}`));
1449
- console.log(chalk.gray(` Run again without --global to install BMAD agents into the current project.`));
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 repo layout config (after BMAD install creates config.yaml)
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
- writeRepoLayoutConfig(repoLayout);
1618
- writeProjectLayoutYaml(repoLayout);
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
- try {
1700
- showDemerzelDeprecationNoticeOnce(process.cwd());
1701
- } catch (err) {
1702
- console.log(chalk.yellow(`\n Deprecation notice skipped: ${err.message}`));
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
- setProfile(projectRootForProfile, resolvedProfile);
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
- try {
1873
- showDemerzelDeprecationNoticeOnce(process.cwd());
1874
- } catch (err) {
1875
- console.log(chalk.yellow(`\n Deprecation notice skipped: ${err.message}`));
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
- for (const [label, key] of [['Knowledgebase', 'knowledgebase'], ['Sprint Management', 'sprintManagement']]) {
1939
- const concern = layout[key];
1940
- console.log(chalk.white(` ${label}:`));
1941
- console.log(chalk.gray(` Mode: ${concern.mode}`));
1942
- console.log(chalk.gray(` Path: ${concern.path}`));
1943
- if (concern.gitUrl) {
1944
- console.log(chalk.gray(` Git URL: ${concern.gitUrl}`));
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 updated config
1967
- writeRepoLayoutConfig(newLayout);
1968
- writeProjectLayoutYaml(newLayout);
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 };