forge-orkes 0.19.0 → 0.19.2

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.
@@ -18,6 +18,29 @@ const FORGE_END = '<!-- forge:end -->';
18
18
  // Framework-owned: Forge controls these entirely
19
19
  const FRAMEWORK_OWNED_DIRS = ['.claude/agents', '.claude/skills'];
20
20
 
21
+ // Experimental / opt-in skills installed separately (e.g. from experimental/m10).
22
+ // They are NOT in the shipped base template, so the framework-owned auto-clean
23
+ // would otherwise delete them on upgrade. Preserve them instead.
24
+ const EXPERIMENTAL_SKILL_PATHS = ['.claude/skills/orchestrating'];
25
+
26
+ function isExperimentalSkillPath(displayPath) {
27
+ const norm = displayPath.split(path.sep).join('/');
28
+ return EXPERIMENTAL_SKILL_PATHS.some(
29
+ (p) => norm === p || norm.startsWith(p + '/')
30
+ );
31
+ }
32
+
33
+ // Compare dotted numeric versions (e.g. "0.19.1"). Returns 1 if a>b, -1 if a<b, 0 if equal.
34
+ function compareVersions(a, b) {
35
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
36
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
37
+ for (let i = 0; i < 3; i++) {
38
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
39
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
40
+ }
41
+ return 0;
42
+ }
43
+
21
44
  // Template-only: reference templates Forge controls
22
45
  const TEMPLATE_ONLY_DIRS = ['.forge/templates', '.forge/migrations'];
23
46
 
@@ -106,7 +129,7 @@ function upgradeDir(relDir, { autoClean = false } = {}) {
106
129
  const srcDir = path.join(templateDir, relDir);
107
130
  const destDir = path.join(targetDir, relDir);
108
131
 
109
- const result = { updated: [], added: [], unchanged: [], removed: [] };
132
+ const result = { updated: [], added: [], unchanged: [], removed: [], preserved: [] };
110
133
 
111
134
  if (!fs.existsSync(srcDir)) return result;
112
135
 
@@ -138,6 +161,12 @@ function upgradeDir(relDir, { autoClean = false } = {}) {
138
161
  for (const rel of destFiles) {
139
162
  const srcPath = path.join(srcDir, rel);
140
163
  if (!fs.existsSync(srcPath)) {
164
+ const displayPath = path.join(relDir, rel);
165
+ // Never auto-clean opt-in experimental skills — they live outside the base template.
166
+ if (autoClean && isExperimentalSkillPath(displayPath)) {
167
+ result.preserved.push(displayPath);
168
+ continue;
169
+ }
141
170
  const destPath = path.join(destDir, rel);
142
171
  if (autoClean) {
143
172
  fs.unlinkSync(destPath);
@@ -381,7 +410,7 @@ function detectLegacyStateIndex() {
381
410
  }
382
411
 
383
412
  // Legacy markers: shared accumulators or per-milestone narrative drifted in.
384
- if (/^\s*desire_paths:|^\s*metrics:|current_status:/m.test(content)) return true;
413
+ if (/^\s*(desire_paths|metrics|current_status):/m.test(content)) return true;
385
414
  // A slim registry is small; KBs of index.yml means narrative crept in.
386
415
  if (Buffer.byteLength(content, 'utf-8') > 4096) return true;
387
416
 
@@ -468,11 +497,28 @@ async function upgrade() {
468
497
  console.log(` Installed: v${installedVersion}`);
469
498
  console.log(` Available: v${pkgVersion}\n`);
470
499
 
500
+ // Downgrade guard: refuse to roll backward (overwrites newer framework files
501
+ // with older ones and deletes files newer versions added). Almost always a
502
+ // stale npx cache serving an old forge-orkes. Override with --force.
503
+ const force = process.argv.includes('--force');
504
+ if (
505
+ installedVersion !== 'unknown' &&
506
+ compareVersions(pkgVersion, installedVersion) < 0 &&
507
+ !force
508
+ ) {
509
+ console.error(` ✖ Refusing to downgrade: installed v${installedVersion} is newer than available v${pkgVersion}.`);
510
+ console.error(` This usually means a stale npx cache served an old forge-orkes.`);
511
+ console.error(` Fix: npx forge-orkes@latest upgrade (or clear the cache: rm -rf ~/.npm/_npx)`);
512
+ console.error(` To downgrade intentionally: forge-orkes upgrade --force\n`);
513
+ process.exit(1);
514
+ }
515
+
471
516
  const results = {
472
517
  updated: [],
473
518
  added: [],
474
519
  unchanged: [],
475
520
  removed: [],
521
+ preserved: [],
476
522
  };
477
523
 
478
524
  // 1. Process framework-owned directories (auto-clean stale files)
@@ -482,6 +528,7 @@ async function upgrade() {
482
528
  results.added.push(...dirResult.added);
483
529
  results.unchanged.push(...dirResult.unchanged);
484
530
  results.removed.push(...dirResult.removed);
531
+ results.preserved.push(...dirResult.preserved);
485
532
  }
486
533
 
487
534
  // 2. Process template-only directories
@@ -491,6 +538,7 @@ async function upgrade() {
491
538
  results.added.push(...dirResult.added);
492
539
  results.unchanged.push(...dirResult.unchanged);
493
540
  results.removed.push(...dirResult.removed);
541
+ results.preserved.push(...dirResult.preserved);
494
542
  }
495
543
 
496
544
  // 2b. Sync the forge .gitignore. Shipped as `gitignore` (npm strips dotfiles
@@ -560,6 +608,14 @@ async function upgrade() {
560
608
  console.log();
561
609
  }
562
610
 
611
+ if (results.preserved.length > 0) {
612
+ console.log(` Preserved (${results.preserved.length}):`);
613
+ for (const f of results.preserved) {
614
+ console.log(` ${f} (kept — opt-in experimental skill)`);
615
+ }
616
+ console.log();
617
+ }
618
+
563
619
  console.log(` Upgraded to v${pkgVersion}\n`);
564
620
 
565
621
  runPostUpgradeMigrationChecks();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -15,13 +15,13 @@ Entry point. Detect tier, route skills, manage transitions. New projects → ini
15
15
 
16
16
  Rollup procedure (deterministic + idempotent):
17
17
  1. Glob `.forge/state/milestone-*.yml`.
18
- 2. For each, read `milestone.id`, `milestone.name`, `current.status`, `lifecycle.{deferred_at, resumed_at}`, `current.last_updated`.
18
+ 2. For each, read `milestone.id`, `milestone.name`, `current.status`, `lifecycle.{deferred_at, resumed_at}`, and the last-touched date as `current.last_updated` → else legacy `progress.last_update` → else null. (The fallback keeps pre-0.19.0 milestone files self-sourcing without editing them; active milestones gain `current.last_updated` on their next transition.)
19
19
  3. Derive the registry `status`:
20
20
  - **deferred** — `lifecycle.deferred_at` set and not superseded by a later `resumed_at`
21
21
  - **complete** — `current.status == complete`
22
22
  - **not_started** — `current.status == not_started`
23
23
  - **active** — otherwise
24
- 4. Rewrite `index.yml` `milestones:` (sorted by id) as `{id, name, status, last_updated: current.last_updated}`. No other keys.
24
+ 4. Rewrite `index.yml` `milestones:` (sorted by id) as `{id, name, status, last_updated}` (date from step 2's fallback). No other keys.
25
25
 
26
26
  Output is a pure function of the milestone files, so two sessions regenerating it produce identical bytes — it never needs a hand-merge. **Only the main/orchestrator session runs rollup; worktree agents never write `index.yml`** (they edit only their own `milestone-{id}.yml`).
27
27
 
@@ -18,6 +18,10 @@ Check if `.forge/dev-source` exists in the project root.
18
18
 
19
19
  Template directory: `{source}/packages/create-forge/template/`.
20
20
 
21
+ ### Downgrade guard
22
+
23
+ Read the source version (`{source}/packages/create-forge/package.json` `version`) and the installed version (`.claude/settings.json` `forge.version`). **If source < installed, STOP** — do not sync. Report: *"Refusing to downgrade: installed v{installed} is newer than source v{source}. Point dev-source at a newer checkout, or confirm an intentional downgrade."* Only proceed on explicit user override. (Rolling backward overwrites newer framework files and deletes files newer versions added — and with `.claude/` often gitignored, it is unrecoverable.)
24
+
21
25
  ## Step 2: File Classification
22
26
 
23
27
  | Category | Paths | Behavior |
@@ -28,6 +32,8 @@ Template directory: `{source}/packages/create-forge/template/`.
28
32
 
29
33
  **Never touch** user-generated files: `.forge/project.yml`, `.forge/state/`, `.forge/constitution.md`, `.forge/context.md`, `.forge/requirements/`, `.forge/roadmap.yml`, `.forge/design-system.md`, `.forge/refactor-backlog.yml`.
30
34
 
35
+ **Preserve experimental skills.** Opt-in skills installed separately (e.g. `.claude/skills/orchestrating/` from `experimental/m10/`) are not in the base template. Never flag them as "removed from template" or delete them — leave them untouched.
36
+
31
37
  ## Step 3: Sync Framework-Owned Files
32
38
 
33
39
  For each framework-owned file in the source template:
@@ -152,7 +158,7 @@ Add the layers field now? (yes/no/show guide)
152
158
  Run from project root:
153
159
 
154
160
  ```bash
155
- grep -qE 'desire_paths:|metrics:|current_status:' .forge/state/index.yml && echo "legacy index"
161
+ grep -qE '^[[:space:]]*(desire_paths|metrics|current_status):' .forge/state/index.yml && echo "legacy index"
156
162
  wc -c .forge/state/index.yml # slim registry is a few hundred bytes; KBs = narrative drifted in
157
163
  ```
158
164
 
@@ -20,9 +20,10 @@ Two problems this release fixes:
20
20
  ## Detection
21
21
 
22
22
  ```bash
23
- # Bloated/legacy index.yml — large, or still carrying desire_paths/metrics/narrative
23
+ # Bloated/legacy index.yml — large, or still carrying the metrics/desire-paths/narrative blocks
24
24
  wc -c .forge/state/index.yml # » a few hundred bytes once migrated; KBs means legacy
25
- grep -nE 'desire_paths:|metrics:|current_status:' .forge/state/index.yml # any hit migrate
25
+ # Anchored so the file's own explanatory comments don't false-positive:
26
+ grep -nE '^[[:space:]]*(desire_paths|metrics|current_status):' .forge/state/index.yml # any hit → migrate
26
27
  ```
27
28
 
28
29
  The installer also prints a notice after `upgrade` when it detects a legacy `index.yml`.
@@ -60,7 +61,13 @@ For each entry under the old `desire_paths:` lists, create one file under `.forg
60
61
  .forge/state/desire-paths/{YYYY-MM-DD}-{type}-{milestone}-{slug}.yml
61
62
  ```
62
63
 
63
- Map the old list name to `type` (`deviation_patterns→deviation_pattern`, `tier_overrides→tier_override`, etc.). An old `occurrences: N` becomes **N files** (or one file plus a note recurrence is now derived by counting files, not a stored number). Then delete the `desire_paths:` block from `index.yml`.
64
+ Map the old list name to `type` (`deviation_patterns→deviation_pattern`, `tier_overrides→tier_override`, etc.). Migrate **one file per entry** — do *not* fabricate one-file-per-occurrence. The old `occurrences: N` is only an aggregate; collapse it to a single file and keep the count in a field (e.g. `detail.historical_occurrences: N`) if you want it. Forward recurrence is counted from *new* files.
65
+
66
+ **Resolved vs open:**
67
+ - **Still-open / could-recur** → file under `.forge/state/desire-paths/` (counts toward the future 3+ detection).
68
+ - **Already resolved** (pattern fixed / framework already evolved) → file under `.forge/state/desire-paths/resolved/`. The active check globs `desire-paths/*.yml` (top level only), so `resolved/` is preserved for the audit trail but excluded from the count — matching how the `forge` skill archives resolved groups.
69
+
70
+ Then delete the `desire_paths:` block from `index.yml`.
64
71
 
65
72
  ### 4. Drop `metrics:`
66
73
 
@@ -78,14 +85,18 @@ git commit -m "chore(forge): migrate to worktree-safe state (0.19.0)"
78
85
  ## Post-migration verification
79
86
 
80
87
  ```bash
81
- # index.yml is small and registry-only
82
- grep -qE 'desire_paths:|metrics:|current_status:' .forge/state/index.yml && echo "STILL LEGACY" || echo "OK: registry-only"
88
+ # index.yml is registry-only — parse the YAML and check actual keys (a text grep
89
+ # false-positives on the file's explanatory comments). Uses ruby (no pip yaml needed):
90
+ ruby -ryaml -e 'd=YAML.load_file(".forge/state/index.yml"); bad=d.key?("metrics")||d.key?("desire_paths")||(d["milestones"]||[]).any?{|m| m.is_a?(Hash) && (m.key?("current_status")||m.key?("overall_percent"))}; puts bad ? "STILL LEGACY" : "OK: registry-only"'
91
+
92
+ # (grep alternative — anchored + comment-stripped so it doesn't match the header)
93
+ grep -vE '^[[:space:]]*#' .forge/state/index.yml | grep -qE '^[[:space:]]*(desire_paths|metrics|current_status):' && echo "STILL LEGACY" || echo "OK: registry-only"
83
94
 
84
95
  # rollup is idempotent — running /forge twice produces no diff to index.yml
85
96
  git diff --quiet .forge/state/index.yml && echo "OK: stable" || echo "rollup changed index — commit it"
86
97
 
87
- # desire-paths now live as files
88
- ls .forge/state/desire-paths/ 2>/dev/null
98
+ # desire-paths now live as files (active at top level, resolved/ archived separately)
99
+ ls .forge/state/desire-paths/ .forge/state/desire-paths/resolved/ 2>/dev/null
89
100
  ```
90
101
 
91
102
  ## What changes downstream
@@ -17,8 +17,9 @@ milestones: # Registry rolled up from state/milestone
17
17
  status: not_started # mirrors current.status: not_started | active | deferred | complete
18
18
  last_updated: null # mirrors current.last_updated — used for resume default selection
19
19
 
20
- # NOTE — removed from this file by design (M11):
21
- # metrics: had zero writers; derive from `git log` if ever needed.
22
- # desire_paths: now append-only files under state/desire-paths/ (one per
23
- # observation) so concurrent agents never collide. Occurrence
24
- # counts are derived by globbing that directory, not mutated here.
20
+ # NOTE — removed from this file by design (M11). (Tokens below are spaced to
21
+ # avoid tripping naive legacy-detection greps that scan for "<key>:".)
22
+ # metrics — had zero writers; derive from `git log` if ever needed.
23
+ # desire-paths now append-only files under state/desire-paths/ (one per
24
+ # observation) so concurrent agents never collide. Occurrence
25
+ # counts are derived by globbing that directory, not mutated here.