forge-orkes 0.61.0 → 0.63.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 (34) hide show
  1. package/bin/create-forge.js +250 -99
  2. package/experimental/conventions/README.md +87 -0
  3. package/experimental/conventions/install.sh +179 -0
  4. package/experimental/conventions/source/rules/code-quality.md +17 -0
  5. package/experimental/conventions/source/rules/laravel-http.md +21 -0
  6. package/experimental/conventions/source/rules/laravel-jobs.md +19 -0
  7. package/experimental/conventions/source/rules/laravel-migrations.md +19 -0
  8. package/experimental/conventions/source/rules/laravel-services.md +21 -0
  9. package/experimental/conventions/source/rules/python.md +14 -0
  10. package/experimental/conventions/source/rules/testing.md +15 -0
  11. package/experimental/conventions/source/rules/vue-inertia.md +24 -0
  12. package/experimental/conventions/source/skills/laravel-conventions/SKILL.md +124 -0
  13. package/experimental/conventions/source/skills/python-conventions/SKILL.md +100 -0
  14. package/experimental/conventions/source/skills/testing-standards/SKILL.md +142 -0
  15. package/experimental/conventions/source/skills/vue-conventions/SKILL.md +92 -0
  16. package/experimental/conventions/uninstall.sh +97 -0
  17. package/experimental/m10/README.md +130 -0
  18. package/experimental/m10/install.sh +198 -0
  19. package/experimental/m10/source/hooks/forge-branch-guard.sh +61 -0
  20. package/experimental/m10/source/hooks/forge-claim-check-doctor.sh +77 -0
  21. package/experimental/m10/source/hooks/forge-claim-check.sh +113 -0
  22. package/experimental/m10/source/hooks/forge-session-id.sh +22 -0
  23. package/experimental/m10/source/mcp-server/README.md +74 -0
  24. package/experimental/m10/source/mcp-server/example.mcp.json +8 -0
  25. package/experimental/m10/source/mcp-server/index.js +460 -0
  26. package/experimental/m10/source/mcp-server/package.json +17 -0
  27. package/experimental/m10/source/skills/orchestrating/SKILL.md +223 -0
  28. package/experimental/m10/source/skills/orchestrating/bootstrap-checks.md +70 -0
  29. package/experimental/m10/uninstall.sh +69 -0
  30. package/package.json +3 -2
  31. package/template/.claude/settings.json +1 -7
  32. package/template/.claude/skills/upgrading/SKILL.md +105 -176
  33. package/template/.forge/FORGE.md +96 -200
  34. package/template/.forge/migrations/0.63.0-origin-first-upgrading.md +49 -0
@@ -10,6 +10,26 @@ const templateDir = path.join(__dirname, '..', 'template');
10
10
  const targetDir = process.cwd();
11
11
  const pkgVersion = require('../package.json').version;
12
12
 
13
+ // --- CLI flags (parsed once up front) ---
14
+
15
+ // --json: machine-readable `upgrade` mode — exactly ONE JSON document on stdout
16
+ // (the report, or an {error, reason} object on refusal); every line of human
17
+ // prose is diverted to stderr so stdout stays parseable. --yes: auto-confirm
18
+ // stale-framework-file removal ONLY — it never bypasses the downgrade guard
19
+ // (that remains --force, which keeps its existing dual meaning).
20
+ const jsonMode = process.argv.includes('--json');
21
+ const yesMode = process.argv.includes('--yes');
22
+
23
+ // Route human prose: stdout normally, stderr under --json.
24
+ function say(...args) {
25
+ (jsonMode ? console.error : console.log)(...args);
26
+ }
27
+
28
+ // The only stdout write allowed in --json mode: one JSON document.
29
+ function emitJson(doc) {
30
+ process.stdout.write(JSON.stringify(doc, null, 2) + '\n');
31
+ }
32
+
13
33
  // --- CLAUDE.md import management ---
14
34
 
15
35
  // The framework prose lives in .forge/FORGE.md (a framework-owned file). CLAUDE.md
@@ -39,15 +59,15 @@ const FRAMEWORK_OWNED_DIRS = ['.claude/agents', '.claude/skills'];
39
59
  // They are NOT in the shipped base template, so the framework-owned auto-clean
40
60
  // would otherwise delete them on upgrade. Preserve them instead.
41
61
  //
42
- // The npm tarball ships only bin/ + template/ — NOT experimental/ — so this route
43
- // cannot diff or re-sync an experimental skill against its source. Preserving
44
- // blindly is what silently froze them pre-0.33.0 (issue #5): a base migration that
45
- // changed experimental-skill behavior became a no-op. The structural fix lives
46
- // elsewhere (a) the clone-based `upgrading` skill (Step 3b) offers a real
47
- // re-sync against experimental/*/source, and (b) load-bearing conventions are
48
- // promoted to base (FORGE.md) so they no longer depend on the frozen skill. Here
49
- // we can only PRESERVE + flag, so the report below tells the user staleness was
50
- // not checked and how to refresh.
62
+ // The npm tarball ships experimental/ alongside bin/ + template/, so this route
63
+ // CAN diff an installed experimental skill against its same-tarball source:
64
+ // detectExperimentalStaleness() reports unchanged|stale with the differing files.
65
+ // (Blind preservation is what silently froze these pre-0.33.0 issue #5: a base
66
+ // migration that changed experimental-skill behavior became a no-op.) Detection
67
+ // only — consent for a re-sync lives in the /upgrading skill layer, and
68
+ // load-bearing conventions are still promoted to base (FORGE.md) rather than
69
+ // left to a frozen skill. This preserve-list gates auto-clean only; staleness
70
+ // discovery is by directory scan of the shipped experimental/ tree.
51
71
  const EXPERIMENTAL_SKILL_PATHS = ['.claude/skills/orchestrating'];
52
72
 
53
73
  function isExperimentalSkillPath(displayPath) {
@@ -532,10 +552,14 @@ function extractDetectionBlock(guideContent) {
532
552
  * pkgVersion (this package's package.json) — never the template settings.json
533
553
  * literal, which is a placeholder the installer stamps at write time. Non-
534
554
  * interactive: the bin only warns and points at the guide; it never applies.
555
+ *
556
+ * Returns the applying guides as [{version, guide, reason}] — the human warning
557
+ * and the --json `migrations` key render the same findings.
535
558
  */
536
559
  function runPostUpgradeMigrationChecks(installedVersion) {
560
+ const found = [];
537
561
  const migrationsDir = path.join(targetDir, '.forge', 'migrations');
538
- if (!fs.existsSync(migrationsDir)) return;
562
+ if (!fs.existsSync(migrationsDir)) return found;
539
563
 
540
564
  const installed =
541
565
  installedVersion && installedVersion !== 'unknown' ? installedVersion : '0.0.0';
@@ -558,7 +582,7 @@ function runPostUpgradeMigrationChecks(installedVersion) {
558
582
  )
559
583
  .sort((a, b) => compareVersions(a.version, b.version));
560
584
  } catch {
561
- return;
585
+ return found;
562
586
  }
563
587
 
564
588
  for (const guide of guides) {
@@ -598,18 +622,22 @@ function runPostUpgradeMigrationChecks(installedVersion) {
598
622
  ? reasonLine.replace(/^.*MIGRATE\s*[—-]?\s*/, '').trim()
599
623
  : '';
600
624
 
601
- console.log(` ⚠ ${guide.version} migration available${reason ? ` ${reason}` : ''}`);
602
- console.log(' ─────────────────────────────────────────────────────────────');
603
- console.log(` Guide: .forge/migrations/${guide.name}`);
604
- console.log(
625
+ const hit = { version: guide.version, guide: `.forge/migrations/${guide.name}`, reason };
626
+ found.push(hit);
627
+
628
+ say(` ⚠ ${hit.version} migration available${hit.reason ? ` — ${hit.reason}` : ''}`);
629
+ say(' ─────────────────────────────────────────────────────────────');
630
+ say(` Guide: ${hit.guide}`);
631
+ say(
605
632
  ` (canonical: https://github.com/Attuned-Media/forge/blob/main/docs/migrations/${guide.name})`
606
633
  );
607
- console.log();
608
- console.log(' Migrations are never applied automatically. In Claude Code:');
609
- console.log(' /forge then hand the guide to quick-tasking, or invoke Skill(quick-tasking)');
610
- console.log(' with the guide path as the task definition.');
611
- console.log();
634
+ say();
635
+ say(' Migrations are never applied automatically. In Claude Code:');
636
+ say(' /forge then hand the guide to quick-tasking, or invoke Skill(quick-tasking)');
637
+ say(' with the guide path as the task definition.');
638
+ say();
612
639
  }
640
+ return found;
613
641
  }
614
642
 
615
643
  /**
@@ -632,8 +660,87 @@ function syncFrameworkFile(srcPath, destPath, displayName, results) {
632
660
  }
633
661
  }
634
662
 
663
+ /**
664
+ * Real staleness detection for opt-in experimental skills (FR-185). The tarball
665
+ * ships experimental/ (m10, conventions), so the bin diffs an installed
666
+ * experimental skill against its own same-tarball source. Discovery is by
667
+ * directory scan — an installed skill dir absent from the base template is a
668
+ * candidate; a matching {packageRoot}/experimental/{pkg}/source/skills/{name}/
669
+ * makes it reportable. A skill with no experimental source (a project-local
670
+ * skill) keeps today's preserved/removed-from-template verdicts and is not
671
+ * reported here. Detection ONLY: the bin never copies or overwrites an
672
+ * experimental skill — re-sync consent lives in the /upgrading skill (FR-186).
673
+ *
674
+ * Returns [{name, pkg, status: 'unchanged'|'stale', files: [differing .md]}].
675
+ */
676
+ function detectExperimentalStaleness() {
677
+ const found = [];
678
+ const installedSkillsDir = path.join(targetDir, '.claude', 'skills');
679
+ const templateSkillsDir = path.join(templateDir, '.claude', 'skills');
680
+ const experimentalRoot = path.join(__dirname, '..', 'experimental');
681
+
682
+ let installed;
683
+ try {
684
+ installed = fs
685
+ .readdirSync(installedSkillsDir, { withFileTypes: true })
686
+ .filter((e) => e.isDirectory())
687
+ .map((e) => e.name);
688
+ } catch {
689
+ return found; // no installed skills dir — nothing to report
690
+ }
691
+
692
+ let pkgs = [];
693
+ try {
694
+ pkgs = fs
695
+ .readdirSync(experimentalRoot, { withFileTypes: true })
696
+ .filter((e) => e.isDirectory())
697
+ .map((e) => e.name);
698
+ } catch {
699
+ return found; // tarball without experimental/ — nothing to diff against
700
+ }
701
+
702
+ for (const name of installed) {
703
+ if (fs.existsSync(path.join(templateSkillsDir, name))) continue; // base skill
704
+ for (const pkg of pkgs) {
705
+ const srcDir = path.join(experimentalRoot, pkg, 'source', 'skills', name);
706
+ if (!fs.existsSync(srcDir)) continue;
707
+ const destDir = path.join(installedSkillsDir, name);
708
+ const differing = [];
709
+ for (const rel of collectFiles(srcDir, '')) {
710
+ if (!rel.endsWith('.md')) continue;
711
+ const destPath = path.join(destDir, rel);
712
+ if (
713
+ !fs.existsSync(destPath) ||
714
+ Buffer.compare(
715
+ fs.readFileSync(path.join(srcDir, rel)),
716
+ fs.readFileSync(destPath)
717
+ ) !== 0
718
+ ) {
719
+ differing.push(rel);
720
+ }
721
+ }
722
+ found.push({
723
+ name,
724
+ pkg,
725
+ status: differing.length ? 'stale' : 'unchanged',
726
+ files: differing,
727
+ });
728
+ break; // first matching experimental pkg wins
729
+ }
730
+ }
731
+ return found;
732
+ }
733
+
635
734
  async function upgrade() {
636
- console.log('\n Forge Upgrade\n');
735
+ // A --json run must be non-interactive end-to-end: a prompt would corrupt the
736
+ // machine-read stream, so --json without --yes is refused before any work.
737
+ if (jsonMode && !yesMode) {
738
+ console.error(' ✖ --json requires --yes (a prompt would corrupt a machine run).\n');
739
+ emitJson({ error: 'yes_required', reason: '--json requires --yes' });
740
+ process.exit(1);
741
+ }
742
+
743
+ say('\n Forge Upgrade\n');
637
744
 
638
745
  // Verify Forge is installed
639
746
  const settingsPath = path.join(targetDir, SETTINGS_FILE);
@@ -641,6 +748,12 @@ async function upgrade() {
641
748
  console.error(
642
749
  ' Forge is not installed in this directory.\n Run `npx forge-orkes` first to install.\n'
643
750
  );
751
+ if (jsonMode) {
752
+ emitJson({
753
+ error: 'not_installed',
754
+ reason: 'Forge is not installed in this directory — run `npx forge-orkes` first',
755
+ });
756
+ }
644
757
  process.exit(1);
645
758
  }
646
759
 
@@ -653,8 +766,8 @@ async function upgrade() {
653
766
  // proceed with unknown version
654
767
  }
655
768
 
656
- console.log(` Installed: v${installedVersion}`);
657
- console.log(` Available: v${pkgVersion}\n`);
769
+ say(` Installed: v${installedVersion}`);
770
+ say(` Available: v${pkgVersion}\n`);
658
771
 
659
772
  // Downgrade guard: refuse to roll backward (overwrites newer framework files
660
773
  // with older ones and deletes files newer versions added). Almost always a
@@ -669,6 +782,12 @@ async function upgrade() {
669
782
  console.error(` This usually means a stale npx cache served an old forge-orkes.`);
670
783
  console.error(` Fix: npx forge-orkes@latest upgrade (or clear the cache: rm -rf ~/.npm/_npx)`);
671
784
  console.error(` To downgrade intentionally: forge-orkes upgrade --force\n`);
785
+ if (jsonMode) {
786
+ emitJson({
787
+ error: 'downgrade_refused',
788
+ reason: `installed v${installedVersion} is newer than available v${pkgVersion}`,
789
+ });
790
+ }
672
791
  process.exit(1);
673
792
  }
674
793
 
@@ -720,12 +839,9 @@ async function upgrade() {
720
839
  // to yes (cleanup is the normal case); auto-confirms when non-interactive
721
840
  // (CI / piped stdin) or with --force/--yes, preserving prior behavior.
722
841
  if (pendingRemovals.length > 0) {
723
- const auto =
724
- process.argv.includes('--force') ||
725
- process.argv.includes('--yes') ||
726
- !process.stdin.isTTY;
727
- console.log(` ${pendingRemovals.length} stale framework file(s) no longer in the template:`);
728
- for (const c of pendingRemovals) console.log(` ${c.displayPath}`);
842
+ const auto = force || yesMode || !process.stdin.isTTY;
843
+ say(` ${pendingRemovals.length} stale framework file(s) no longer in the template:`);
844
+ for (const c of pendingRemovals) say(` ${c.displayPath}`);
729
845
  let go = auto;
730
846
  if (!auto) {
731
847
  const ans = await prompt(' Remove these stale framework files? [Y/n] ');
@@ -743,12 +859,12 @@ async function upgrade() {
743
859
  results.removed.push(c.displayPath);
744
860
  }
745
861
  } else {
746
- console.log(' Keeping stale files (skipped removal).');
862
+ say(' Keeping stale files (skipped removal).');
747
863
  for (const c of pendingRemovals) {
748
864
  results.preserved.push(`${c.displayPath} (stale — kept by choice)`);
749
865
  }
750
866
  }
751
- console.log();
867
+ say();
752
868
  }
753
869
 
754
870
  // 2b. Sync the forge .gitignore. Shipped as `gitignore` (npm strips dotfiles
@@ -773,10 +889,10 @@ async function upgrade() {
773
889
  const claudeStatus = ensureClaudeMdImport();
774
890
  if (claudeStatus === 'migrated') {
775
891
  results.updated.push('CLAUDE.md');
776
- console.log(' CLAUDE.md migrated: forge section extracted to .forge/FORGE.md (@import)');
892
+ say(' CLAUDE.md migrated: forge section extracted to .forge/FORGE.md (@import)');
777
893
  } else if (claudeStatus === 'appended') {
778
894
  results.updated.push('CLAUDE.md');
779
- console.log(' CLAUDE.md: @.forge/FORGE.md import line restored');
895
+ say(' CLAUDE.md: @.forge/FORGE.md import line restored');
780
896
  } else if (claudeStatus === 'unchanged') {
781
897
  results.unchanged.push('CLAUDE.md');
782
898
  } else if (claudeStatus === 'created') {
@@ -791,64 +907,88 @@ async function upgrade() {
791
907
  results.unchanged.push(SETTINGS_FILE);
792
908
  }
793
909
 
910
+ // 5. Diff installed experimental skills against their same-tarball sources
911
+ // (detection only — never written; re-sync consent lives in /upgrading).
912
+ const experimental = detectExperimentalStaleness();
913
+
794
914
  // Report results
795
915
  const totalChanges = results.updated.length + results.added.length;
796
-
797
- if (totalChanges === 0 && results.removed.length === 0) {
798
- console.log(' Already up to date.\n');
799
- return;
800
- }
801
-
802
- if (results.updated.length > 0) {
803
- console.log(` Updated (${results.updated.length}):`);
804
- for (const f of results.updated) {
805
- console.log(` ${f}`);
916
+ const upToDate = totalChanges === 0 && results.removed.length === 0;
917
+
918
+ if (upToDate) {
919
+ say(' Already up to date.\n');
920
+ // Human mode preserves the historical early exit: an up-to-date project
921
+ // skips the migration checks and notices entirely. --json continues so the
922
+ // report document always carries every contract key.
923
+ if (!jsonMode) return;
924
+ } else {
925
+ if (results.updated.length > 0) {
926
+ say(` Updated (${results.updated.length}):`);
927
+ for (const f of results.updated) {
928
+ say(` ${f}`);
929
+ }
930
+ say();
806
931
  }
807
- console.log();
808
- }
809
932
 
810
- if (results.added.length > 0) {
811
- console.log(` Added (${results.added.length}):`);
812
- for (const f of results.added) {
813
- console.log(` ${f}`);
933
+ if (results.added.length > 0) {
934
+ say(` Added (${results.added.length}):`);
935
+ for (const f of results.added) {
936
+ say(` ${f}`);
937
+ }
938
+ say();
814
939
  }
815
- console.log();
816
- }
817
940
 
818
- if (results.removed.length > 0) {
819
- console.log(` Cleaned up (${results.removed.length}):`);
820
- for (const f of results.removed) {
821
- console.log(` ${f} (removed — no longer in framework)`);
941
+ if (results.removed.length > 0) {
942
+ say(` Cleaned up (${results.removed.length}):`);
943
+ for (const f of results.removed) {
944
+ say(` ${f} (removed — no longer in framework)`);
945
+ }
946
+ say();
822
947
  }
823
- console.log();
824
- }
825
948
 
826
- if (results.preserved.length > 0) {
827
- console.log(` Preserved (${results.preserved.length}):`);
828
- for (const f of results.preserved) {
829
- console.log(` ${f} (kept — opt-in experimental skill; staleness not checked by npm)`);
949
+ if (results.preserved.length > 0) {
950
+ say(` Preserved (${results.preserved.length}):`);
951
+ for (const f of results.preserved) {
952
+ say(` ${f} (kept — opt-in experimental skill)`);
953
+ }
954
+ for (const e of experimental) {
955
+ say(
956
+ e.status === 'stale'
957
+ ? ` ↳ ${e.name} (${e.pkg}): stale — differs from the shipped source: ${e.files.join(', ')}`
958
+ : ` ↳ ${e.name} (${e.pkg}): up to date with the shipped source`
959
+ );
960
+ }
961
+ say(` ↳ re-sync via /upgrading (consent-gated; the bin never overwrites these)`);
962
+ say();
830
963
  }
831
- console.log(
832
- ` ↳ The npm upgrade does not ship experimental sources, so it cannot`
833
- );
834
- console.log(
835
- ` re-sync these. To refresh against a Forge clone, run the \`upgrading\``
836
- );
837
- console.log(
838
- ` skill (offers a re-sync), or re-run \`experimental/<pkg>/install.sh\`.`
839
- );
840
- console.log();
841
- }
842
964
 
843
- console.log(` Upgraded to v${pkgVersion}\n`);
965
+ say(` Upgraded to v${pkgVersion}\n`);
966
+ }
844
967
 
845
968
  // Pass the version captured BEFORE upgradeSettings() stamped the new one, so the
846
969
  // migration range is (installed, source] against the freshly-synced guides.
847
- runPostUpgradeMigrationChecks(installedVersion);
970
+ const migrations = runPostUpgradeMigrationChecks(installedVersion);
848
971
 
849
972
  // Presence-gated advisories (no generation, no rebase — notices only).
850
- noticeCodexAdapter();
851
- noticeWorktreeLag();
973
+ const codexAdapterPresent = noticeCodexAdapter();
974
+ const worktrees = noticeWorktreeLag();
975
+
976
+ if (jsonMode) {
977
+ emitJson({
978
+ schemaVersion: 1,
979
+ command: 'upgrade',
980
+ installedVersion,
981
+ packageVersion: pkgVersion,
982
+ packageRoot: path.join(__dirname, '..'),
983
+ results,
984
+ claudeMd: claudeStatus,
985
+ settings: settingsStatus,
986
+ experimental,
987
+ migrations,
988
+ codexAdapterPresent,
989
+ worktrees,
990
+ });
991
+ }
852
992
  }
853
993
 
854
994
  /**
@@ -857,17 +997,21 @@ async function upgrade() {
857
997
  * .claude/ sync and point to the regeneration route (/upgrading Step 4b). It
858
998
  * never generates, never writes .agents/, never adds .agents to
859
999
  * FRAMEWORK_OWNED_DIRS. Silent when no Codex adapter is present.
1000
+ *
1001
+ * Returns whether a Codex adapter is present — the human notice and the --json
1002
+ * `codexAdapterPresent` key render the same finding.
860
1003
  */
861
1004
  function noticeCodexAdapter() {
862
1005
  const hasCodex =
863
1006
  fs.existsSync(path.join(targetDir, '.agents')) ||
864
1007
  fs.existsSync(path.join(targetDir, '.codex'));
865
- if (!hasCodex) return;
866
- console.log(' Codex adapter detected (.agents/ / .codex/).');
867
- console.log(' It may now be stale relative to the .claude/ source just synced.');
868
- console.log(' This installer does not regenerate it (no LLM). To refresh it,');
869
- console.log(' run the `/upgrading` skill — its Codex step offers regeneration');
870
- console.log(' from .forge/adapters/codex-generation.md.\n');
1008
+ if (!hasCodex) return false;
1009
+ say(' Codex adapter detected (.agents/ / .codex/).');
1010
+ say(' It may now be stale relative to the .claude/ source just synced.');
1011
+ say(' This installer does not regenerate it (no LLM). To refresh it,');
1012
+ say(' run the `/upgrading` skill — its Codex step offers regeneration');
1013
+ say(' from .forge/adapters/codex-generation.md.\n');
1014
+ return true;
871
1015
  }
872
1016
 
873
1017
  /**
@@ -876,6 +1020,9 @@ function noticeCodexAdapter() {
876
1020
  * branch-point framework files until rebased. The bin only NOTICES — it never
877
1021
  * rebases (a rebase can conflict). Silent when there are no live forge worktrees
878
1022
  * or this is not a git repo.
1023
+ *
1024
+ * Returns the lagging worktrees as [{path, branch}] — the human notice and the
1025
+ * --json `worktrees` key render the same findings.
879
1026
  */
880
1027
  function noticeWorktreeLag() {
881
1028
  let out;
@@ -885,7 +1032,7 @@ function noticeWorktreeLag() {
885
1032
  stdio: ['ignore', 'pipe', 'ignore'],
886
1033
  }).toString();
887
1034
  } catch {
888
- return; // not a git repo / git unavailable
1035
+ return []; // not a git repo / git unavailable
889
1036
  }
890
1037
  const lagging = [];
891
1038
  let curPath = null;
@@ -898,15 +1045,16 @@ function noticeWorktreeLag() {
898
1045
  }
899
1046
  }
900
1047
  }
901
- if (!lagging.length) return;
902
- console.log(` ${lagging.length} live Forge worktree(s) lag this upgrade:`);
1048
+ if (!lagging.length) return lagging;
1049
+ say(` ${lagging.length} live Forge worktree(s) lag this upgrade:`);
903
1050
  for (const w of lagging) {
904
- console.log(` ${w.path} (${w.branch})`);
1051
+ say(` ${w.path} (${w.branch})`);
905
1052
  }
906
- console.log(' They keep their branch-point framework files until rebased.');
907
- console.log(' Pick up this upgrade in each: git -C <path> rebase main');
908
- console.log(' (Gitignored carve-outs like .mcp.json / experimental hooks do not');
909
- console.log(' rebase — re-run the experimental installer if the upgrade changed them.)\n');
1053
+ say(' They keep their branch-point framework files until rebased.');
1054
+ say(' Pick up this upgrade in each: git -C <path> rebase main');
1055
+ say(' (Gitignored carve-outs like .mcp.json / experimental hooks do not');
1056
+ say(' rebase — re-run the experimental installer if the upgrade changed them.)\n');
1057
+ return lagging;
910
1058
  }
911
1059
 
912
1060
  // --- Entry point ---
@@ -931,18 +1079,21 @@ assertSaneTargetDir();
931
1079
 
932
1080
  const subcommand = process.argv[2];
933
1081
 
1082
+ // Shared failure path for both upgrade entries: human error on stderr; under
1083
+ // --json also emit the {error, reason} document so a machine caller always
1084
+ // gets a parseable stdout even on an unexpected failure.
1085
+ function upgradeFailed(err) {
1086
+ console.error('Error:', err.message);
1087
+ if (jsonMode) emitJson({ error: 'upgrade_failed', reason: err.message });
1088
+ process.exit(1);
1089
+ }
1090
+
934
1091
  if (subcommand === 'upgrade') {
935
- upgrade().catch((err) => {
936
- console.error('Error:', err.message);
937
- process.exit(1);
938
- });
1092
+ upgrade().catch(upgradeFailed);
939
1093
  } else if (isForgeInstalled()) {
940
1094
  // Auto-detect: Forge already installed, run upgrade instead
941
- console.log(' Forge detected — running upgrade automatically.\n');
942
- upgrade().catch((err) => {
943
- console.error('Error:', err.message);
944
- process.exit(1);
945
- });
1095
+ say(' Forge detected — running upgrade automatically.\n');
1096
+ upgrade().catch(upgradeFailed);
946
1097
  } else {
947
1098
  install().catch((err) => {
948
1099
  console.error('Error:', err.message);
@@ -0,0 +1,87 @@
1
+ # Forge Conventions — Stack Convention Skills + Rules (Experimental)
2
+
3
+ ## Status
4
+
5
+ **Experimental, opt-in.** Per [ADR-018](../../../../docs/decisions/ADR-018-lightweight-policy-layer.md), Forge core is **stack-agnostic** — it ships only the always-on `agent-discipline.md` rule and zero stack-specific conventions. Anything tied to a particular framework (Laravel, Vue, Python) would make core non-portable, so it lives here as an opt-in pack you install **only when your project's stack matches**, exactly like the `m10` orchestration pack.
6
+
7
+ This pack is **not shipped on npm.** The `create-forge` tarball publishes only `bin/` + `template/` (same as `m10`). Convention packs are delivered via the dev repo or the `upgrading` skill's experimental route — clone/pull the Forge dev repo and run the installer against your project.
8
+
9
+ ## What it is
10
+
11
+ Opt-in stack convention **skills** (heavy, task-triggered checklists) plus path-scoped **rules** (lightweight, file-path-triggered ambient reminders) for:
12
+
13
+ - **Laravel / PHP** — strict types, thin controllers, Services/Actions, Eloquent, migrations, jobs, security
14
+ - **Python** — type hints, Pydantic v2, async patterns, Django/FastAPI, DI via Protocols, tooling
15
+ - **Vue / Inertia** — `<script setup lang="ts">`, generated-type contracts, `useForm`, accessibility
16
+ - **Universal testing & code quality** — behaviour-not-implementation tests, function-size/magic-value discipline
17
+
18
+ The skills load when a task description matches; the path-scoped rules cost **zero context** until you touch a matching file (`app/Http/**`, `**/*.vue`, `**/*.py`, …). See the rules-layer design in [ADR-018](../../../../docs/decisions/ADR-018-lightweight-policy-layer.md) and `.claude/rules/README.md` in core.
19
+
20
+ These conventions are de-coupled from any single company's setup: multi-tenancy is treated as conditional ("multi-tenant apps") rather than assumed, and the Laravel layout supports both **standard** Laravel (`app/Http/**`, `app/Services/**`) and a nested **module** layout (`app/<project>/<module>/Http/**`) — the rules are dual-globbed so they fire on either.
21
+
22
+ ## What it installs
23
+
24
+ Into the **target project's** `.claude/`:
25
+
26
+ ### Skills → `.claude/skills/`
27
+ | Skill | Stack |
28
+ |---|---|
29
+ | `laravel-conventions/` | laravel |
30
+ | `python-conventions/` | python |
31
+ | `vue-conventions/` | vue |
32
+ | `testing-standards/` | testing |
33
+
34
+ ### Rules → `.claude/rules/`
35
+ | Rule | Stack | Scope (`paths:`) |
36
+ |---|---|---|
37
+ | `laravel-http.md` | laravel | `app/Http/**`, `app/*/*/Http/**` |
38
+ | `laravel-services.md` | laravel | `app/Services/**`, `app/Actions/**` (+ module globs) |
39
+ | `laravel-jobs.md` | laravel | `app/Jobs/**`, `app/*/*/Jobs/**` |
40
+ | `laravel-migrations.md` | laravel | `database/migrations/**` (+ module glob) |
41
+ | `vue-inertia.md` | vue | `resources/js/pages/**`, `resources/js/components/**`, `**/*.vue` |
42
+ | `python.md` | python | `**/*.py` |
43
+ | `code-quality.md` | universal | always-on (no `paths:`) |
44
+ | `testing.md` | universal / testing | always-on (no `paths:`) |
45
+
46
+ **Why `code-quality.md` and `testing.md` are included (and additive, not redundant with core):** core ships only `agent-discipline.md`, which covers *agent* failure modes (scope creep, phantom deps, hallucinated APIs). It does **not** cover code *shape* (function/file size, magic values, nesting) or the ambient testing floor (AAA, one-behaviour-per-test, no flaky retries). Both are universal and stack-agnostic, so they ship here as always-on rules that complement core. `agent-discipline.md` itself is **not** re-shipped — it already lives in core, so installing it again would clobber/duplicate.
47
+
48
+ ## Install
49
+
50
+ Run from your project root (the directory containing `.forge/project.yml`):
51
+
52
+ ```bash
53
+ # All stacks:
54
+ bash experimental/conventions/install.sh
55
+
56
+ # Selectively — only the stacks you use:
57
+ bash experimental/conventions/install.sh laravel vue
58
+ bash experimental/conventions/install.sh python
59
+ bash experimental/conventions/install.sh testing universal
60
+
61
+ # Explicit target path + stacks:
62
+ bash experimental/conventions/install.sh /path/to/your/project laravel
63
+
64
+ # Preview without writing:
65
+ bash experimental/conventions/install.sh --dry-run laravel
66
+ ```
67
+
68
+ Stacks: `laravel`, `python`, `vue`, `testing`, `universal`. No stack args = install all.
69
+
70
+ **Idempotent and non-clobbering.** If a target skill or rule of the same name already exists in `.claude/`, the installer **skips it and warns** rather than overwriting — this avoids the half-install / stale-skill trap (a same-named skill present from a prior install or your own authoring is never silently replaced). To intentionally refresh a stale file, delete the existing one first, then re-run.
71
+
72
+ ## Uninstall
73
+
74
+ ```bash
75
+ # Remove everything this pack installed:
76
+ bash experimental/conventions/uninstall.sh
77
+
78
+ # Remove only specific stacks:
79
+ bash experimental/conventions/uninstall.sh laravel
80
+ bash experimental/conventions/uninstall.sh /path/to/your/project vue
81
+ ```
82
+
83
+ The uninstaller only touches files this pack ships; your own skills/rules are left alone. Note: `testing.md` is shared by the `testing` and `universal` stacks, so removing either stack removes it.
84
+
85
+ ## Selective install rationale
86
+
87
+ Install only the stacks a project actually uses. A pure-Python service has no business carrying Laravel HTTP rules; a Vue+Laravel app skips `python`. Path-scoped rules cost zero context until a matching file is touched, but keeping the install scoped to your stack keeps `.claude/` clean and the skill list short.