agentic-team 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +8 -3
  2. package/bin/build-template.js +2 -2
  3. package/bin/index.js +234 -39
  4. package/bin/platforms.js +510 -0
  5. package/package.json +4 -4
  6. package/template/.claude-plugin/plugin.json +2 -2
  7. package/template/.orchestra/README.md +41 -51
  8. package/template/.orchestra/config.yml +16 -0
  9. package/template/.orchestra/roles/product-manager.md +3 -2
  10. package/template/.orchestra/scripts/compact-diff.awk +1 -1
  11. package/template/.orchestra/scripts/context.mjs +373 -0
  12. package/template/.orchestra/stacks/_repo/setup.sh +7 -3
  13. package/template/.orchestra/stacks/mobile-expo/setup.sh +1 -1
  14. package/template/CLAUDE.md +1 -1
  15. package/template/agents/lead.md +171 -116
  16. package/template/agents/milestone-reviewer.md +5 -3
  17. package/template/agents/product-manager.md +3 -2
  18. package/template/agents/re-reviewer.md +25 -0
  19. package/template/agents/reviewer.md +7 -5
  20. package/template/agents/scaffolder.md +26 -0
  21. package/template/commands/help.md +1 -0
  22. package/template/commands/hotfix.md +3 -1
  23. package/template/commands/rewind.md +10 -8
  24. package/template/commands/setup.md +2 -1
  25. package/template/commands/start.md +2 -2
  26. package/template/commands/status.md +1 -1
  27. package/template/commands/sync.md +5 -2
  28. package/template/commands/verifier.md +2 -2
  29. package/template/hooks/audit-context.sh +15 -2
  30. package/template/hooks/enforce-role.sh +21 -3
  31. package/template/hooks/protect-system-files.sh +2 -2
  32. package/template/hooks/review-rounds.sh +1 -1
  33. package/template/rules/code-standards.orchestra.md +3 -2
  34. package/template/rules/design-parity.orchestra.md +12 -1
  35. package/template/rules/observability.orchestra.md +3 -0
  36. package/template/rules/performance.orchestra.md +5 -1
  37. package/template/rules/phase-limits.orchestra.md +1 -1
  38. package/template/rules/pull-request.orchestra.md +7 -2
  39. package/template/rules/role-boundaries.orchestra.md +24 -9
  40. package/template/rules/security.orchestra.md +1 -1
  41. package/template/rules/stuck-detection.orchestra.md +1 -1
  42. package/template/rules/testing-standards.orchestra.md +10 -5
  43. package/template/rules/tracker-authority.orchestra.md +9 -1
  44. package/template/skills/push-notifications/SKILL.md +1 -1
  45. package/template/skills/testing/SKILL.md +2 -1
package/README.md CHANGED
@@ -24,6 +24,11 @@ That copies `.orchestra/` and `.claude/` in and adds the Orchestra section to
24
24
  blueprints, your own roles, skills and rules are preserved, and `config.yml`
25
25
  is merged rather than overwritten.
26
26
 
27
+ Only the project's platforms are installed — `web`, `expo`, `ios`, `android`,
28
+ `backend` — from `project.platforms` in `config.yml`, detected from the repo
29
+ when it is empty, or `--platforms ios,backend`. See
30
+ [Getting started](docs/getting-started.md#only-your-platforms).
31
+
27
32
  ## How it works
28
33
 
29
34
  Once, in a freshly cloned empty repo:
@@ -105,10 +110,10 @@ With `tracker.provider: none` there is no board. The markdown under
105
110
  ├── stacks/ how each kind of app is scaffolded and built afterwards
106
111
  ├── trackers/ the GitHub adapter; ClickUp goes through its MCP connector
107
112
  ├── blueprints/ milestone templates
108
- └── milestones/ your work — prd, milestone, phases, tracker.yml, context.md
113
+ └── milestones/ your work — prd, milestone, phases, tracker.yml, context/decisions/journal
109
114
 
110
115
  .claude/
111
- ├── agents/ architect, scaffolder, lead, reviewer, tracker
116
+ ├── agents/ architect, scaffolder, lead, reviewer, re-reviewer, tracker
112
117
  ├── rules/*.orchestra.md discipline, loaded into every session
113
118
  ├── skills/*/SKILL.md depth on one topic, loaded per phase
114
119
  └── commands/orchestra/ the commands above
@@ -125,7 +130,7 @@ is enforced by `scripts/policy-check.mjs` in the gate; the rest is a review
125
130
  item, and the reviewer is told which is which.
126
131
 
127
132
  **Roles** write only inside their ownership. PM writes milestones. Lead
128
- writes `context.md`. Sub-agents write what a phase's scope names. A
133
+ writes the milestone record — `context.md`, `decisions.md`, `journal.md`. Sub-agents write what a phase's scope names. A
129
134
  `PreToolUse` hook blocks everything else.
130
135
 
131
136
  ## Principles
@@ -36,7 +36,7 @@ const PLUGIN_MANIFEST = {
36
36
  "AI Team Orchestration — multi-role coordination with milestones, phases, and quality gates for Claude Code",
37
37
  version: require("../package.json").version,
38
38
  author: { name: "Sulhadin Öney" },
39
- repository: "https://github.com/sulhadin/orchestrator",
39
+ repository: "https://github.com/sulhadin/agentic-team",
40
40
  license: "MIT",
41
41
  };
42
42
 
@@ -79,7 +79,7 @@ const SYSTEM_PATHS = [
79
79
 
80
80
  // The repo's CLAUDE.md carries a "## Development" section about THIS package.
81
81
  // Copying it wholesale means every scaffolded project tells its own agents it
82
- // is the orchestrator npm package. Ship only what is between the markers.
82
+ // is the agentic-team npm package. Ship only what is between the markers.
83
83
  function packClaudeMd() {
84
84
  const src = fs.readFileSync(path.join(rootDir, "CLAUDE.md"), "utf-8");
85
85
  const start = src.indexOf("<!-- orchestra -->");
package/bin/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { execFileSync } = require("node:child_process");
4
4
  const fs = require("node:fs");
5
5
  const path = require("node:path");
6
+ const platformsLib = require("./platforms");
6
7
 
7
8
  const targetDir = process.cwd();
8
9
  const templateDir = path.join(__dirname, "..", "template");
@@ -71,9 +72,11 @@ const ALLOW_PERMISSIONS = [
71
72
 
72
73
  function parseArgs() {
73
74
  const args = process.argv.slice(2);
74
- const flags = { skipPermissions: false, help: false };
75
+ const platforms = platformsLib.takePlatformsArg(args);
76
+ const flags = { skipPermissions: false, help: false, platforms: platforms?.list ?? null };
75
77
 
76
- for (const arg of args) {
78
+ for (const [i, arg] of args.entries()) {
79
+ if (platforms?.used.has(i)) continue;
77
80
  switch (arg) {
78
81
  case "--dangerously-skip-permissions":
79
82
  flags.skipPermissions = true;
@@ -101,11 +104,16 @@ function showHelp() {
101
104
  console.log("");
102
105
  console.log(" Options:");
103
106
  console.log(" --public new: create a public repository");
107
+ console.log(
108
+ " --platforms <list> web,expo,ios,android,backend — install only these",
109
+ );
110
+ console.log(" (default: project.platforms, else detected)");
104
111
  console.log(" --dangerously-skip-permissions Auto-allow all tool permissions");
105
112
  console.log(" --help, -h Show this help");
106
113
  console.log("");
107
114
  console.log(" Examples:");
108
115
  console.log(" npx agentic-team");
116
+ console.log(" npx agentic-team --platforms ios,backend");
109
117
  console.log(" npx agentic-team new daily-intake");
110
118
  console.log("");
111
119
  }
@@ -441,22 +449,72 @@ function smartMergeDir(backupPath, restorePath, templateDirPath) {
441
449
  // The manifest is that marker, for everything, in one place both sides read.
442
450
  const MANIFEST_PATH = ".orchestra/.installed.json";
443
451
 
444
- /** Every destination path the template installs, repo-relative and POSIX. */
445
- function templateFileList() {
452
+ /**
453
+ * Every file the template installs for these platforms: its repo-relative
454
+ * POSIX destination and its source in the package. An empty platform list is
455
+ * everything.
456
+ */
457
+ function templateEntries(platforms, keepSkills) {
446
458
  const out = [];
447
459
  const walk = (dir, prefix) => {
448
460
  if (!fs.existsSync(dir)) return;
449
461
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
450
462
  const rel = `${prefix}/${entry.name}`;
451
- if (entry.isDirectory()) walk(path.join(dir, entry.name), rel);
452
- else out.push(rel);
463
+ const src = path.join(dir, entry.name);
464
+ if (entry.isDirectory()) walk(src, rel);
465
+ else if (platformsLib.selected(rel, platforms, keepSkills)) out.push({ rel, src });
453
466
  }
454
467
  };
455
468
  walk(path.join(templateDir, ".orchestra"), ".orchestra");
456
469
  for (const [pluginDir, claudeSubdir] of Object.entries(PLUGIN_TO_CLAUDE)) {
457
470
  walk(path.join(templateDir, pluginDir), claudeSubdir);
458
471
  }
459
- return out.sort();
472
+ return out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
473
+ }
474
+
475
+ /**
476
+ * Copy the template's files for these platforms into the project. A rule
477
+ * loses the sections marked for other platforms; every file keeps its name,
478
+ * so the manifest and the system-file hook see the same paths either way.
479
+ */
480
+ function installTemplate(entries, platforms) {
481
+ for (const { rel, src } of entries) {
482
+ const dest = path.join(targetDir, ...rel.split("/"));
483
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
484
+ // Dereference rather than recreate: a relative link that resolved in the
485
+ // package will not resolve in the user's tree.
486
+ const real = fs.realpathSync(src);
487
+ try {
488
+ if (fs.lstatSync(dest).isSymbolicLink()) fs.unlinkSync(dest);
489
+ } catch {
490
+ // Optional: on a fresh install there is nothing at dest yet.
491
+ }
492
+ if (rel.startsWith(".claude/rules/") && rel.endsWith(".md")) {
493
+ const text = fs.readFileSync(real, "utf-8");
494
+ fs.writeFileSync(dest, platformsLib.filterSections(text, platforms));
495
+ } else {
496
+ fs.copyFileSync(real, dest);
497
+ }
498
+ }
499
+ }
500
+
501
+ /** Names in a platform list that are not platforms. */
502
+ const unknownPlatforms = (list) => list.filter((p) => !platformsLib.PLATFORMS.includes(p));
503
+
504
+ /**
505
+ * Which platforms this run installs for, and where that answer came from:
506
+ * the flag (already validated), then the config, then detection. A config
507
+ * naming something that is not a platform is refused, not narrowed around —
508
+ * dropping the name once retired a platform's files and rewrote the user's
509
+ * list without it.
510
+ */
511
+ function resolvePlatforms(flagValue, configText, detect) {
512
+ if (flagValue) return { list: flagValue, source: "--platforms" };
513
+ const configured = platformsLib.readConfigPlatforms(configText || "");
514
+ const bad = unknownPlatforms(configured);
515
+ if (bad.length) return { error: `project.platforms in config.yml names ${bad.join(", ")}` };
516
+ if (configured.length) return { list: configured, source: "config.yml" };
517
+ return { list: detect(), source: "detected" };
460
518
  }
461
519
 
462
520
  function readManifest() {
@@ -464,7 +522,9 @@ function readManifest() {
464
522
  if (!fs.existsSync(p)) return null;
465
523
  try {
466
524
  const m = JSON.parse(fs.readFileSync(p, "utf-8"));
467
- return Array.isArray(m.files) ? m : null;
525
+ if (Array.isArray(m?.files)) return m;
526
+ console.log(" [!] .orchestra/.installed.json has no files list — nothing retired this run");
527
+ return null;
468
528
  } catch {
469
529
  // A corrupt manifest means retirement cannot run this time. Reported, and
470
530
  // not fatal: the install itself is still correct without it.
@@ -481,18 +541,22 @@ function writeManifest(files) {
481
541
  }
482
542
 
483
543
  /**
484
- * Delete what a previous version installed and this one no longer ships.
544
+ * Delete what a previous install put down and this one does not: a file this
545
+ * version no longer ships, or one for a platform the project no longer lists.
546
+ * `alsoRetire` adds paths no manifest records, for an install older than it.
485
547
  *
486
548
  * Without it a retired skill is indistinguishable from one the user wrote, so
487
549
  * it survives every future upgrade and keeps teaching whatever it taught.
488
550
  */
489
- function retireRemoved(previous, current) {
490
- if (!previous) return 0;
551
+ function retireRemoved(previous, current, alsoRetire = []) {
491
552
  const keep = new Set(current);
492
553
  let removed = 0;
493
- for (const rel of previous.files) {
554
+ for (const rel of new Set([...(previous?.files || []), ...alsoRetire])) {
494
555
  if (keep.has(rel)) continue;
495
556
  const full = path.join(targetDir, rel);
557
+ // The manifest is a committed file anyone can edit. An entry that leaves
558
+ // the project is never deleted.
559
+ if (!full.startsWith(targetDir + path.sep)) continue;
496
560
  if (!fs.existsSync(full)) continue;
497
561
  fs.unlinkSync(full);
498
562
  // The directory it lived in is ours too when nothing is left in it.
@@ -711,11 +775,12 @@ function setupPermissions() {
711
775
  * Deliberately stops before `/orchestra:setup`. Setup is a conversation — it
712
776
  * asks what you are building — and that conversation has to happen inside the
713
777
  * new project, where the project's own CLAUDE.md and role boundaries are
714
- * loaded. Run from the orchestrator repo instead, an agent would be wearing
778
+ * loaded. Run from the agentic-team repo instead, an agent would be wearing
715
779
  * the system maintainer's contract at the moment it starts building an app.
716
780
  */
717
781
  function newProject(argv) {
718
- const name = argv.find((a) => !a.startsWith("-"));
782
+ const platforms = platformsLib.takePlatformsArg(argv);
783
+ const name = argv.find((a, i) => !a.startsWith("-") && !platforms?.used.has(i));
719
784
  const isPublic = argv.includes("--public");
720
785
 
721
786
  if (!name) {
@@ -731,6 +796,15 @@ function newProject(argv) {
731
796
  return;
732
797
  }
733
798
 
799
+ // Checked before the repository exists: the installer would refuse a typo
800
+ // only after GitHub already has an empty repository by that name.
801
+ if (platforms && (!platforms.list.length || unknownPlatforms(platforms.list).length)) {
802
+ console.log(` [x] --platforms takes a comma list of: ${platformsLib.PLATFORMS.join(", ")}`);
803
+ process.exitCode = 1;
804
+ return;
805
+ }
806
+ const forward = platforms ? ["--platforms", platforms.list.join(",")] : [];
807
+
734
808
  const dir = path.resolve(targetDir, name);
735
809
  if (fs.existsSync(dir)) {
736
810
  console.log(` [x] ${name}/ already exists here. Refusing to write into it.`);
@@ -771,7 +845,7 @@ function newProject(argv) {
771
845
  // The installer runs unchanged, in the new directory. Copying its logic here
772
846
  // would give the manifest, the upgrade path and the hook registration a
773
847
  // second implementation to drift from.
774
- execFileSync(process.execPath, [__filename], { cwd: dir, stdio: "inherit" });
848
+ execFileSync(process.execPath, [__filename, ...forward], { cwd: dir, stdio: "inherit" });
775
849
  }
776
850
 
777
851
  function run() {
@@ -781,6 +855,20 @@ function run() {
781
855
  return;
782
856
  }
783
857
 
858
+ // Refused before anything is touched: a typo in the flag would otherwise
859
+ // install for the wrong platforms and retire the right ones. An empty list
860
+ // means "everything", which is never what a flag asking for platforms meant.
861
+ if (flags.platforms && (!flags.platforms.length || unknownPlatforms(flags.platforms).length)) {
862
+ console.log("");
863
+ console.log(
864
+ ` [x] Unknown platform: ${unknownPlatforms(flags.platforms).join(", ") || "(none given)"}`,
865
+ );
866
+ console.log(` Expected any of: ${platformsLib.PLATFORMS.join(", ")}`);
867
+ console.log("");
868
+ process.exitCode = 1;
869
+ return;
870
+ }
871
+
784
872
  console.log("");
785
873
  console.log(" Orchestra — AI Team Orchestration");
786
874
  console.log(` Installing into: ${targetDir}`);
@@ -793,6 +881,38 @@ function run() {
793
881
  // Read before anything is copied: after the install the manifest describes
794
882
  // this version, and what a previous one left behind is no longer knowable.
795
883
  const previousManifest = readManifest();
884
+ // Asked separately: a manifest that exists but does not parse is not an old
885
+ // install, and treating it as one sweeps user files the manifest would have
886
+ // told apart. Read now, because the upgrade removes .orchestra/ below.
887
+ const manifestExisted = fs.existsSync(path.join(targetDir, MANIFEST_PATH));
888
+
889
+ // Also before the copy: the upgrade's config merge cannot keep a block-form
890
+ // list over the template's flow-form `platforms: []`, so the answer is
891
+ // taken from the user's file as it stands and written back afterwards.
892
+ const configBefore = path.join(orchestraDest, "config.yml");
893
+ let detected = null;
894
+ const detect = () => {
895
+ detected ??= platformsLib.detectPlatforms(targetDir);
896
+ return detected;
897
+ };
898
+ const profile = resolvePlatforms(
899
+ flags.platforms,
900
+ fs.existsSync(configBefore) ? fs.readFileSync(configBefore, "utf-8") : "",
901
+ detect,
902
+ );
903
+ if (profile.error) {
904
+ console.log(` [x] ${profile.error}.`);
905
+ console.log(
906
+ ` Expected any of: ${platformsLib.PLATFORMS.join(", ")}. Nothing was changed.`,
907
+ );
908
+ console.log("");
909
+ process.exitCode = 1;
910
+ return;
911
+ }
912
+ // Read before the upgrade moves milestones/ aside.
913
+ const phaseSkills = platformsLib.phaseSkills(targetDir);
914
+ const entries = templateEntries(profile.list, phaseSkills);
915
+ const allEntries = templateEntries([]);
796
916
 
797
917
  if (isUpgrade) {
798
918
  // A backup nobody finished restoring holds the only copy of the user's
@@ -923,17 +1043,8 @@ function run() {
923
1043
  }
924
1044
  }
925
1045
 
926
- copyDirRecursive(orchestraSrc, orchestraDest);
1046
+ installTemplate(entries, profile.list);
927
1047
  console.log(" [+] .orchestra/ installed");
928
-
929
- // Copy plugin dirs → .claude/ (plugin-to-standalone mapping)
930
- for (const [pluginDir, claudeSubdir] of Object.entries(PLUGIN_TO_CLAUDE)) {
931
- const src = path.join(templateDir, pluginDir);
932
- if (fs.existsSync(src)) {
933
- const dest = path.join(targetDir, claudeSubdir);
934
- copyDirRecursive(src, dest);
935
- }
936
- }
937
1048
  console.log(" [+] .claude/ orchestra files installed");
938
1049
 
939
1050
  // ── Restore user data ──
@@ -988,30 +1099,111 @@ function run() {
988
1099
  console.log("");
989
1100
  }
990
1101
  }
991
- } else {
992
- // ── Fresh install ──
993
- copyDirRecursive(orchestraSrc, orchestraDest);
994
- console.log(" [+] .orchestra/ installed");
995
1102
 
996
- // Copy plugin dirs → .claude/ (plugin-to-standalone mapping)
997
- for (const [pluginDir, claudeSubdir] of Object.entries(PLUGIN_TO_CLAUDE)) {
998
- const src = path.join(templateDir, pluginDir);
999
- if (fs.existsSync(src)) {
1000
- const dest = path.join(targetDir, claudeSubdir);
1001
- copyDirRecursive(src, dest);
1103
+ // A milestone written before the split keeps decisions and the journal
1104
+ // inside context.md. The script moves them out losslessly, new files
1105
+ // first, so a failure here leaves context.md whole.
1106
+ const contextScript = path.join(orchestraDest, "scripts", "context.mjs");
1107
+ if (fs.existsSync(contextScript)) {
1108
+ try {
1109
+ const out = execFileSync(process.execPath, [contextScript, "migrate"], {
1110
+ cwd: targetDir,
1111
+ encoding: "utf8",
1112
+ });
1113
+ for (const line of out.split("\n").filter(Boolean)) console.log(` [~] ${line}`);
1114
+ } catch (e) {
1115
+ // Not fatal: the install itself is complete and context.md is whole,
1116
+ // because the split writes the new files before it touches it. What
1117
+ // is left is one command, and the message names it.
1118
+ console.log(` [!] context.md not split (${e.message.split("\n")[0]}); run it again with`);
1119
+ console.log(" node .orchestra/scripts/context.mjs migrate");
1002
1120
  }
1003
1121
  }
1122
+ } else {
1123
+ // ── Fresh install ──
1124
+ installTemplate(entries, profile.list);
1125
+ console.log(" [+] .orchestra/ installed");
1004
1126
  console.log(" [+] .claude/ installed");
1005
1127
  }
1006
1128
 
1007
1129
  // ── Record what was installed ──
1008
- const installed = templateFileList();
1009
- const retired = retireRemoved(previousManifest, installed);
1130
+ const installed = entries.map((e) => e.rel);
1131
+ // The manifest retires what this project no longer lists. An install from
1132
+ // before the manifest has none, so its files for other platforms would look
1133
+ // like the user's own; only then is every such template path retired. With
1134
+ // a manifest, a path it does not list is the user's — somebody who writes
1135
+ // their own skill under a name a dropped platform freed keeps it.
1136
+ const unselected =
1137
+ isUpgrade && !manifestExisted
1138
+ ? allEntries.map((e) => e.rel).filter((rel) => !installed.includes(rel))
1139
+ : [];
1140
+ const retired = retireRemoved(previousManifest, installed, unselected);
1010
1141
  if (retired > 0) {
1011
- console.log(` [~] Removed ${retired} system file(s) this version no longer ships`);
1142
+ console.log(` [~] Removed ${retired} system file(s) this install no longer ships`);
1012
1143
  }
1013
1144
  writeManifest(installed);
1014
1145
 
1146
+ // ── Record the platforms ──
1147
+ const cfgPath = path.join(orchestraDest, "config.yml");
1148
+ if (fs.existsSync(cfgPath)) {
1149
+ const text = fs.readFileSync(cfgPath, "utf-8");
1150
+ const next = platformsLib.writeConfigPlatforms(text, profile.list);
1151
+ if (next === null) {
1152
+ console.log(" [!] project: in config.yml is an inline map — platforms not written back.");
1153
+ console.log(" Write it as a block, with platforms: [ios] indented under project:");
1154
+ } else {
1155
+ fs.writeFileSync(cfgPath, next);
1156
+ }
1157
+ }
1158
+ if (profile.list.length) {
1159
+ const skipped = allEntries.length - entries.length;
1160
+ console.log(
1161
+ ` [~] Platforms: ${profile.list.join(", ")} (${profile.source})` +
1162
+ (skipped ? ` — ${skipped} file(s) for other platforms not installed` : ""),
1163
+ );
1164
+ if (profile.source === "detected") {
1165
+ console.log(
1166
+ " Wrong? Edit project.platforms in .orchestra/config.yml, or pass --platforms.",
1167
+ );
1168
+ } else {
1169
+ // The list is trusted once written, so an app added later is said out
1170
+ // loud on every run rather than silently left out.
1171
+ const unlisted = detect().filter((p) => !profile.list.includes(p));
1172
+ if (unlisted.length) {
1173
+ console.log(
1174
+ ` [!] The repository also looks like ${unlisted.join(", ")}, which project.platforms does not list.`,
1175
+ );
1176
+ console.log(" Add it to project.platforms if this project builds for it.");
1177
+ }
1178
+ }
1179
+ } else {
1180
+ console.log(
1181
+ " [~] Platforms: none set — everything installed. Set project.platforms or pass --platforms to narrow it.",
1182
+ );
1183
+ }
1184
+
1185
+ // A skill kept only because a phase names it is said, so it is not mistaken
1186
+ // for one the profile chose; a name that exists nowhere is a broken plan.
1187
+ const keptOnlyByPhase = [...phaseSkills].filter(
1188
+ (n) =>
1189
+ platformsLib.SKILLS[n] !== undefined &&
1190
+ !allEntries.some(
1191
+ (e) =>
1192
+ e.rel.startsWith(`.claude/skills/${n}/`) && platformsLib.selected(e.rel, profile.list),
1193
+ ),
1194
+ );
1195
+ if (keptOnlyByPhase.length) {
1196
+ console.log(
1197
+ ` [~] Kept for other platforms because a phase names them: ${keptOnlyByPhase.join(", ")}`,
1198
+ );
1199
+ }
1200
+ const missing = [...phaseSkills].filter(
1201
+ (n) => !fs.existsSync(path.join(targetDir, ".claude", "skills", n, "SKILL.md")),
1202
+ );
1203
+ if (missing.length) {
1204
+ console.log(` [!] A phase names a skill that does not exist: ${missing.join(", ")}`);
1205
+ }
1206
+
1015
1207
  // ── Handle CLAUDE.md ──
1016
1208
  const templateClaudeMd = fs.readFileSync(path.join(templateDir, "CLAUDE.md"), "utf-8");
1017
1209
  const targetClaudeMdPath = path.join(targetDir, "CLAUDE.md");
@@ -1092,9 +1284,12 @@ function run() {
1092
1284
  // The previous message sent a fresh repo straight to the PM, while the
1093
1285
  // CLAUDE.md it had just written says to point at setup before anything else.
1094
1286
  // The first screen a new user reads contradicted the system it installed.
1287
+ // A native app at the root has neither apps/ nor a package.json, so
1288
+ // detection counts too.
1095
1289
  const scaffolded =
1096
1290
  fs.existsSync(path.join(targetDir, "apps")) ||
1097
- fs.existsSync(path.join(targetDir, "package.json"));
1291
+ fs.existsSync(path.join(targetDir, "package.json")) ||
1292
+ detect().length > 0;
1098
1293
 
1099
1294
  // Preserved, and said out loud. Silently keeping a key nothing reads is how
1100
1295
  // somebody spends an afternoon wondering why their setting has no effect.