forge-orkes 0.30.0 → 0.31.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.
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
+ const { execSync } = require('child_process');
6
7
 
7
8
  const templateDir = path.join(__dirname, '..', 'template');
8
9
  const targetDir = process.cwd();
@@ -349,144 +350,117 @@ async function install() {
349
350
  }
350
351
 
351
352
  /**
352
- * Detect pre-0.10.0 requirements layouts (single top-level file, suffixed top-level,
353
- * or per-phase files). Returns array of legacy paths found, relative to project root.
353
+ * Extract the first fenced ```bash block under a guide's `## Detection` heading.
354
+ * Returns the script string, or null if the guide has no such block (e.g. a
355
+ * non-conformant guide). State-machine parse — no markdown dependency.
354
356
  */
355
- function detectLegacyRequirementsLayout() {
356
- const forgeDir = path.join(targetDir, '.forge');
357
- if (!fs.existsSync(forgeDir)) return [];
358
-
359
- const legacy = [];
360
-
361
- for (const entry of fs.readdirSync(forgeDir, { withFileTypes: true })) {
362
- if (!entry.isFile()) continue;
363
- if (entry.name === 'requirements.yml') {
364
- legacy.push(path.join('.forge', entry.name));
365
- } else if (/^requirements-m\d+\.yml$/.test(entry.name)) {
366
- legacy.push(path.join('.forge', entry.name));
357
+ function extractDetectionBlock(guideContent) {
358
+ const lines = guideContent.split('\n');
359
+ let inDetection = false;
360
+ let inCode = false;
361
+ const code = [];
362
+ for (const line of lines) {
363
+ if (!inDetection) {
364
+ if (/^##\s+Detection\b/.test(line)) inDetection = true;
365
+ continue;
367
366
  }
368
- }
369
-
370
- const phasesDir = path.join(forgeDir, 'phases');
371
- if (fs.existsSync(phasesDir)) {
372
- for (const phase of fs.readdirSync(phasesDir, { withFileTypes: true })) {
373
- if (!phase.isDirectory()) continue;
374
- const reqPath = path.join(phasesDir, phase.name, 'requirements.yml');
375
- if (fs.existsSync(reqPath)) {
376
- legacy.push(path.join('.forge', 'phases', phase.name, 'requirements.yml'));
377
- }
367
+ // Stop scanning if we hit the next top-level (## ...) heading before a block.
368
+ if (!inCode && /^##\s/.test(line) && !/^##\s+Detection\b/.test(line)) break;
369
+ if (!inCode) {
370
+ if (/^```bash\s*$/.test(line)) inCode = true;
371
+ continue;
378
372
  }
373
+ if (/^```/.test(line)) break; // closing fence
374
+ code.push(line);
379
375
  }
380
-
381
- return legacy;
376
+ return code.length ? code.join('\n') : null;
382
377
  }
383
378
 
384
379
  /**
385
- * Detect a pre-0.17.0 project.yml that has no `layers:` field. Cross-layer
386
- * contract detection (planning Step 6.1) reads `layers:`; without it the planner
387
- * falls back to a coarse top-level-directory heuristic. Returns true if the field
388
- * is absent and no contract index has been seeded. False if project.yml is missing
389
- * (not yet initialized) or already migrated.
380
+ * Data-driven post-upgrade migration check. Loops over the migration guides
381
+ * synced into the project this run (.forge/migrations/{v}-*.md), runs each
382
+ * guide's `## Detection` block, and on a `MIGRATE` stdout hit prints a warning
383
+ * with the exact guide pointer. There are NO per-version code branches: a new
384
+ * guide that ships with a conformant Detection block is auto-covered.
385
+ *
386
+ * Range: installed < v <= source. `installedVersion` is the project's
387
+ * settings.json forge.version captured BEFORE the version stamp; `source` is
388
+ * pkgVersion (this package's package.json) — never the template settings.json
389
+ * literal, which is a placeholder the installer stamps at write time. Non-
390
+ * interactive: the bin only warns and points at the guide; it never applies.
390
391
  */
391
- function detectMissingLayersConfig() {
392
- const projectYml = path.join(targetDir, '.forge', 'project.yml');
393
- if (!fs.existsSync(projectYml)) return false;
394
-
395
- let content;
396
- try {
397
- content = fs.readFileSync(projectYml, 'utf-8');
398
- } catch {
399
- return false;
400
- }
401
-
402
- // Already declared a layers: key (any value, including []) → migrated.
403
- if (/^layers:/m.test(content)) return false;
404
- // A seeded contract index implies layers were declared at init → migrated.
405
- if (fs.existsSync(path.join(targetDir, '.forge', 'contracts', 'index.yml'))) return false;
392
+ function runPostUpgradeMigrationChecks(installedVersion) {
393
+ const migrationsDir = path.join(targetDir, '.forge', 'migrations');
394
+ if (!fs.existsSync(migrationsDir)) return;
406
395
 
407
- return true;
408
- }
396
+ const installed =
397
+ installedVersion && installedVersion !== 'unknown' ? installedVersion : '0.0.0';
398
+ const source = pkgVersion;
409
399
 
410
- /**
411
- * Detect a pre-0.19.0 state/index.yml: 0.19.0 makes index.yml a thin derived
412
- * registry (regenerated by rollup) and moves desire_paths to append-only files.
413
- * A legacy index.yml still carries desire_paths:/metrics:/embedded narrative, or
414
- * is large. Returns true if index.yml exists and looks legacy; false if missing
415
- * (not yet initialized) or already a slim registry.
416
- */
417
- function detectLegacyStateIndex() {
418
- const indexYml = path.join(targetDir, '.forge', 'state', 'index.yml');
419
- if (!fs.existsSync(indexYml)) return false;
420
-
421
- let content;
400
+ // Select in-range guides: installed < v <= source. Parse {v} from the filename.
401
+ let guides;
422
402
  try {
423
- content = fs.readFileSync(indexYml, 'utf-8');
403
+ guides = fs
404
+ .readdirSync(migrationsDir)
405
+ .map((name) => {
406
+ const m = name.match(/^(\d+\.\d+\.\d+)-.*\.md$/);
407
+ return m ? { name, version: m[1] } : null;
408
+ })
409
+ .filter(Boolean)
410
+ .filter(
411
+ (g) =>
412
+ compareVersions(installed, g.version) < 0 &&
413
+ compareVersions(g.version, source) <= 0
414
+ )
415
+ .sort((a, b) => compareVersions(a.version, b.version));
424
416
  } catch {
425
- return false;
417
+ return;
426
418
  }
427
419
 
428
- // Legacy markers: shared accumulators or per-milestone narrative drifted in.
429
- if (/^\s*(desire_paths|metrics|current_status):/m.test(content)) return true;
430
- // A slim registry is small; KBs of index.yml means narrative crept in.
431
- if (Buffer.byteLength(content, 'utf-8') > 4096) return true;
420
+ for (const guide of guides) {
421
+ const guidePath = path.join(migrationsDir, guide.name);
422
+ let script;
423
+ try {
424
+ script = extractDetectionBlock(fs.readFileSync(guidePath, 'utf-8'));
425
+ } catch {
426
+ continue;
427
+ }
428
+ if (!script) continue; // no runnable Detection block → treat as no-op
429
+
430
+ let stdout = '';
431
+ try {
432
+ stdout = execSync(script, {
433
+ cwd: targetDir,
434
+ shell: '/bin/bash',
435
+ encoding: 'utf-8',
436
+ stdio: ['ignore', 'pipe', 'ignore'],
437
+ });
438
+ } catch {
439
+ // A non-zero exit or detection error is a no-op, not a failure — keep going.
440
+ continue;
441
+ }
442
+
443
+ if (!/MIGRATE/.test(stdout)) continue; // no-op
432
444
 
433
- return false;
434
- }
445
+ const reasonLine = stdout
446
+ .split('\n')
447
+ .find((l) => l.includes('MIGRATE'));
448
+ const reason = reasonLine
449
+ ? reasonLine.replace(/^.*MIGRATE\s*[—-]?\s*/, '').trim()
450
+ : '';
435
451
 
436
- /**
437
- * After upgrade, surface any detected legacy file layouts the new framework
438
- * version no longer writes, or new config fields older projects lack.
439
- * Non-interactive — prints a warning with a pointer to the migration guide.
440
- * Add new detection blocks here as future versions change layout.
441
- */
442
- function runPostUpgradeMigrationChecks() {
443
- const legacyReqs = detectLegacyRequirementsLayout();
444
- if (legacyReqs.length > 0) {
445
- console.log(' ⚠ Pre-0.10.0 requirements layout detected');
446
- console.log(' ─────────────────────────────────────────');
447
- console.log(' Found:');
448
- for (const p of legacyReqs) {
449
- console.log(` ${p}`);
450
- }
451
- console.log();
452
- console.log(' Forge 0.10.0+ uses per-milestone files at .forge/requirements/m{N}.yml.');
453
- console.log(' Migration guide: .forge/migrations/0.10.0-per-milestone-requirements.md');
452
+ console.log(` ⚠ ${guide.version} migration available${reason ? ` — ${reason}` : ''}`);
453
+ console.log(' ─────────────────────────────────────────────────────────────');
454
+ console.log(` Guide: .forge/migrations/${guide.name}`);
455
+ console.log(
456
+ ` (canonical: https://github.com/Attuned-Media/forge/blob/main/docs/migrations/${guide.name})`
457
+ );
454
458
  console.log();
455
- console.log(' Run the migration before your next planning cycle. In Claude Code:');
459
+ console.log(' Migrations are never applied automatically. In Claude Code:');
456
460
  console.log(' /forge then hand the guide to quick-tasking, or invoke Skill(quick-tasking)');
457
461
  console.log(' with the guide path as the task definition.');
458
462
  console.log();
459
463
  }
460
-
461
- if (detectMissingLayersConfig()) {
462
- console.log(' ⚠ Cross-layer contracts: project.yml has no `layers:` field (Forge 0.17.0)');
463
- console.log(' ──────────────────────────────────────────────────────────────────────');
464
- console.log(' Forge 0.17.0 added cross-layer contract detection (planning Step 6.1),');
465
- console.log(' which reads `layers:` from .forge/project.yml. Your project.yml predates');
466
- console.log(' this field, so planning falls back to a coarse top-level-directory heuristic.');
467
- console.log();
468
- console.log(' Migration guide: .forge/migrations/0.17.0-cross-layer-contracts.md');
469
- console.log();
470
- console.log(' Quick fix: add a `layers:` list to .forge/project.yml — or `layers: []`');
471
- console.log(' for a single-layer project to silence this notice. In Claude Code:');
472
- console.log(' /forge then hand the guide to quick-tasking.');
473
- console.log();
474
- }
475
-
476
- if (detectLegacyStateIndex()) {
477
- console.log(' ⚠ Worktree-safe state: legacy state/index.yml detected (Forge 0.19.0)');
478
- console.log(' ────────────────────────────────────────────────────────────────────');
479
- console.log(' Forge 0.19.0 makes index.yml a derived registry (regenerated by rollup),');
480
- console.log(' moves desire_paths to append-only files, and commits .forge/ state at every');
481
- console.log(' phase handoff. Your index.yml predates this — it still carries desire_paths/');
482
- console.log(' metrics or embedded narrative, which conflicts across git worktrees.');
483
- console.log();
484
- console.log(' Migration guide: .forge/migrations/0.19.0-worktree-safe-state.md');
485
- console.log();
486
- console.log(' This migration edits user-owned state, so it is guided — never automatic.');
487
- console.log(' In Claude Code: /forge then hand the guide to quick-tasking.');
488
- console.log();
489
- }
490
464
  }
491
465
 
492
466
  async function upgrade() {
@@ -656,7 +630,9 @@ async function upgrade() {
656
630
 
657
631
  console.log(` Upgraded to v${pkgVersion}\n`);
658
632
 
659
- runPostUpgradeMigrationChecks();
633
+ // Pass the version captured BEFORE upgradeSettings() stamped the new one, so the
634
+ // migration range is (installed, source] against the freshly-synced guides.
635
+ runPostUpgradeMigrationChecks(installedVersion);
660
636
  }
661
637
 
662
638
  // --- Entry point ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
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"
@@ -112,215 +112,38 @@ Unchanged: {N} files
112
112
 
113
113
  The `Migrated:` line appears only when a legacy marker/`# Forge` section was extracted this run; `Restored:` only when a missing import line was re-appended. A steady-state upgrade lists `CLAUDE.md` under Unchanged.
114
114
 
115
- ## Step 7: Post-Upgrade Migration Checks
115
+ ## Step 7: Post-Upgrade Migration Checks (data-driven)
116
116
 
117
- After sync completes, detect legacy file layouts that the new framework version no longer writes but may still read in compatibility mode. For each match, surface a migration promptdo not auto-migrate.
117
+ After sync completes, detect legacy file layouts the new framework version no longer writes but may still read in compatibility mode. This is **data-driven** — it loops over the migration guides synced in Step 4 and runs each guide's `## Detection` block. There are **no per-version blocks here**: a new guide that ships in the template (with a conformant Detection block see `.forge/templates/migration-guide.md`) is auto-covered on the next upgrade with no edit to this skill.
118
118
 
119
- ### Pre-0.10.0 requirements layout
119
+ For each guide that signals it applies, surface a migration prompt — **never auto-migrate**.
120
120
 
121
- Run from project root:
121
+ ### The loop
122
122
 
123
- ```bash
124
- find .forge -maxdepth 2 -type f \( -name "requirements.yml" -o -name "requirements-m*.yml" \) \
125
- | grep -v "^.forge/templates/" \
126
- | grep -v "^.forge/requirements/"
123
+ 1. **`installed`** = the project's `.claude/settings.json` `forge.version`, **captured at the START of this upgrade run, BEFORE Step 5 stamps the new version**. If absent/unknown, treat `installed` as `0.0.0` (all guides in range). Do **not** read the *source template's* `settings.json` literal — it carries a placeholder the installer stamps at write time, not the real version.
124
+ 2. **`source`** = `{source}/packages/create-forge/package.json` `version`. This is the authoritative new version. (Again: never the template `settings.json` literal.)
125
+ 3. Glob the just-synced guides: `.forge/migrations/{v}-*.md`. Parse `{v}` (the leading `MAJOR.MINOR.PATCH`) from each filename. Select guides where **`installed < v <= source`** (semver compare on dotted numerics).
126
+ 4. For each selected guide, ascending by version: run its `## Detection` fenced bash block from the project root (via the Bash tool, under `bash`).
127
+ - stdout contains **`MIGRATE`** → the migration applies; surface the prompt (below). The text after `MIGRATE —` is the reason; show it.
128
+ - prints nothing / no `MIGRATE` → **no-op**; report `{v}: no action` and move on.
129
+ - the block errors → report `{v}: detection error (skipped)`; do **not** abort the loop.
127
130
 
128
- find .forge/phases -type f -name "requirements.yml" 2>/dev/null
129
- ```
130
-
131
- If either command returns matches, surface:
132
-
133
- ```
134
- Pre-0.10.0 requirements layout detected
135
- ───────────────────────────────────────
136
- Found: {list of legacy paths}
137
-
138
- Forge 0.10.0 uses per-milestone files at .forge/requirements/m{N}.yml.
139
- A migration guide is available at: .forge/migrations/0.10.0-per-milestone-requirements.md
140
- (installed alongside Forge; canonical copy at
141
- https://github.com/Attuned-Media/forge/blob/main/docs/migrations/0.10.0-per-milestone-requirements.md)
142
-
143
- Run the migration now? (yes/no/show guide)
144
- ```
145
-
146
- - **yes** → invoke `quick-tasking` skill, hand it the migration guide as the task definition
147
- - **show guide** → read and display the file, then re-ask
148
- - **no** → note in upgrade report. Skills will continue reading the legacy path as deprecated; new writes still go to the new path, causing split-brain state. Recommend running migration before next planning cycle.
149
-
150
- ### Pre-0.17.0 missing `layers:` field
151
-
152
- Run from project root:
153
-
154
- ```bash
155
- grep -q '^layers:' .forge/project.yml || echo "no layers field"
156
- ls .forge/contracts/index.yml 2>/dev/null
157
- ```
158
-
159
- If `project.yml` has no `layers:` key AND `.forge/contracts/index.yml` is absent, surface:
160
-
161
- ```
162
- Cross-layer contracts: project.yml predates the `layers:` field (Forge 0.17.0)
163
- ─────────────────────────────────────────────────────────────────────────────
164
- Forge 0.17.0 added cross-layer contract detection (planning Step 6.1), which
165
- reads `layers:` from .forge/project.yml. Without it, planning falls back to a
166
- coarse top-level-directory heuristic.
167
-
168
- A migration guide is available at: .forge/migrations/0.17.0-cross-layer-contracts.md
169
-
170
- Add the layers field now? (yes/no/show guide)
171
- ```
172
-
173
- - **yes** → invoke `quick-tasking` skill, hand it the migration guide as the task definition (identify layers, write `layers:` to project.yml, seed `.forge/contracts/index.yml` for multi-layer projects)
174
- - **show guide** → read and display the file, then re-ask
175
- - **no** → note in upgrade report. Planning still works on the fallback heuristic; cross-layer detection is just imprecise until `layers:` is declared. A single-layer project can set `layers: []` to opt out and clear the notice.
176
-
177
- `upgrading` never edits `.forge/project.yml` directly — this is the only path that adds the field, via `quick-tasking`.
178
-
179
- ### Pre-0.19.0 legacy `state/index.yml`
180
-
181
- Run from project root:
182
-
183
- ```bash
184
- grep -qE '^[[:space:]]*(desire_paths|metrics|current_status):' .forge/state/index.yml && echo "legacy index"
185
- wc -c .forge/state/index.yml # slim registry is a few hundred bytes; KBs = narrative drifted in
186
- ```
187
-
188
- If `state/index.yml` carries `desire_paths:`/`metrics:`/embedded narrative or is large, surface:
189
-
190
- ```
191
- Worktree-safe state: state/index.yml predates the derived registry (Forge 0.19.0)
192
- ─────────────────────────────────────────────────────────────────────────────────
193
- Forge 0.19.0 makes index.yml a derived registry (regenerated by rollup), moves
194
- desire_paths to append-only files, and commits .forge/ at every phase handoff.
195
- A legacy index.yml conflicts across git worktrees.
196
-
197
- A migration guide is available at: .forge/migrations/0.19.0-worktree-safe-state.md
198
-
199
- Migrate now? (yes/no/show guide)
200
- ```
131
+ ### The prompt (uniform across guides)
201
132
 
202
- - **yes** invoke `quick-tasking` skill, hand it the migration guide as the task definition (secure state, slim index.yml, split desire_paths into files, drop metrics, regenerate via rollup). This edits user-owned state — confirm each lossy step (the narrative extraction) with the user.
203
- - **show guide** → read and display the file, then re-ask
204
- - **no** → note in upgrade report. Skills still run against a legacy index.yml; it just won't gain worktree conflict-safety until migrated.
205
-
206
- `upgrading` never edits `.forge/state/` directly — migration runs via `quick-tasking`.
207
-
208
- ### Pre-0.20.0 flat phase dirs
209
-
210
- Run from project root:
211
-
212
- ```bash
213
- ls .forge/phases/ | grep -E '^m[0-9]+-[0-9]+-' && echo "flat layout"
214
- ```
215
-
216
- If any dir matching `m{id}-{phase}-{name}` sits directly under `.forge/phases/`, surface:
133
+ For each applying guide:
217
134
 
218
135
  ```
219
- Nested phase layout: phase dirs are flat under .forge/phases/ (Forge 0.20.0)
220
- ──────────────────────────────────────────────────────────────────────────
221
- Forge 0.20.0 nests phase dirs one level per milestone:
222
- m{id}-{phase}-{name}/ → milestone-{id}/{phase}-{name}/
223
- This keeps `ls .forge/phases/` scoped per-milestone instead of dumping every
224
- phase across all milestones into context. Phase numbers are preserved verbatim
225
- (nothing is renumbered); the state cursor stays valid.
226
-
227
- {N} flat phase dir(s) detected.
228
- A migration guide is available at: .forge/migrations/0.20.0-nested-phase-layout.md
229
- (canonical: https://github.com/Attuned-Media/forge/blob/main/docs/migrations/0.20.0-nested-phase-layout.md)
136
+ {v} migration available {reason from the MIGRATE line, or the guide title}
137
+ Guide: .forge/migrations/{v}-{slug}.md
138
+ (canonical: https://github.com/Attuned-Media/forge/blob/main/docs/migrations/{v}-{slug}.md)
230
139
 
231
140
  Migrate now? (yes/no/show guide)
232
141
  ```
233
142
 
234
- - **yes** → invoke `quick-tasking` skill, hand it the migration guide as the task definition (`git mv` each flat dir to `milestone-{id}/{phase}-{name}/`, preserving phase numbers; rewrite any `milestone_dir:` in roadmap.yml; verify plan-file count unchanged).
235
- - **show guide** → read and display the file, then re-ask
236
- - **no** → note in upgrade report. Skills compose the new nested path on write while flat dirs still exist on disk, so new and old plans split across two layouts (split-brain) until migrated. Recommend running migration before the next planning cycle.
237
-
238
- `upgrading` never moves phase dirs directly — migration runs via `quick-tasking`.
239
-
240
- ### Pre-0.22.0 unbounded refactor-backlog
241
-
242
- Run from project root (backlog present only):
243
-
244
- ```bash
245
- F=.forge/refactor-backlog.yml
246
- [ -f "$F" ] || exit 0
247
- kb=$(( $(wc -c < "$F") / 1024 ))
248
- terminal=$(grep -cE 'status:[[:space:]]*(done|dismissed|completed|complete|closed|wont_fix|stale)' "$F")
249
- noncanon=$(grep -oE 'status:[[:space:]]*[A-Za-z_]+' "$F" \
250
- | grep -vE 'status:[[:space:]]*(pending|in_progress|done|dismissed|deferred)$' | wc -l)
251
- # bloat if: kb > 150 OR terminal > 0 OR noncanon > 0
252
- ```
253
-
254
- Surface the prompt when `kb > 150` OR `terminal > 0` OR `noncanon > 0`, printing the three detected numbers:
255
-
256
- ```
257
- Refactor-backlog compaction available (Forge 0.22.0)
258
- ────────────────────────────────────────────────────
259
- Pre-0.22.0 the refactor backlog was append-only — resolved items were never
260
- pruned and statuses drifted (completed/complete/closed/wont_fix/stale + junk).
261
- 0.22.0 archives terminal items to .forge/refactor-backlog-archive.yml and
262
- enforces the canonical vocab (pending|in_progress|done|dismissed|deferred).
263
-
264
- Detected: {kb} KB · {terminal} terminal item(s) · {noncanon} non-canonical status(es).
265
- A migration guide is available at: .forge/migrations/0.22.0-backlog-compaction.md
266
- (canonical: https://github.com/Attuned-Media/forge/blob/main/docs/migrations/0.22.0-backlog-compaction.md)
267
-
268
- Migrate now? (yes/no/show guide)
269
- ```
270
-
271
- - **yes** → invoke `quick-tasking` skill, hand it the migration guide as the task definition (snapshot item count, normalize statuses, split actionable/terminal/triage, archive terminal items to `.forge/refactor-backlog-archive.yml`, rewrite the working file, verify item-count conservation before committing).
272
- - **show guide** → read and display the file, then re-ask
273
- - **no** → note in upgrade report. The backlog keeps growing and statuses stay drifted until migrated; reviewing/quick-tasking will compact on their next write regardless, but a bloated file lingers until then. Recommend running before the next review cycle.
274
-
275
- No-op guarantee: detection exits clean (no prompt) when the backlog is absent, under the gate, has zero terminal items, and all statuses are canonical. (Pending-item count is **not** a trigger — a busy-but-clean backlog is left alone.)
276
-
277
- `upgrading` never edits the backlog directly — migration runs via `quick-tasking`.
278
-
279
- ### Pre-0.28.0 fully-ignored `.claude/`
280
-
281
- Run from project root:
282
-
283
- ```bash
284
- F=.gitignore
285
- [ -f "$F" ] || exit 0
286
- # bare ".claude/" or ".claude" rule (no negation lines un-ignoring product files)
287
- if grep -qE '^[[:space:]]*\.claude/?[[:space:]]*$' "$F" \
288
- && ! grep -qE '^![[:space:]]*\.claude/(skills|agents|hooks|settings\.json)' "$F"; then
289
- echo "broad-ignore"
290
- fi
291
- ```
292
-
293
- If the script prints `broad-ignore`, surface:
294
-
295
- ```
296
- .claude/ fully ignored — Forge worktrees + hooks silently break (Forge 0.28.0)
297
- ─────────────────────────────────────────────────────────────────────────────
298
- Your .gitignore ignores all of `.claude/` without a carve-out. That means:
299
- • git worktrees (Forge or the app's) have no `.claude/skills/`, so `/forge`
300
- is missing — typing `/forge` in a worktree prints "Unknown command".
301
- • M10 worktrees come up with no hooks (forge-claim-check, branch-guard,
302
- session-id) — coordination + ADR-008 protection are silently disabled.
303
-
304
- These are PRODUCT files, not per-machine config. The fix is a narrow
305
- carve-out: track skills/agents/hooks/settings.json; keep settings.local.json,
306
- projects/, worktrees/ ignored.
307
-
308
- A migration guide is available at: .forge/migrations/0.28.0-worktree-root.md
309
- (canonical: https://github.com/Attuned-Media/forge/blob/main/docs/migrations/0.28.0-worktree-root.md)
310
-
311
- Apply the carve-out now? (yes/no/show guide)
312
- ```
313
-
314
- - **yes** → invoke `quick-tasking` skill with the migration guide as the task (edit `.gitignore` in place, `git rm -r --cached .claude/` to clear the ignore-cache, stage `.gitignore` + the four product trees, commit `chore(forge): track .claude/ product files`). The skill MUST confirm with the user before staging anything outside the four product trees.
315
- - **show guide** → read and display the file, then re-ask
316
- - **no** → note in upgrade report. The framework keeps working in the main checkout but worktree-mode sessions (M10 / Claude Desktop folder-open) will be missing `/forge` and Forge hooks until migrated.
317
-
318
- This is a Forge-product-tracking concern, not a state migration — `upgrading` never edits user `.gitignore` directly. Migration runs via `quick-tasking`.
143
+ - **yes** → invoke the `quick-tasking` skill, handing it the guide path as the task definition. `upgrading` never edits user state / project files / `.gitignore` directly — every migration runs via `quick-tasking`, and any lossy step (e.g. narrative extraction, staging files) is confirmed with the user first.
144
+ - **show guide** → read and display the guide file, then re-ask.
145
+ - **no** → note in the upgrade report. The new skills compose new-shape paths on write while legacy files persist on disk (split-brain) until migrated; recommend running it before the next planning/review cycle.
319
146
 
320
- ### Future migrations
147
+ ### Why there is no bookkeeping
321
148
 
322
- Add new detection blocks here for each Forge version that changes file layout. Pattern:
323
- 1. Detection command(s)
324
- 2. Single-line description of the drift
325
- 3. Pointer to `.forge/migrations/{version}-{slug}.md` (installed copy; canonical at `docs/migrations/...` in the Forge source repo)
326
- 4. Three-way prompt (yes/no/show guide)
149
+ Recording "crossed" guides is implicit in the range. Once Step 5 stamps `forge.version` to `source`, a later run's range `(new installed, source]` excludes every guide already crossed — so nothing re-detects, with **no ledger or marker file**. The range also closes the install-run blind spot: detection keys off the guides **freshly synced this run** (Step 4) plus the **pre-stamp installed version**, so the run that installs new detection behavior uses the new guides, not this skill's own pre-sync prose.
@@ -15,16 +15,20 @@ The pre-0.10.0 spec had a single top-level `.forge/requirements.yml` "refreshed
15
15
 
16
16
  ## Detection
17
17
 
18
- Run from project root. Any match below means migration is needed:
18
+ Run from project root. Prints `MIGRATE` on stdout if a legacy requirements layout is present; silent + exit 0 when already on the per-milestone layout (the data-driven `upgrading` loop keys off the `MIGRATE` sentinel).
19
19
 
20
20
  ```bash
21
- # Off-spec layout signals
22
- ls .forge/requirements.yml 2>/dev/null # legacy single file
23
- ls .forge/requirements-m*.yml 2>/dev/null # suffixed top-level
24
- find .forge/phases -name "requirements.yml" 2>/dev/null # per-phase files
21
+ legacy=$(
22
+ ls .forge/requirements.yml 2>/dev/null # legacy single file
23
+ ls .forge/requirements-m*.yml 2>/dev/null # suffixed top-level
24
+ find .forge/phases -name "requirements.yml" 2>/dev/null # per-phase files
25
+ )
26
+ if [ -n "$legacy" ]; then
27
+ echo "MIGRATE — legacy requirements layout: $(echo "$legacy" | tr '\n' ' ')"
28
+ fi
25
29
  ```
26
30
 
27
- If `.forge/requirements/` already exists as a directory and the legacy paths above are absent, migration is complete — stop here.
31
+ If `.forge/requirements/` already exists as a directory and the legacy paths above are absent, migration is complete — the block prints nothing and exits 0.
28
32
 
29
33
  ## Migration steps
30
34
 
@@ -17,17 +17,17 @@ Adding `layers:` once makes the detection deterministic. A single-layer project
17
17
 
18
18
  ## Detection
19
19
 
20
- Run from project root. Migration is suggested if `layers:` is absent:
20
+ Run from project root. Prints `MIGRATE` if `project.yml` predates the `layers:` field and no contract index has been seeded; silent + exit 0 once `layers:` is declared (the data-driven `upgrading` loop keys off the `MIGRATE` sentinel).
21
21
 
22
22
  ```bash
23
- # No `layers:` key in project.yml migration applies
24
- grep -q '^layers:' .forge/project.yml || echo "no layers field — migrate"
25
-
26
- # Already has a contract index? Then layers were declared at init — likely done.
27
- ls .forge/contracts/index.yml 2>/dev/null
23
+ if [ -f .forge/project.yml ] \
24
+ && ! grep -q '^layers:' .forge/project.yml \
25
+ && [ ! -f .forge/contracts/index.yml ]; then
26
+ echo "MIGRATE project.yml has no layers: field"
27
+ fi
28
28
  ```
29
29
 
30
- If `project.yml` already contains a `layers:` key (any value, including `[]`), migration is complete — stop here.
30
+ If `project.yml` already contains a `layers:` key (any value, including `[]`), or a `.forge/contracts/index.yml` exists (layers were declared at init), migration is complete — the block prints nothing and exits 0.
31
31
 
32
32
  ## Migration steps
33
33
 
@@ -19,14 +19,19 @@ Two problems this release fixes:
19
19
 
20
20
  ## Detection
21
21
 
22
+ Run from project root. Prints `MIGRATE` if `state/index.yml` is a legacy file — still carrying the metrics/desire-paths/narrative blocks, or grown to KBs; silent + exit 0 once it is the slim derived registry (the data-driven `upgrading` loop keys off the `MIGRATE` sentinel).
23
+
22
24
  ```bash
23
- # Bloated/legacy index.yml — large, or still carrying the metrics/desire-paths/narrative blocks
24
- wc -c .forge/state/index.yml # » a few hundred bytes once migrated; KBs means legacy
25
+ F=.forge/state/index.yml
26
+ [ -f "$F" ] || exit 0
25
27
  # 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
28
+ if grep -qE '^[[:space:]]*(desire_paths|metrics|current_status):' "$F" \
29
+ || [ "$(wc -c < "$F")" -gt 4096 ]; then
30
+ echo "MIGRATE — legacy state/index.yml ($(wc -c < "$F") bytes)"
31
+ fi
27
32
  ```
28
33
 
29
- The installer also prints a notice after `upgrade` when it detects a legacy `index.yml`.
34
+ A slim derived registry is a few hundred bytes and carries none of those keys → the block prints nothing and exits 0. The installer (`npx forge-orkes upgrade`) runs the same check after sync.
30
35
 
31
36
  ## Migration steps
32
37
 
@@ -55,14 +55,18 @@ Now `ls .forge/phases/milestone-9/` shows only milestone 9's phases.
55
55
  ## Detection
56
56
 
57
57
  A flat (un-migrated) layout has directories matching `m{id}-{phase}-{name}`
58
- **directly** under `.forge/phases/`:
58
+ **directly** under `.forge/phases/`. Run from project root — prints `MIGRATE`
59
+ if any flat phase dir is present; silent + exit 0 when already nested (the
60
+ data-driven `upgrading` loop keys off the `MIGRATE` sentinel):
59
61
 
60
62
  ```bash
61
- ls .forge/phases/ | grep -E '^m[0-9]+-[0-9]+-' && echo "FLAT — migrate" || echo "nested — no-op"
63
+ if ls .forge/phases/ 2>/dev/null | grep -qE '^m[0-9]+-[0-9]+-'; then
64
+ echo "MIGRATE — flat phase layout under .forge/phases/"
65
+ fi
62
66
  ```
63
67
 
64
- Already-nested projects have only `milestone-{id}/` dirs at that level and the
65
- check prints `nested no-op`.
68
+ Already-nested projects have only `milestone-{id}/` dirs at that level the
69
+ block prints nothing and exits 0.
66
70
 
67
71
  ## Defensive parse
68
72
 
@@ -59,23 +59,27 @@ triage."* Silently dropping or guessing is forbidden.
59
59
  ## Detection
60
60
 
61
61
  Bloat/drift exists if the file is over the 150 KB size gate, OR any terminal
62
- item is present, OR any non-canonical status appears:
62
+ item is present, OR any non-canonical status appears. Run from project root —
63
+ prints `MIGRATE` when bloat is detected; silent + exit 0 when the backlog is
64
+ absent or clean (the data-driven `upgrading` loop keys off the `MIGRATE`
65
+ sentinel):
63
66
 
64
67
  ```bash
65
68
  F=.forge/refactor-backlog.yml
66
- [ -f "$F" ] || { echo "no backlog — no-op"; exit 0; }
69
+ [ -f "$F" ] || exit 0
67
70
  kb=$(( $(wc -c < "$F") / 1024 ))
68
71
  terminal=$(grep -cE 'status:[[:space:]]*(done|dismissed|completed|complete|closed|wont_fix|stale)' "$F")
69
72
  noncanon=$(grep -oE 'status:[[:space:]]*[A-Za-z_]+' "$F" \
70
73
  | grep -vE 'status:[[:space:]]*(pending|in_progress|done|dismissed|deferred)$' | wc -l)
71
- echo "size=${kb}KB terminal=$terminal noncanon=$noncanon"
72
- # bloat if: kb > 150 OR terminal > 0 OR noncanon > 0
74
+ if [ "$kb" -gt 150 ] || [ "$terminal" -gt 0 ] || [ "$noncanon" -gt 0 ]; then
75
+ echo "MIGRATE — backlog bloat: size=${kb}KB terminal=$terminal noncanon=$noncanon"
76
+ fi
73
77
  ```
74
78
 
75
- A clean backlog (under the gate, zero terminal, all-canonical) prints
76
- `size=… terminal=0 noncanon=0` → **no-op**. The detector keys off bloat that
77
- compaction actually fixes, **not** the count of pending items — a busy-but-clean
78
- backlog is left alone.
79
+ A clean backlog (under the gate, zero terminal, all-canonical) prints nothing
80
+ and exits 0 → **no-op**. The detector keys off bloat that compaction actually
81
+ fixes, **not** the count of pending items — a busy-but-clean backlog is left
82
+ alone.
79
83
 
80
84
  ## ⚠️ Environment gotcha (read before scripting)
81
85
 
@@ -27,14 +27,14 @@ git worktree add ${wt_root}/${anchor} main
27
27
 
28
28
  The resolved absolute path is recorded in `lifecycle.worktree_path` and read verbatim by teardown + crash-recovery — so the config knob is forward-looking, not retroactive.
29
29
 
30
- ### Detection
30
+ ### Inventory (informational — not a migration trigger)
31
31
 
32
32
  ```bash
33
33
  # Are any of your active milestones pointing at the old shared dir?
34
34
  grep -rE '^\s*worktree_path:.*forge-worktrees' .forge/state/ 2>/dev/null | head
35
35
  ```
36
36
 
37
- Matches = pre-0.28.0 worktrees still in flight. **Leave them alone** — they keep working off their recorded path. Only **new** worktrees pick up the new default.
37
+ Matches = pre-0.28.0 worktrees still in flight. **Leave them alone** — they keep working off their recorded path. Only **new** worktrees pick up the new default. This is informational: it does **not** trigger a migration (the actionable trigger for this guide is the `.gitignore` carve-out — see `## Detection` below).
38
38
 
39
39
  ### Migration
40
40
 
@@ -86,16 +86,16 @@ Forge installs into `.claude/` (skills, agents, hooks, `settings.json`). Many Cl
86
86
 
87
87
  The fix: track product files, keep per-machine/scratch files ignored.
88
88
 
89
- ### Detection
89
+ ## Detection
90
90
 
91
- `upgrading` runs this automatically; you can run it by hand too:
91
+ This is the guide's single migration trigger — the `.gitignore` carve-out. (Part 1's worktree-path inventory above is informational only.) `upgrading` runs this automatically; you can run it by hand too. Prints `MIGRATE` when `.claude/` is broadly ignored with no product-file carve-out; silent + exit 0 otherwise (the data-driven `upgrading` loop keys off the `MIGRATE` sentinel):
92
92
 
93
93
  ```bash
94
94
  F=.gitignore
95
95
  [ -f "$F" ] || exit 0
96
96
  if grep -qE '^[[:space:]]*\.claude/?[[:space:]]*$' "$F" \
97
97
  && ! grep -qE '^![[:space:]]*\.claude/(skills|agents|hooks|settings\.json)' "$F"; then
98
- echo "broad-ignoreapply the carve-out below"
98
+ echo "MIGRATE.claude/ broadly ignored with no product-file carve-out"
99
99
  fi
100
100
  ```
101
101
 
@@ -50,9 +50,14 @@ The taxonomy is descriptive: every existing decision in `context.md` already fit
50
50
 
51
51
  If a past edit broke the rule (decision rewritten in place), don't go back and reconstruct strike-throughs — the history is in git. Just follow the rule going forward.
52
52
 
53
- ## Detection (none needed)
53
+ ## Detection
54
54
 
55
- There's nothing to detect on disk. The framework changes are in skills and `FORGE.md`. Upgrade and you have them.
55
+ Intentional no-op. There's nothing to detect on disk the 0.29.0 changes are in skills and `FORGE.md`, so upgrading the framework files is the whole migration. The block below is the uniform-contract placeholder: it prints no `MIGRATE` sentinel and exits 0, so the data-driven `upgrading` loop correctly treats this in-range guide as "no action".
56
+
57
+ ```bash
58
+ # 0.29.0 is documentation + skill-behavior only — nothing on disk to migrate.
59
+ exit 0
60
+ ```
56
61
 
57
62
  ## Validation
58
63
 
@@ -0,0 +1,110 @@
1
+ # Migration Guide Template
2
+
3
+ Copy to `.forge/migrations/{version}-{slug}.md` (e.g. `0.31.0-foo.md`) when a Forge
4
+ version changes an on-disk layout, a config field, or a state shape that older
5
+ projects need to migrate. Also mirror the file to `docs/migrations/{version}-{slug}.md`
6
+ (canonical) — the two copies must be byte-identical.
7
+
8
+ A guide written to this contract is **auto-covered** by the post-upgrade migration
9
+ check in BOTH routes (`upgrading` SKILL Step 7 and `npx forge-orkes upgrade`) the
10
+ moment it ships in the template. **You do not edit `upgrading/SKILL.md` or
11
+ `create-forge.js`** — they loop over the guides, so a conformant new guide just works.
12
+
13
+ ---
14
+
15
+ ## The Detection-block contract (load-bearing — read before writing the guide)
16
+
17
+ The data-driven migration loop in both routes finds each guide, decides whether it is
18
+ in range, and runs its Detection block. For that to work, every guide MUST follow this
19
+ contract:
20
+
21
+ 1. **Exactly one `## Detection` heading per guide.** Not `### Detection`, not two of
22
+ them. If a guide has informational sub-checks (e.g. an inventory the user should see
23
+ but that does NOT trigger a migration), give them a different heading — only the one
24
+ `## Detection` block is run.
25
+ 2. **Under it, exactly one fenced ` ```bash ` block.** It is run from the project root,
26
+ must tolerate absent files (`[ -f … ] || exit 0`, `2>/dev/null`), and must not depend
27
+ on the user's shell config. The loop runs it under `bash` explicitly — do not rely on
28
+ zsh-only or interactive features. (The Bash tool runs zsh; `$BASH_REMATCH` / `=~`
29
+ capture groups silently fail — avoid them.)
30
+ 3. **Sentinel = `MIGRATE`.** The block echoes the literal token `MIGRATE` on stdout when
31
+ the migration applies. A trailing `— reason` after it is encouraged — it surfaces in
32
+ the prompt the user sees. When there is nothing to migrate (no-op guides included),
33
+ the block prints **no** `MIGRATE` token and exits 0.
34
+ 4. **No-op guides still get the heading.** A documentation/skill-behavior-only change
35
+ (nothing on disk to migrate) keeps a `## Detection` heading with a bash block that
36
+ simply `exit 0`s, so the loop's heading-find stays uniform across all guides.
37
+
38
+ ### Range gating (why you never touch the loop code)
39
+
40
+ The loop reads the project's **installed** version (`settings.json forge.version`, captured
41
+ BEFORE the upgrade stamps the new value) and the **source** version
42
+ (`packages/create-forge/package.json`). It runs only the guides where
43
+ `installed < {guide version} <= source`. So a guide ships, the next upgrade that crosses
44
+ its version runs it once, and after the version is stamped it falls out of range — no
45
+ ledger, no per-version code, no staleness. (It also closes the install-run blind spot:
46
+ detection keys off the freshly-synced guides on disk, not the skill's own pre-sync code.)
47
+
48
+ ### Detection block skeleton (applies)
49
+
50
+ ```bash
51
+ # Detect the legacy shape; tolerate absent files; emit MIGRATE iff it applies.
52
+ F=.forge/some-file.yml
53
+ [ -f "$F" ] || exit 0
54
+ if grep -q 'legacy-marker' "$F"; then
55
+ echo "MIGRATE — <one-line reason the user will see>"
56
+ fi
57
+ ```
58
+
59
+ ### Detection block skeleton (no-op guide)
60
+
61
+ ```bash
62
+ # Documentation / skill-behavior change only — nothing on disk to migrate.
63
+ exit 0
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Guide skeleton
69
+
70
+ ```markdown
71
+ # Migration Guide: <Title> (Forge <version>)
72
+
73
+ Applies to <which projects>. <One-paragraph what-changed-and-why.>
74
+
75
+ ## Prerequisites
76
+
77
+ 1. <e.g. on the new version's framework files (run `npx forge-orkes upgrade` first)>
78
+ 2. <e.g. working tree clean or changes committed — migration touches N files>
79
+
80
+ ## Detection
81
+
82
+ <One sentence: prints `MIGRATE` when it applies; silent + exit 0 otherwise.>
83
+
84
+ ​```bash
85
+ <the single detection block per the contract above>
86
+ ​```
87
+
88
+ ## Migration steps
89
+
90
+ ### 1. <step>
91
+ ### 2. <step>
92
+ ...
93
+ <The migration is NEVER auto-applied. In Claude Code it runs via the `quick-tasking`
94
+ skill, which the `upgrading` Step-7 prompt hands the guide to on "yes". Lossy steps
95
+ must be confirmed with the user.>
96
+
97
+ ## Validation
98
+
99
+ <How to confirm the migration succeeded — greps, counts, conservation checks.>
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Acceptance self-check before you ship the guide
105
+
106
+ - [ ] Exactly one `## Detection` heading; exactly one ` ```bash ` block under it.
107
+ - [ ] Block emits `MIGRATE` on stdout iff the migration applies; silent + exit 0 otherwise.
108
+ - [ ] Block is safe on a clean/empty project (no errors, exit 0).
109
+ - [ ] Mirrored byte-identical to `docs/migrations/{same-name}.md`.
110
+ - [ ] No edit to `upgrading/SKILL.md` or `create-forge.js` was needed (the loop covers it).