@basou/cli 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/program.js CHANGED
@@ -3658,13 +3658,17 @@ import {
3658
3658
  readMarkdownFile as readMarkdownFile4,
3659
3659
  reconcileSourceRoots,
3660
3660
  removeMarkerSection,
3661
+ renderAnchorStarter,
3662
+ renderViewPresetBlock,
3661
3663
  renderWithMarkers as renderWithMarkers4,
3662
3664
  resolveRepositoryRoot as resolveRepositoryRoot8,
3663
3665
  safeSimpleGit,
3666
+ seedMarkers,
3664
3667
  summarizePresetPlan,
3665
3668
  summarizeRosterDrift,
3666
3669
  summarizeSymlinkPlan,
3667
3670
  summarizeWiring,
3671
+ summarizeWiringDrift,
3668
3672
  unknownManifestKeys,
3669
3673
  writeManifest as writeManifest2,
3670
3674
  writeMarkdownFile as writeMarkdownFile5
@@ -3674,7 +3678,7 @@ var CANONICAL_FILE = "AGENTS.md";
3674
3678
  function registerProjectCommand(program) {
3675
3679
  const project = program.command("project").description("Inspect a project's declared repo roster (read-only)");
3676
3680
  project.command("check").description(
3677
- "Compare the declared repo roster (manifest `repos`) against the capture config (`source_roots`) and surface drift (read-only, advisory)"
3681
+ "Surface project drift (read-only, advisory): the declared repo roster (manifest `repos`) vs the capture config (`source_roots`), AND the instruction-file wiring vs basou's native topology \u2014 a missing AGENTS.md canonical (repo or workspace view), incomplete spokes, conflicts, or collisions"
3678
3682
  ).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (opts) => {
3679
3683
  await runProjectCheck(opts);
3680
3684
  });
@@ -3749,8 +3753,11 @@ function registerProjectCommand(program) {
3749
3753
  project.command("new").argument("[repos...]", "Extra repo paths (besides the anchor) to seed into the roster").description(
3750
3754
  "Scaffold a new project from scratch at the current Git repository (the anchor): create `.basou/` and seed the manifest with a candidate `repos` roster (the anchor plus any given repos, which must already be git repositories) and a `workspace.view` placeholder. Dry-run by default; pass --apply to write. Pass --no-view for a solo project. The greenfield entry point \u2014 declare visibility/language per repo afterward, then run `basou project derive --apply` to materialize the wiring"
3751
3755
  ).option("--apply", "Create `.basou/` and write the seeded manifest (default: dry-run preview)").option(
3756
+ "--project-name <name>",
3757
+ "The project's product name (a simple name \u2014 letters, digits, '.', '-', '_'): sets `project.name` and the default view path (a `<name>-workspace` sibling). Omit to fall back to the anchor directory name; use --view for a custom view path"
3758
+ ).option(
3752
3759
  "--view <path>",
3753
- "Override the workspace view path (default: a <name>-workspace sibling)"
3760
+ "Override the workspace view path (default: a `<name>-workspace` sibling, where <name> is --project-name or the anchor directory name)"
3754
3761
  ).option("--no-view", "Solo project: declare no workspace view").option(
3755
3762
  "--local-only",
3756
3763
  "Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
@@ -3763,10 +3770,10 @@ function registerProjectCommand(program) {
3763
3770
  await runProjectDerive(opts);
3764
3771
  });
3765
3772
  project.command("retrofit").argument(
3766
- "<repo>",
3767
- "The declared roster repo whose hand-authored AGENTS.md to relocate (e.g. ../foo)"
3773
+ "[repo]",
3774
+ "The declared roster repo whose hand-authored AGENTS.md to relocate (e.g. ../foo). Omit to run only the workspace view's canonical auto-migration"
3768
3775
  ).description(
3769
- "Fold an existing repo's hand-authored AGENTS.md into the project topology: move the repo's regular-file `AGENTS.md` to the anchor canonical (`agents/<repo>/AGENTS.md`) and replace it with a symlink, so the prose lives at the single source of truth. Dry-run by default; pass --apply to relocate. The onboarding counterpart to `new` for a repo that already carries its own AGENTS.md \u2014 run it before `basou project derive`, which then adds the preset block, the CLAUDE.md / Copilot spokes, and the .gitignore. Non-destructive: it refuses when the destination canonical already exists (it never clobbers it), and skips a repo whose AGENTS.md is already a symlink or absent. The anchor (`.`) is refused"
3776
+ "Fold an existing repo's hand-authored AGENTS.md into the project topology: move the repo's regular-file `AGENTS.md` to the anchor canonical (`agents/<repo>/AGENTS.md`) and replace it with a symlink, so the prose lives at the single source of truth. Dry-run by default; pass --apply to relocate. The onboarding counterpart to `new` for a repo that already carries its own AGENTS.md \u2014 run it before `basou project derive`, which then adds the preset block, the CLAUDE.md / Copilot spokes, and the .gitignore. Non-destructive: it refuses when the destination canonical already exists (it never clobbers it), and skips a repo whose AGENTS.md is already a symlink or absent. The anchor (`.`) is refused. The workspace view's own canonical is auto-migrated when it is markerless prose (the generated block is prepended, the prose kept): omit the repo argument to perform that migration \u2014 a repo-argument run only reports it"
3770
3777
  ).option(
3771
3778
  "--apply",
3772
3779
  "Relocate the AGENTS.md to the canonical and recreate the symlink (default: dry-run preview)"
@@ -3797,18 +3804,39 @@ async function doRunProjectCheck(options, ctx) {
3797
3804
  const repositoryRoot = await resolveBasouRootForCommand(cwd, "project check");
3798
3805
  const paths = basouPaths10(repositoryRoot);
3799
3806
  const manifest = await readManifest6(paths);
3800
- const summary = summarizeRosterDrift({
3807
+ const roster = summarizeRosterDrift({
3801
3808
  ...manifest.repos !== void 0 ? { repos: manifest.repos } : {},
3802
3809
  sourceRoots: effectiveSourceRoots(manifest)
3803
3810
  });
3811
+ let wiring;
3812
+ const rosterRepos2 = manifest.repos ?? [];
3813
+ if (rosterRepos2.length > 0) {
3814
+ const anchorReal = realpathSync(repositoryRoot);
3815
+ const repoFacts = rosterRepos2.map(
3816
+ (entry) => gatherRepoSymlinks(repositoryRoot, anchorReal, entry)
3817
+ );
3818
+ let view = { kind: "no-view" };
3819
+ const viewPath = manifest.workspace.view;
3820
+ if (viewPath !== void 0) {
3821
+ const viewDir = resolveViewDir(repositoryRoot, viewPath);
3822
+ view = gatherViewSymlinks(repositoryRoot, anchorReal, rosterRepos2, viewDir);
3823
+ }
3824
+ wiring = summarizeWiringDrift({ repos: repoFacts, view });
3825
+ }
3826
+ const result = {
3827
+ roster,
3828
+ ...wiring !== void 0 ? { wiring } : {},
3829
+ ok: roster.ok && (wiring?.ok ?? true)
3830
+ };
3804
3831
  if (options.json === true) {
3805
- console.log(JSON.stringify(summary));
3832
+ console.log(JSON.stringify(result));
3806
3833
  } else {
3807
- console.log(renderProjectCheck(summary));
3834
+ console.log(renderProjectCheck(result));
3808
3835
  }
3809
- return summary;
3836
+ return result;
3810
3837
  }
3811
- function renderProjectCheck(summary) {
3838
+ function renderProjectCheck(result) {
3839
+ const summary = result.roster;
3812
3840
  const lines = [];
3813
3841
  lines.push("# Project composition check (declared vs captured)");
3814
3842
  lines.push("");
@@ -3843,8 +3871,58 @@ function renderProjectCheck(summary) {
3843
3871
  for (const p of summary.extra) lines.push(`- ${p}`);
3844
3872
  lines.push("");
3845
3873
  }
3874
+ const w = result.wiring;
3875
+ if (w !== void 0) {
3876
+ lines.push("## Instruction-file wiring drift (declared topology vs on-disk)");
3877
+ if (w.ok) {
3878
+ lines.push(
3879
+ "\u2705 Every present repo's and the view's instruction files (AGENTS.md + spokes) are wired as declared."
3880
+ );
3881
+ } else {
3882
+ if (w.missingCanonicals.length > 0) {
3883
+ lines.push(`\u26A0\uFE0F Missing instruction canonical (AGENTS.md): ${w.missingCanonicals.length}`);
3884
+ for (const m of w.missingCanonicals) {
3885
+ const where = m.target === "view" ? `view "${m.name}"` : m.target === "repo-self" ? `${m.name} (self)` : m.name;
3886
+ lines.push(`- ${where} \u2014 canonical AGENTS.md absent`);
3887
+ }
3888
+ }
3889
+ if (w.incompleteWiring.length > 0) {
3890
+ lines.push(
3891
+ `\u26A0\uFE0F Incomplete wiring (canonical present, spokes missing): ${w.incompleteWiring.length}`
3892
+ );
3893
+ for (const i of w.incompleteWiring) {
3894
+ const where = i.target === "view" ? `view "${i.path}"` : i.path;
3895
+ lines.push(`- ${where}: ${i.files.join(", ")}`);
3896
+ }
3897
+ }
3898
+ if (w.conflicts.length > 0) {
3899
+ lines.push(
3900
+ `\u26A0\uFE0F Wiring conflicts (existing file/link, left untouched): ${w.conflicts.length}`
3901
+ );
3902
+ for (const c of w.conflicts) {
3903
+ const where = c.target === "view" ? `view "${c.path}"` : c.path;
3904
+ const detail = c.reason === "mismatch" && c.actualTarget !== void 0 ? `mismatch -> ${c.actualTarget}` : c.reason;
3905
+ lines.push(`- ${where} ${c.file}: ${detail}`);
3906
+ }
3907
+ }
3908
+ if (w.collisions.length > 0) {
3909
+ lines.push(`\u26A0\uFE0F Canonical-name collisions (ambiguous, not wired): ${w.collisions.length}`);
3910
+ for (const c of w.collisions) {
3911
+ const who = c.view === true ? `${c.repos.join(", ")} + view` : c.repos.join(", ");
3912
+ lines.push(`- agents/${c.canonicalName}/AGENTS.md <- ${who}`);
3913
+ }
3914
+ }
3915
+ }
3916
+ if (w.unreachable.length > 0) {
3917
+ lines.push(
3918
+ `\u2139\uFE0F Declared repos not present on this machine (advisory \u2014 not counted as drift): ${w.unreachable.length}`
3919
+ );
3920
+ for (const u of w.unreachable) lines.push(`- ${u}`);
3921
+ }
3922
+ lines.push("");
3923
+ }
3846
3924
  lines.push(
3847
- "Note: read-only advisory. It only shows the difference between the declaration (repos) and the capture config (source_roots); it does not enforce."
3925
+ "Note: read-only advisory. It shows the difference between the declaration (repos) and the capture config (source_roots), and the instruction-file wiring vs basou's native topology; it does not enforce or generate."
3848
3926
  );
3849
3927
  return lines.join("\n");
3850
3928
  }
@@ -4298,6 +4376,17 @@ function inspectSymlink(filePath, expectedTarget) {
4298
4376
  const actual = readlinkSync(filePath);
4299
4377
  return actual === expectedTarget ? { state: "correct" } : { state: "mismatch", actualTarget: actual };
4300
4378
  }
4379
+ function anchorCanonicalState(filePath) {
4380
+ let st;
4381
+ try {
4382
+ st = lstatSync(filePath);
4383
+ } catch (error) {
4384
+ if (hasErrorCode(error) && error.code === "ENOENT") return "absent";
4385
+ return "broken";
4386
+ }
4387
+ if (st.isSymbolicLink()) return existsSync(filePath) ? "usable" : "broken";
4388
+ return st.isFile() ? "usable" : "broken";
4389
+ }
4301
4390
  function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
4302
4391
  const mode = instructionMode(entry);
4303
4392
  const isSelf = mode === "self";
@@ -4309,7 +4398,42 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
4309
4398
  return { ...base, isAnchor: false, reachable: false, canonicalPresent: false, files: [] };
4310
4399
  }
4311
4400
  if (real === anchorReal) {
4312
- return { ...base, isAnchor: true, reachable: true, canonicalPresent: false, files: [] };
4401
+ const anchorCanonical = join9(real, CANONICAL_FILE);
4402
+ const anchorState = anchorCanonicalState(anchorCanonical);
4403
+ if (anchorState === "absent") {
4404
+ return { ...base, isAnchor: true, reachable: true, canonicalPresent: false, files: [] };
4405
+ }
4406
+ if (anchorState === "broken") {
4407
+ return {
4408
+ ...base,
4409
+ isAnchor: true,
4410
+ reachable: true,
4411
+ canonicalPresent: true,
4412
+ canonicalName: basename5(real),
4413
+ files: [{ name: CANONICAL_FILE, expectedTarget: CANONICAL_FILE, state: "blocked" }]
4414
+ };
4415
+ }
4416
+ const anchorFiles = expectedSymlinkTargets(
4417
+ real,
4418
+ anchorCanonical,
4419
+ "self"
4420
+ ).map((spec) => {
4421
+ const { state, actualTarget } = inspectSymlink(join9(real, spec.name), spec.target);
4422
+ return {
4423
+ name: spec.name,
4424
+ expectedTarget: spec.target,
4425
+ state,
4426
+ ...actualTarget !== void 0 ? { actualTarget } : {}
4427
+ };
4428
+ });
4429
+ return {
4430
+ ...base,
4431
+ isAnchor: true,
4432
+ reachable: true,
4433
+ canonicalPresent: true,
4434
+ canonicalName: basename5(real),
4435
+ files: anchorFiles
4436
+ };
4313
4437
  }
4314
4438
  if (!existsSync(join9(real, ".git"))) {
4315
4439
  return { ...base, isAnchor: false, reachable: false, canonicalPresent: false, files: [] };
@@ -4363,6 +4487,53 @@ function applySymlinkPlan(repositoryRoot, plan) {
4363
4487
  function failureReason(error) {
4364
4488
  return hasErrorCode(error) ? error.code : "unknown error";
4365
4489
  }
4490
+ function viewCanonicalCollision(repositoryRoot, roster, viewName) {
4491
+ for (const entry of roster) {
4492
+ let name;
4493
+ try {
4494
+ name = basename5(realpathSync(resolve7(repositoryRoot, entry.path)));
4495
+ } catch {
4496
+ name = basename5(resolve7(repositoryRoot, entry.path));
4497
+ }
4498
+ if (name === viewName) return entry.path;
4499
+ }
4500
+ return void 0;
4501
+ }
4502
+ function gatherViewSymlinks(repositoryRoot, anchorReal, roster, viewDir) {
4503
+ const viewName = basename5(viewDir);
4504
+ const collision = viewCanonicalCollision(repositoryRoot, roster, viewName);
4505
+ if (collision !== void 0) return { kind: "collision", viewName, repoPath: collision };
4506
+ const canonicalFile = canonicalFileFor(anchorReal, viewName);
4507
+ if (!existsSync(canonicalFile)) return { kind: "missing-canonical", viewName };
4508
+ const files = expectedSymlinkTargets(viewDir, canonicalFile, "hub").map(
4509
+ (spec) => {
4510
+ const { state, actualTarget } = inspectSymlink(join9(viewDir, spec.name), spec.target);
4511
+ return {
4512
+ name: spec.name,
4513
+ expectedTarget: spec.target,
4514
+ state,
4515
+ ...actualTarget !== void 0 ? { actualTarget } : {}
4516
+ };
4517
+ }
4518
+ );
4519
+ return { kind: "gathered", viewName, files };
4520
+ }
4521
+ function applyViewSymlinks(viewDir, files) {
4522
+ const created = [];
4523
+ const failed = [];
4524
+ for (const f of files) {
4525
+ if (f.state !== "missing") continue;
4526
+ const filePath = join9(viewDir, f.name);
4527
+ try {
4528
+ mkdirSync(dirname3(filePath), { recursive: true });
4529
+ symlinkSync(f.expectedTarget, filePath);
4530
+ created.push(f.name);
4531
+ } catch (error) {
4532
+ failed.push({ file: f.name, message: failureReason(error) });
4533
+ }
4534
+ }
4535
+ return { created, failed };
4536
+ }
4366
4537
  async function doRunProjectSymlinks(options, ctx) {
4367
4538
  const cwd = ctx.cwd ?? process.cwd();
4368
4539
  const repositoryRoot = await resolveBasouRootForCommand(cwd, "project symlinks");
@@ -4382,11 +4553,33 @@ async function doRunProjectSymlinks(options, ctx) {
4382
4553
  for (const f of failed) failures.push({ repo: plan.path, file: f.file, message: f.message });
4383
4554
  }
4384
4555
  }
4556
+ let view;
4557
+ let viewCreated = [];
4558
+ const viewFailures = [];
4559
+ if (roster.length > 0) {
4560
+ const viewPath = manifest.workspace.view;
4561
+ if (viewPath === void 0) {
4562
+ view = { kind: "no-view" };
4563
+ } else {
4564
+ const viewDir = resolveViewDir(repositoryRoot, viewPath);
4565
+ view = gatherViewSymlinks(repositoryRoot, anchorReal, roster, viewDir);
4566
+ if (options.apply === true && view.kind === "gathered") {
4567
+ const { created, failed } = applyViewSymlinks(viewDir, view.files);
4568
+ viewCreated = created;
4569
+ for (const f of failed) viewFailures.push(f);
4570
+ }
4571
+ }
4572
+ }
4573
+ const viewClean = view === void 0 || view.kind === "no-view" || view.kind === "gathered" && view.files.every((f) => f.state === "correct");
4385
4574
  const result = {
4386
4575
  ...summary,
4576
+ ok: summary.ok && viewClean,
4387
4577
  hasRoster: roster.length > 0,
4388
4578
  applied: createdCount > 0,
4389
- failures
4579
+ failures,
4580
+ ...view !== void 0 ? { view } : {},
4581
+ viewCreated,
4582
+ viewFailures
4390
4583
  };
4391
4584
  if (options.json === true) {
4392
4585
  console.log(JSON.stringify(result));
@@ -4434,7 +4627,7 @@ function renderProjectSymlinks(result) {
4434
4627
  );
4435
4628
  } else {
4436
4629
  lines.push(
4437
- "\u2139\uFE0F No symlink needs generating, but there are conflicts / collisions / a missing canonical / unreachable repos (see below)."
4630
+ "\u2139\uFE0F No repo symlink needs generating, but there are conflicts / collisions / a missing canonical / unreachable repos, or the workspace view's spokes need attention (see below)."
4438
4631
  );
4439
4632
  }
4440
4633
  lines.push("");
@@ -4483,11 +4676,61 @@ function renderProjectSymlinks(result) {
4483
4676
  for (const p of result.unreachable) lines.push(`- ${p}`);
4484
4677
  lines.push("");
4485
4678
  }
4679
+ appendViewSymlinksSection(lines, result);
4486
4680
  lines.push(
4487
4681
  "Note: an existing file or a symlink pointing elsewhere is never overwritten; only the missing links are created (GEMINI.md is discontinued and not generated)."
4488
4682
  );
4489
4683
  return lines.join("\n");
4490
4684
  }
4685
+ function appendViewSymlinksSection(lines, result) {
4686
+ const view = result.view;
4687
+ if (view === void 0 || view.kind === "no-view") return;
4688
+ lines.push("## Workspace view spokes (the view's own instruction files)");
4689
+ if (view.kind === "collision") {
4690
+ lines.push(
4691
+ `\u26A0\uFE0F ${view.viewName}: the view shares its canonical name with the roster repo \`${view.repoPath}\` \u2014 both would own \`agents/${view.viewName}/${CANONICAL_FILE}\`, so no view spoke is wired. Rename the view directory or the repo to disambiguate, then re-run.`
4692
+ );
4693
+ lines.push("");
4694
+ return;
4695
+ }
4696
+ if (view.kind === "missing-canonical") {
4697
+ lines.push(
4698
+ `\u2139\uFE0F ${view.viewName}: the view canonical \`agents/${view.viewName}/${CANONICAL_FILE}\` does not exist yet \u2014 run \`basou project preset --apply\` first (or \`basou project derive --apply\`), then re-run.`
4699
+ );
4700
+ lines.push("");
4701
+ return;
4702
+ }
4703
+ const failedFiles = new Set(result.viewFailures.map((f) => f.file));
4704
+ const missing = view.files.filter((f) => f.state === "missing");
4705
+ const attempted = result.viewCreated.length > 0 || result.viewFailures.length > 0;
4706
+ if (missing.length === 0) {
4707
+ lines.push(`\u2705 ${view.viewName}: the view's instruction spokes are correctly wired.`);
4708
+ } else if (!attempted) {
4709
+ lines.push(`${view.viewName}: view spokes to create (dry-run; pass --apply to write):`);
4710
+ for (const f of missing) lines.push(` ${f.name} -> ${f.expectedTarget}`);
4711
+ } else {
4712
+ lines.push(`${view.viewName}: view spokes created:`);
4713
+ for (const f of missing) {
4714
+ if (failedFiles.has(f.name)) continue;
4715
+ lines.push(` ${f.name} -> ${f.expectedTarget}`);
4716
+ }
4717
+ }
4718
+ if (result.viewFailures.length > 0) {
4719
+ lines.push(` Failed:`);
4720
+ for (const f of result.viewFailures) lines.push(` ${f.file}: ${f.message}`);
4721
+ }
4722
+ const conflicts = view.files.filter(
4723
+ (f) => f.state === "mismatch" || f.state === "occupied" || f.state === "blocked"
4724
+ );
4725
+ if (conflicts.length > 0) {
4726
+ lines.push(` Conflicts (left untouched):`);
4727
+ for (const f of conflicts) {
4728
+ const detail = f.state === "mismatch" ? `points elsewhere (currently: ${f.actualTarget ?? "?"})` : f.state === "occupied" ? "a real file/directory" : "an uninspectable path";
4729
+ lines.push(` ${f.name}: ${detail}`);
4730
+ }
4731
+ }
4732
+ lines.push("");
4733
+ }
4491
4734
  async function runProjectWorkspace(options, ctx = {}) {
4492
4735
  try {
4493
4736
  await doRunProjectWorkspace(options, ctx);
@@ -4878,6 +5121,86 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
4878
5121
  ...section.kind === "ok" ? { currentBlock: section.generated } : {}
4879
5122
  };
4880
5123
  }
5124
+ function normalizeViewBlock(s) {
5125
+ return s.replace(/\r\n/g, "\n").replace(/\n+$/, "");
5126
+ }
5127
+ function viewPresetReposFor(repositoryRoot, roster) {
5128
+ let anchorReal;
5129
+ try {
5130
+ anchorReal = realpathSync(repositoryRoot);
5131
+ } catch {
5132
+ anchorReal = void 0;
5133
+ }
5134
+ return roster.map((entry) => {
5135
+ const abs = resolve7(repositoryRoot, entry.path);
5136
+ let real;
5137
+ try {
5138
+ real = realpathSync(abs);
5139
+ } catch {
5140
+ real = void 0;
5141
+ }
5142
+ const name = basename5(real ?? abs);
5143
+ const isAnchor = real !== void 0 && anchorReal !== void 0 ? real === anchorReal : abs === repositoryRoot;
5144
+ return {
5145
+ name,
5146
+ ...entry.visibility !== void 0 ? { visibility: entry.visibility } : {},
5147
+ ...entry.language !== void 0 ? { language: entry.language } : {},
5148
+ ...isAnchor ? { anchor: true } : instructionMode(entry) === "self" ? { self: true } : {}
5149
+ };
5150
+ });
5151
+ }
5152
+ function gatherViewPreset(repositoryRoot, anchorReal, viewName, roster) {
5153
+ const collision = viewCanonicalCollision(repositoryRoot, roster, viewName);
5154
+ if (collision !== void 0) return { kind: "collision", viewName, repoPath: collision };
5155
+ const desiredBlock = renderViewPresetBlock({
5156
+ viewName,
5157
+ repos: viewPresetReposFor(repositoryRoot, roster)
5158
+ });
5159
+ const canonicalFile = canonicalFileFor(anchorReal, viewName);
5160
+ let content;
5161
+ try {
5162
+ content = readFileSync(canonicalFile, "utf8");
5163
+ } catch (error) {
5164
+ if (hasErrorCode(error) && error.code === "ENOENT") {
5165
+ return {
5166
+ kind: "plan",
5167
+ action: "create",
5168
+ canonicalName: viewName,
5169
+ viewName,
5170
+ block: desiredBlock
5171
+ };
5172
+ }
5173
+ return { kind: "unreadable", canonicalName: viewName, viewName };
5174
+ }
5175
+ const section = parseMarkers(content);
5176
+ if (section.kind === "ok") {
5177
+ if (normalizeViewBlock(section.generated) === normalizeViewBlock(desiredBlock)) {
5178
+ return { kind: "in-sync", canonicalName: viewName, viewName };
5179
+ }
5180
+ return {
5181
+ kind: "plan",
5182
+ action: "update",
5183
+ canonicalName: viewName,
5184
+ viewName,
5185
+ block: desiredBlock
5186
+ };
5187
+ }
5188
+ return { kind: "conflict", canonicalName: viewName, viewName, reason: section.kind };
5189
+ }
5190
+ async function applyViewPreset(anchorReal, outcome) {
5191
+ const file = canonicalFileFor(anchorReal, outcome.canonicalName);
5192
+ const label = canonicalLabelFor(outcome.canonicalName);
5193
+ let isLink = false;
5194
+ try {
5195
+ isLink = lstatSync(file).isSymbolicLink();
5196
+ } catch {
5197
+ isLink = false;
5198
+ }
5199
+ if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
5200
+ if (outcome.action === "create") mkdirSync(dirname3(file), { recursive: true });
5201
+ const existing = await readMarkdownFile4(file);
5202
+ await writeMarkdownFile5(file, renderWithMarkers4(existing, outcome.block, label));
5203
+ }
4881
5204
  async function applyPresetPlan(anchorReal, plan) {
4882
5205
  const file = canonicalFileFor(anchorReal, plan.canonicalName);
4883
5206
  const label = canonicalLabelFor(plan.canonicalName);
@@ -4910,7 +5233,12 @@ async function doRunProjectPreset(options, ctx) {
4910
5233
  const anchorReal = realpathSync(repositoryRoot);
4911
5234
  const facts = [];
4912
5235
  for (const entry of roster) facts.push(await gatherRepoPreset(repositoryRoot, anchorReal, entry));
4913
- const summary = summarizePresetPlan(facts);
5236
+ const viewPath = manifest.workspace.view;
5237
+ const viewCanonicalName = roster.length > 0 && viewPath !== void 0 ? basename5(resolveViewDir(repositoryRoot, viewPath)) : void 0;
5238
+ const summary = summarizePresetPlan(
5239
+ facts,
5240
+ viewCanonicalName !== void 0 ? { viewCanonicalName } : void 0
5241
+ );
4914
5242
  const failures = [];
4915
5243
  let writtenCount = 0;
4916
5244
  if (options.apply === true && summary.plans.length > 0) {
@@ -4923,11 +5251,30 @@ async function doRunProjectPreset(options, ctx) {
4923
5251
  }
4924
5252
  }
4925
5253
  }
5254
+ let view;
5255
+ let viewApplied = false;
5256
+ let viewFailure;
5257
+ if (roster.length > 0) {
5258
+ view = viewCanonicalName === void 0 ? { kind: "no-view" } : gatherViewPreset(repositoryRoot, anchorReal, viewCanonicalName, roster);
5259
+ if (options.apply === true && view.kind === "plan") {
5260
+ try {
5261
+ await applyViewPreset(anchorReal, view);
5262
+ viewApplied = true;
5263
+ } catch (error) {
5264
+ viewFailure = presetFailureReason(error);
5265
+ }
5266
+ }
5267
+ }
5268
+ const viewClean = view === void 0 || view.kind === "no-view" || view.kind === "in-sync";
4926
5269
  const result = {
4927
5270
  ...summary,
5271
+ ok: summary.ok && viewClean,
4928
5272
  hasRoster: roster.length > 0,
4929
5273
  applied: writtenCount > 0,
4930
- failures
5274
+ failures,
5275
+ ...view !== void 0 ? { view } : {},
5276
+ viewApplied,
5277
+ ...viewFailure !== void 0 ? { viewFailure } : {}
4931
5278
  };
4932
5279
  if (options.json === true) {
4933
5280
  console.log(JSON.stringify(result));
@@ -4980,7 +5327,7 @@ function renderProjectPreset(result) {
4980
5327
  );
4981
5328
  } else {
4982
5329
  lines.push(
4983
- "\u2139\uFE0F No repo needs generating, but there are marker conflicts / collisions / undeclared / unreachable repos (see below)."
5330
+ "\u2139\uFE0F No repo needs generating, but there are marker conflicts / collisions / undeclared / unreachable repos, or the workspace view canonical needs attention (see below)."
4984
5331
  );
4985
5332
  }
4986
5333
  lines.push("");
@@ -5017,10 +5364,11 @@ function renderProjectPreset(result) {
5017
5364
  }
5018
5365
  if (result.collisions.length > 0) {
5019
5366
  lines.push(
5020
- `## Canonical collisions (${result.collisions.length}) \u2014 another repo shares the same-named canonical (not auto-generated)`
5367
+ `## Canonical collisions (${result.collisions.length}) \u2014 another repo (or the workspace view) shares the same-named canonical (not auto-generated)`
5021
5368
  );
5022
5369
  for (const c of result.collisions) {
5023
- lines.push(`- agents/${c.canonicalName}/AGENTS.md \u2190 ${c.repos.join(", ")}`);
5370
+ const suffix = c.view === true ? " + the workspace view (rename the view directory or the repo to disambiguate)" : "";
5371
+ lines.push(`- agents/${c.canonicalName}/AGENTS.md \u2190 ${c.repos.join(", ")}${suffix}`);
5024
5372
  }
5025
5373
  lines.push("");
5026
5374
  }
@@ -5050,11 +5398,46 @@ function renderProjectPreset(result) {
5050
5398
  for (const p of result.unreachable) lines.push(`- ${p}`);
5051
5399
  lines.push("");
5052
5400
  }
5401
+ appendViewPresetSection(lines, result);
5053
5402
  lines.push(
5054
5403
  "Note: only the marker region is generated; the canonical's hand-authored content (outside the markers) is preserved. The generated content is derived from the manifest declaration."
5055
5404
  );
5056
5405
  return lines.join("\n");
5057
5406
  }
5407
+ function appendViewPresetSection(lines, result) {
5408
+ const view = result.view;
5409
+ if (view === void 0 || view.kind === "no-view") return;
5410
+ const canonical2 = canonicalLabelFor(view.viewName);
5411
+ lines.push("## Workspace view canonical (the view's own AGENTS.md)");
5412
+ if (view.kind === "collision") {
5413
+ lines.push(
5414
+ `\u26A0\uFE0F ${view.viewName}: shares its canonical name with the roster repo \`${view.repoPath}\` \u2014 both would own ${canonical2}, so neither side is generated. Rename the view directory or the repo to disambiguate, then re-run.`
5415
+ );
5416
+ } else if (view.kind === "in-sync") {
5417
+ lines.push(`\u2705 ${view.viewName}: in sync with ${canonical2} (nothing to generate).`);
5418
+ } else if (view.kind === "plan") {
5419
+ if (result.viewApplied) {
5420
+ lines.push(`\u2705 ${view.viewName} [${view.action}] \u2192 ${canonical2}`);
5421
+ } else if (result.viewFailure !== void 0) {
5422
+ lines.push(`- ${view.viewName} [${view.action}] \u2192 ${canonical2}: ${result.viewFailure}`);
5423
+ } else {
5424
+ lines.push(
5425
+ `- ${view.viewName} [${view.action}] \u2192 ${canonical2} (dry-run; pass --apply to write):`
5426
+ );
5427
+ for (const bl of view.block.split("\n")) lines.push(` ${bl}`);
5428
+ }
5429
+ } else if (view.kind === "conflict") {
5430
+ const detail = view.reason === "no_markers" ? "no marker region" : `malformed markers (${view.reason})`;
5431
+ lines.push(
5432
+ `\u26A0\uFE0F ${view.viewName}: ${canonical2} has ${detail}, so it is not overwritten. Add \`${GENERATED_START}\` / \`${GENERATED_END}\` where the block should go (or run \`basou project retrofit\` to prepend it, preserving your prose).`
5433
+ );
5434
+ } else {
5435
+ lines.push(
5436
+ `\u26A0\uFE0F ${view.viewName}: ${canonical2} could not be read (a directory, permissions, etc.). Resolve it by hand, then re-run.`
5437
+ );
5438
+ }
5439
+ lines.push("");
5440
+ }
5058
5441
  async function runProjectArchive(target, options, ctx = {}) {
5059
5442
  try {
5060
5443
  await doRunProjectArchive(target, options, ctx);
@@ -5837,10 +6220,21 @@ async function resolveRepositoryRootForNew(cwd) {
5837
6220
  throw error;
5838
6221
  }
5839
6222
  }
6223
+ function validateProjectName(name) {
6224
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
6225
+ throw new Error(
6226
+ `Invalid --project-name "${name}": use a simple name (letters, digits, '.', '-', '_'; starting with a letter or digit; no spaces or path separators). For a custom view path, use --view.`
6227
+ );
6228
+ }
6229
+ return name;
6230
+ }
5840
6231
  async function doRunProjectNew(repos, options, ctx) {
5841
6232
  const cwd = ctx.cwd ?? process.cwd();
5842
6233
  const repositoryRoot = await resolveRepositoryRootForNew(cwd);
5843
6234
  const workspaceName = basename5(repositoryRoot);
6235
+ const productName = options.projectName !== void 0 ? validateProjectName(options.projectName) : void 0;
6236
+ const viewStem = productName ?? workspaceName;
6237
+ const viewOverridesProjectName = productName !== void 0 && typeof options.view === "string";
5844
6238
  const declared = repos.map((p) => {
5845
6239
  const abs = resolve7(cwd, p);
5846
6240
  let real;
@@ -5865,11 +6259,15 @@ async function doRunProjectNew(repos, options, ctx) {
5865
6259
  if (rel !== "." && !rosterPaths.includes(rel)) rosterPaths.push(rel);
5866
6260
  }
5867
6261
  const roster = rosterPaths.map((path) => ({ path }));
5868
- const viewPath = options.view === false ? null : options.view ?? `../${workspaceName}-workspace`;
6262
+ const viewPath = options.view === false ? null : options.view ?? `../${viewStem}-workspace`;
5869
6263
  const sourceRoots = [...rosterPaths, ...viewPath !== null ? [viewPath] : []];
5870
6264
  const paths = basouPaths10(repositoryRoot);
5871
6265
  const existed = existsSync(paths.files.manifest);
5872
- const manifest = createManifest2({ workspaceName, sourceRoots });
6266
+ const manifest = createManifest2({
6267
+ workspaceName,
6268
+ sourceRoots,
6269
+ ...productName !== void 0 ? { projectName: productName } : {}
6270
+ });
5873
6271
  manifest.repos = roster;
5874
6272
  if (viewPath !== null) manifest.workspace.view = viewPath;
5875
6273
  let applied = false;
@@ -5885,8 +6283,10 @@ async function doRunProjectNew(repos, options, ctx) {
5885
6283
  }
5886
6284
  const result = {
5887
6285
  workspaceName,
6286
+ projectName: productName ?? null,
5888
6287
  repos: roster,
5889
6288
  view: viewPath,
6289
+ viewOverridesProjectName,
5890
6290
  sourceRoots,
5891
6291
  invalidRepos: [],
5892
6292
  existed,
@@ -5919,11 +6319,12 @@ function renderProjectNew(result) {
5919
6319
  );
5920
6320
  lines.push("");
5921
6321
  }
6322
+ const identity = result.projectName ?? result.workspaceName;
5922
6323
  if (result.applied) {
5923
- lines.push(`\u2705 Created \`.basou/\` for \`${result.workspaceName}\` and seeded the manifest:`);
6324
+ lines.push(`\u2705 Created \`.basou/\` for \`${identity}\` and seeded the manifest:`);
5924
6325
  } else {
5925
6326
  lines.push(
5926
- `Will create \`.basou/\` for \`${result.workspaceName}\` and seed the manifest (dry-run; pass --apply to write):`
6327
+ `Will create \`.basou/\` for \`${identity}\` and seed the manifest (dry-run; pass --apply to write):`
5927
6328
  );
5928
6329
  }
5929
6330
  lines.push("");
@@ -5932,9 +6333,16 @@ function renderProjectNew(result) {
5932
6333
  lines.push(`- ${r.path}${r.path === "." ? " (anchor)" : ""}`);
5933
6334
  }
5934
6335
  lines.push("");
6336
+ if (result.projectName !== null) {
6337
+ lines.push(`project name: ${result.projectName}`);
6338
+ lines.push("");
6339
+ }
5935
6340
  lines.push(
5936
6341
  result.view !== null ? `workspace view: ${result.view}` : "workspace view: none (solo project)"
5937
6342
  );
6343
+ if (result.viewOverridesProjectName) {
6344
+ lines.push(" (note: --view set this path, so --project-name did not drive the view name)");
6345
+ }
5938
6346
  lines.push("");
5939
6347
  lines.push(`source_roots (${result.sourceRoots.length}):`);
5940
6348
  for (const s of result.sourceRoots) lines.push(`- ${s}`);
@@ -5980,25 +6388,79 @@ async function doRunProjectDerive(options, ctx) {
5980
6388
  const stepOpts = { apply };
5981
6389
  console.log("# Generate project wiring in one pass (declaration \u2192 wiring)");
5982
6390
  console.log("");
5983
- console.log("## 1/5 sync source_roots (roster \u2192 capture config)");
6391
+ console.log("## 1/6 sync source_roots (roster \u2192 capture config)");
5984
6392
  await doRunProjectSync(stepOpts, stepCtx);
5985
6393
  console.log("");
5986
- console.log("## 2/5 generate instruction-file A preset (declaration \u2192 canonical)");
6394
+ console.log("## 2/6 generate instruction-file A preset (declaration \u2192 canonical)");
5987
6395
  await doRunProjectPreset(stepOpts, stepCtx);
5988
6396
  console.log("");
5989
- console.log("## 3/5 generate instruction-file symlinks (each repo \u2192 canonical)");
6397
+ console.log("## 3/6 seed the anchor's own AGENTS.md (greenfield only; create-only)");
6398
+ await doRunProjectSeedAnchor(stepOpts, stepCtx);
6399
+ console.log("");
6400
+ console.log("## 4/6 generate instruction-file symlinks (each repo \u2192 canonical)");
5990
6401
  await doRunProjectSymlinks(stepOpts, stepCtx);
5991
6402
  console.log("");
5992
- console.log("## 4/5 generate workspace view (aggregate the roster repos)");
6403
+ console.log("## 5/6 generate workspace view (aggregate the roster repos)");
5993
6404
  await doRunProjectWorkspace(stepOpts, stepCtx);
5994
6405
  console.log("");
5995
- console.log("## 5/5 generate .gitignore (exclude public repos' instruction files)");
6406
+ console.log("## 6/6 generate .gitignore (exclude public repos' instruction files)");
5996
6407
  await doRunProjectGitignore(stepOpts, stepCtx);
5997
6408
  console.log("");
5998
6409
  console.log(
5999
6410
  apply ? "\u2705 Ran every step (each is idempotent, so a partial apply recovers on re-run)." : "\u2139\uFE0F Dry-run preview. Pass --apply to write the changes, then re-run."
6000
6411
  );
6001
6412
  }
6413
+ async function doRunProjectSeedAnchor(options, ctx) {
6414
+ const cwd = ctx.cwd ?? process.cwd();
6415
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "project derive");
6416
+ const paths = basouPaths10(repositoryRoot);
6417
+ const manifest = await readManifest6(paths);
6418
+ const roster = manifest.repos ?? [];
6419
+ console.log("# Anchor instruction-file seed (the planning master's own AGENTS.md)");
6420
+ console.log("");
6421
+ if (roster.length === 0) {
6422
+ console.log("\u2139\uFE0F No repo roster declared \u2014 nothing to seed.");
6423
+ return;
6424
+ }
6425
+ const anchorDoc = join9(repositoryRoot, CANONICAL_FILE);
6426
+ if (pathPresent(anchorDoc)) {
6427
+ console.log(
6428
+ `\u2705 The anchor's own \`${CANONICAL_FILE}\` already exists \u2014 hand-maintained, left untouched.`
6429
+ );
6430
+ return;
6431
+ }
6432
+ const viewPath = manifest.workspace.view;
6433
+ const viewName = viewPath !== void 0 ? basename5(resolveViewDir(repositoryRoot, viewPath)) : void 0;
6434
+ const repos = viewPresetReposFor(repositoryRoot, roster);
6435
+ const content = renderAnchorStarter({
6436
+ anchorName: basename5(repositoryRoot),
6437
+ ...manifest.project?.name !== void 0 ? { projectName: manifest.project.name } : {},
6438
+ ...viewName !== void 0 ? { viewName } : {},
6439
+ repos
6440
+ });
6441
+ if (options.apply !== true) {
6442
+ console.log(
6443
+ `- ${CANONICAL_FILE} [create] \u2192 the anchor's own AGENTS.md (dry-run; pass --apply to write). A create-only starter; hand-maintain it afterward \u2014 basou never rewrites it.`
6444
+ );
6445
+ return;
6446
+ }
6447
+ try {
6448
+ writeFileSync(anchorDoc, content, { flag: "wx" });
6449
+ console.log(
6450
+ `\u2705 Seeded the anchor's own \`${CANONICAL_FILE}\` (create-only starter \u2014 hand-maintain it from here; basou never rewrites it).`
6451
+ );
6452
+ } catch (error) {
6453
+ if (hasErrorCode(error) && error.code === "EEXIST") {
6454
+ console.log(
6455
+ `\u2705 The anchor's own \`${CANONICAL_FILE}\` already exists \u2014 hand-maintained, left untouched.`
6456
+ );
6457
+ return;
6458
+ }
6459
+ console.log(
6460
+ `\u26A0\uFE0F Could not seed the anchor's \`${CANONICAL_FILE}\`: ${presetFailureReason(error)}`
6461
+ );
6462
+ }
6463
+ }
6002
6464
  async function runProjectRetrofit(repo, options, ctx = {}) {
6003
6465
  try {
6004
6466
  await doRunProjectRetrofit(repo, options, ctx);
@@ -6037,7 +6499,7 @@ function pathPresent(p) {
6037
6499
  return false;
6038
6500
  }
6039
6501
  }
6040
- function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, argReal) {
6502
+ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, argReal, viewCanonicalName) {
6041
6503
  const declaredEntry = roster.find((entry) => {
6042
6504
  const entryAbs = resolve7(repositoryRoot, entry.path);
6043
6505
  if (argReal !== void 0) {
@@ -6061,6 +6523,7 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
6061
6523
  isAnchor: false,
6062
6524
  reachable: false,
6063
6525
  canonicalName,
6526
+ ...viewCanonicalName !== void 0 ? { viewCanonicalName } : {},
6064
6527
  agentsState: "absent",
6065
6528
  canonicalExists: false,
6066
6529
  regularSpokes: []
@@ -6076,6 +6539,7 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
6076
6539
  isAnchor,
6077
6540
  reachable,
6078
6541
  canonicalName,
6542
+ ...viewCanonicalName !== void 0 ? { viewCanonicalName } : {},
6079
6543
  agentsState: inspectAgentsState(join9(argReal, CANONICAL_FILE)),
6080
6544
  canonicalExists: pathPresent(canonicalFile),
6081
6545
  regularSpokes: regularFileSpokes(argReal)
@@ -6102,6 +6566,44 @@ function relocateAgentsFile(repoReal, canonicalFile) {
6102
6566
  return { ok: false, message: failureReason(error), partial: true };
6103
6567
  }
6104
6568
  }
6569
+ function gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster) {
6570
+ if (viewPath === void 0) return { kind: "no-view" };
6571
+ const viewDir = resolveViewDir(repositoryRoot, viewPath);
6572
+ const viewName = basename5(viewDir);
6573
+ const collision = viewCanonicalCollision(repositoryRoot, roster, viewName);
6574
+ if (collision !== void 0) return { kind: "collision", viewName, repoPath: collision };
6575
+ const canonicalFile = canonicalFileFor(anchorReal, viewName);
6576
+ let content;
6577
+ try {
6578
+ content = readFileSync(canonicalFile, "utf8");
6579
+ } catch (error) {
6580
+ if (hasErrorCode(error) && error.code === "ENOENT") return { kind: "absent", viewName };
6581
+ return { kind: "unreadable", viewName };
6582
+ }
6583
+ const section = parseMarkers(content);
6584
+ if (section.kind === "ok") return { kind: "already-marked", viewName };
6585
+ if (section.kind === "no_markers") {
6586
+ const block = renderViewPresetBlock({
6587
+ viewName,
6588
+ repos: viewPresetReposFor(repositoryRoot, roster)
6589
+ });
6590
+ return { kind: "seed", viewName, block };
6591
+ }
6592
+ return { kind: "malformed", viewName, reason: section.kind };
6593
+ }
6594
+ async function applyViewRetrofit(anchorReal, outcome) {
6595
+ const file = canonicalFileFor(anchorReal, outcome.viewName);
6596
+ const label = canonicalLabelFor(outcome.viewName);
6597
+ let isLink = false;
6598
+ try {
6599
+ isLink = lstatSync(file).isSymbolicLink();
6600
+ } catch {
6601
+ isLink = false;
6602
+ }
6603
+ if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
6604
+ const existing = await readMarkdownFile4(file);
6605
+ await writeMarkdownFile5(file, seedMarkers(existing, outcome.block, label));
6606
+ }
6105
6607
  async function doRunProjectRetrofit(repo, options, ctx) {
6106
6608
  const cwd = ctx.cwd ?? process.cwd();
6107
6609
  const repositoryRoot = await resolveBasouRootForCommand(cwd, "project retrofit");
@@ -6109,6 +6611,36 @@ async function doRunProjectRetrofit(repo, options, ctx) {
6109
6611
  const manifest = await readManifest6(paths);
6110
6612
  const roster = manifest.repos ?? [];
6111
6613
  const anchorReal = realpathSync(repositoryRoot);
6614
+ const viewPath = manifest.workspace.view;
6615
+ if (repo === void 0) {
6616
+ let view2;
6617
+ let viewApplied = false;
6618
+ let viewFailure;
6619
+ if (roster.length > 0) {
6620
+ view2 = gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster);
6621
+ if (options.apply === true && view2.kind === "seed") {
6622
+ try {
6623
+ await applyViewRetrofit(anchorReal, view2);
6624
+ viewApplied = true;
6625
+ } catch (error) {
6626
+ viewFailure = presetFailureReason(error);
6627
+ }
6628
+ }
6629
+ }
6630
+ const result2 = {
6631
+ kind: "view-only",
6632
+ hasRoster: roster.length > 0,
6633
+ ...view2 !== void 0 ? { view: view2 } : {},
6634
+ viewApplied,
6635
+ ...viewFailure !== void 0 ? { viewFailure } : {}
6636
+ };
6637
+ if (options.json === true) {
6638
+ console.log(JSON.stringify(result2));
6639
+ } else {
6640
+ console.log(renderProjectRetrofit(result2));
6641
+ }
6642
+ return result2;
6643
+ }
6112
6644
  const argAbs = resolve7(repositoryRoot, repo);
6113
6645
  let argReal;
6114
6646
  try {
@@ -6116,7 +6648,16 @@ async function doRunProjectRetrofit(repo, options, ctx) {
6116
6648
  } catch {
6117
6649
  argReal = void 0;
6118
6650
  }
6119
- const facts = gatherRetrofit(repositoryRoot, anchorReal, roster, repo, argAbs, argReal);
6651
+ const viewCanonicalName = viewPath !== void 0 ? basename5(resolveViewDir(repositoryRoot, viewPath)) : void 0;
6652
+ const facts = gatherRetrofit(
6653
+ repositoryRoot,
6654
+ anchorReal,
6655
+ roster,
6656
+ repo,
6657
+ argAbs,
6658
+ argReal,
6659
+ viewCanonicalName
6660
+ );
6120
6661
  const plan = classifyRetrofit(facts);
6121
6662
  let applied = false;
6122
6663
  let failure;
@@ -6131,12 +6672,19 @@ async function doRunProjectRetrofit(repo, options, ctx) {
6131
6672
  partial = res.partial;
6132
6673
  }
6133
6674
  }
6675
+ let view;
6676
+ if (roster.length > 0) {
6677
+ view = gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster);
6678
+ }
6134
6679
  const result = {
6680
+ kind: "repo",
6135
6681
  ...plan,
6136
6682
  hasRoster: roster.length > 0,
6137
6683
  applied,
6138
6684
  ...failure !== void 0 ? { failure } : {},
6139
- ...partial ? { partial } : {}
6685
+ ...partial ? { partial } : {},
6686
+ ...view !== void 0 ? { view } : {},
6687
+ viewApplied: false
6140
6688
  };
6141
6689
  if (options.json === true) {
6142
6690
  console.log(JSON.stringify(result));
@@ -6145,6 +6693,47 @@ async function doRunProjectRetrofit(repo, options, ctx) {
6145
6693
  }
6146
6694
  return result;
6147
6695
  }
6696
+ var MARKER_PORTABILITY_NOTE = "The marker pair can later be moved anywhere in the file by hand \u2014 `basou project preset` rewrites only the region between the markers.";
6697
+ function appendViewRetrofitSection(lines, result) {
6698
+ const view = result.view;
6699
+ if (view === void 0 || view.kind === "no-view" || view.kind === "absent" || view.kind === "already-marked")
6700
+ return;
6701
+ const canonical2 = `agents/${view.viewName}/${CANONICAL_FILE}`;
6702
+ lines.push("## Workspace view canonical (auto-migrate the view's own AGENTS.md)");
6703
+ if (view.kind === "seed") {
6704
+ if (result.kind === "repo") {
6705
+ lines.push(
6706
+ `\`${canonical2}\` is markerless prose. Run \`basou project retrofit\` (no repo argument) to prepend the generated block, preserving the prose.`
6707
+ );
6708
+ lines.push(MARKER_PORTABILITY_NOTE);
6709
+ } else if (result.viewApplied) {
6710
+ lines.push(
6711
+ `\u2705 Prepended the generated block into \`${canonical2}\` (your hand-written prose is preserved below it).`
6712
+ );
6713
+ lines.push(MARKER_PORTABILITY_NOTE);
6714
+ } else if (result.viewFailure !== void 0) {
6715
+ lines.push(`\u26A0\uFE0F Could not seed \`${canonical2}\`: ${result.viewFailure}. Nothing was changed.`);
6716
+ } else {
6717
+ lines.push(
6718
+ `\`${canonical2}\` is markerless prose. Retrofit will prepend the generated block, preserving the prose (dry-run; pass --apply to write).`
6719
+ );
6720
+ lines.push(MARKER_PORTABILITY_NOTE);
6721
+ }
6722
+ } else if (view.kind === "collision") {
6723
+ lines.push(
6724
+ `\u26A0\uFE0F The view shares its canonical name with the roster repo \`${view.repoPath}\` \u2014 both would own \`${canonical2}\`, so nothing is migrated. Rename the view directory or the repo to disambiguate, then re-run.`
6725
+ );
6726
+ } else if (view.kind === "malformed") {
6727
+ lines.push(
6728
+ `\u26A0\uFE0F \`${canonical2}\` has malformed markers (${view.reason}), so it is not rewritten. Fix the \`${GENERATED_START}\` / \`${GENERATED_END}\` pair by hand, then re-run.`
6729
+ );
6730
+ } else {
6731
+ lines.push(
6732
+ `\u26A0\uFE0F \`${canonical2}\` could not be read (a directory, permissions, etc.). Resolve it by hand, then re-run.`
6733
+ );
6734
+ }
6735
+ lines.push("");
6736
+ }
6148
6737
  function appendSpokeChecklist(lines, spokes) {
6149
6738
  if (spokes.length === 0) return;
6150
6739
  lines.push(
@@ -6169,6 +6758,25 @@ function renderProjectRetrofit(result) {
6169
6758
  );
6170
6759
  return lines.join("\n");
6171
6760
  }
6761
+ if (result.kind === "view-only") {
6762
+ const view = result.view;
6763
+ if (view === void 0 || view.kind === "no-view") {
6764
+ lines.push(
6765
+ "\u2139\uFE0F No `workspace.view` declared \u2014 there is no view canonical to migrate. Pass a repo argument to relocate a repo's AGENTS.md instead."
6766
+ );
6767
+ } else if (view.kind === "absent") {
6768
+ lines.push(
6769
+ `\u2139\uFE0F The view canonical \`agents/${view.viewName}/${CANONICAL_FILE}\` does not exist yet \u2014 run \`basou project preset --apply\` (or \`basou project derive --apply\`) to create it; there is nothing to migrate.`
6770
+ );
6771
+ } else if (view.kind === "already-marked") {
6772
+ lines.push(
6773
+ `\u2705 \`agents/${view.viewName}/${CANONICAL_FILE}\` already has a BASOU:GENERATED region. Nothing to migrate.`
6774
+ );
6775
+ } else {
6776
+ appendViewRetrofitSection(lines, result);
6777
+ }
6778
+ return lines.join("\n").trimEnd();
6779
+ }
6172
6780
  const canonical2 = `agents/${result.canonicalName}/${CANONICAL_FILE}`;
6173
6781
  if (result.action === "refuse") {
6174
6782
  if (result.reason === "not-declared") {
@@ -6191,12 +6799,18 @@ function renderProjectRetrofit(result) {
6191
6799
  lines.push(
6192
6800
  `\u26A0\uFE0F \`${result.path}/${CANONICAL_FILE}\` could not be inspected (a parent component is not a directory, a permission error, or the path is neither a regular file nor a symlink). Resolve it by hand, then re-run.`
6193
6801
  );
6802
+ } else if (result.reason === "view-collision") {
6803
+ lines.push(
6804
+ `\u26A0\uFE0F \`${result.path}\` shares its canonical name with the workspace view \u2014 \`${canonical2}\` would be owned by both, so relocating would corrupt one with the other. Rename the view directory or the repo to disambiguate, then re-run.`
6805
+ );
6194
6806
  } else if (result.reason === "canonical-exists") {
6195
6807
  lines.push(
6196
6808
  `\u26A0\uFE0F The destination canonical \`${canonical2}\` already exists. Not relocating, to avoid clobbering it. If the canonical is the source of truth, the repo's AGENTS.md is redundant (remove it, then run \`basou project symlinks\`); otherwise reconcile the two by hand.`
6197
6809
  );
6198
6810
  }
6199
- return lines.join("\n");
6811
+ lines.push("");
6812
+ appendViewRetrofitSection(lines, result);
6813
+ return lines.join("\n").trimEnd();
6200
6814
  }
6201
6815
  if (result.action === "skip") {
6202
6816
  if (result.reason === "already-symlink") {
@@ -6210,6 +6824,7 @@ function renderProjectRetrofit(result) {
6210
6824
  }
6211
6825
  lines.push("");
6212
6826
  appendSpokeChecklist(lines, result.regularSpokes);
6827
+ appendViewRetrofitSection(lines, result);
6213
6828
  return lines.join("\n").trimEnd();
6214
6829
  }
6215
6830
  if (result.failure !== void 0) {
@@ -6223,7 +6838,9 @@ function renderProjectRetrofit(result) {
6223
6838
  } else {
6224
6839
  lines.push("Nothing was changed. Resolve the cause and re-run.");
6225
6840
  }
6226
- return lines.join("\n");
6841
+ lines.push("");
6842
+ appendViewRetrofitSection(lines, result);
6843
+ return lines.join("\n").trimEnd();
6227
6844
  }
6228
6845
  if (result.applied) {
6229
6846
  lines.push(
@@ -6238,6 +6855,7 @@ function renderProjectRetrofit(result) {
6238
6855
  }
6239
6856
  lines.push("");
6240
6857
  appendSpokeChecklist(lines, result.regularSpokes);
6858
+ appendViewRetrofitSection(lines, result);
6241
6859
  lines.push(
6242
6860
  result.applied ? "Next: run `basou project derive --apply` to add the preset block, the CLAUDE.md / Copilot spokes, and the .gitignore." : "After applying, run `basou project derive --apply` to finish the wiring (preset block, CLAUDE.md / Copilot spokes, .gitignore)."
6243
6861
  );