forge-orkes 0.67.0 → 0.68.1

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.
@@ -52,31 +52,22 @@ const FORGE_END = '<!-- forge:end -->';
52
52
 
53
53
  // --- File classification for upgrades ---
54
54
 
55
- // Framework-owned: Forge controls these entirely
55
+ // Framework-owned: Forge controls these entirely. Auto-clean removes files that
56
+ // dropped out of the template — but ownership is decided per top-level UNIT
57
+ // (a skill DIR under .claude/skills/, an agent FILE under .claude/agents/), and
58
+ // only the template defines which units Forge shipped. A unit the base template
59
+ // never shipped — user-authored, vendored-parity, or an opt-in experimental
60
+ // skill installed separately (e.g. m10's orchestrating, the convention packs) —
61
+ // is NOT framework content and is preserved, never swept, even though it lives
62
+ // under a framework-owned dir. Deleting it would destroy work Forge never owned,
63
+ // and .claude/ is frequently gitignored, so the loss is unrecoverable. Files
64
+ // removed from WITHIN a still-shipped unit (e.g. a renamed helper inside a
65
+ // template skill dir) stay cleanable — their unit is still present in the
66
+ // template. See upgradeDir(). Experimental skills are additionally diffed
67
+ // against their shipped sources by detectExperimentalStaleness() (report-only;
68
+ // re-sync consent lives in the /upgrading skill layer).
56
69
  const FRAMEWORK_OWNED_DIRS = ['.claude/agents', '.claude/skills'];
57
70
 
58
- // Experimental / opt-in skills installed separately (e.g. from experimental/m10).
59
- // They are NOT in the shipped base template, so the framework-owned auto-clean
60
- // would otherwise delete them on upgrade. Preserve them instead.
61
- //
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.
71
- const EXPERIMENTAL_SKILL_PATHS = ['.claude/skills/orchestrating'];
72
-
73
- function isExperimentalSkillPath(displayPath) {
74
- const norm = displayPath.split(path.sep).join('/');
75
- return EXPERIMENTAL_SKILL_PATHS.some(
76
- (p) => norm === p || norm.startsWith(p + '/')
77
- );
78
- }
79
-
80
71
  // Compare dotted numeric versions (e.g. "0.19.1"). Returns 1 if a>b, -1 if a<b, 0 if equal.
81
72
  function compareVersions(a, b) {
82
73
  const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
@@ -259,9 +250,15 @@ function upgradeDir(relDir, { autoClean = false } = {}) {
259
250
  const srcPath = path.join(srcDir, rel);
260
251
  if (!fs.existsSync(srcPath)) {
261
252
  const displayPath = path.join(relDir, rel);
262
- // Never auto-clean opt-in experimental skills they live outside the base template.
263
- if (autoClean && isExperimentalSkillPath(displayPath)) {
264
- result.preserved.push(displayPath);
253
+ // Ownership is per top-level unit: the first path segment of `rel` is the
254
+ // skill dir (or, for flat agents, the file itself). If the template no
255
+ // longer ships that unit at all, the file is user-authored, vendored, or an
256
+ // opt-in experimental skill — not Forge's to delete. Preserve it. Only
257
+ // files whose unit the template STILL ships (a stale file inside a shipped
258
+ // skill dir) remain removal candidates.
259
+ const topUnit = rel.split(path.sep)[0];
260
+ if (autoClean && !fs.existsSync(path.join(srcDir, topUnit))) {
261
+ result.preserved.push(`${displayPath} (kept — not shipped by the base template)`);
265
262
  continue;
266
263
  }
267
264
  const destPath = path.join(destDir, rel);
@@ -374,6 +371,30 @@ function mergeManagedDeny(destSettings, srcSettings) {
374
371
  }
375
372
  }
376
373
 
374
+ /**
375
+ * Union the template's managed allow rules into the project's permissions.allow.
376
+ * Additive — a user's own allow/deny entries are untouched (mirrors
377
+ * mergeManagedDeny). Forge automates a worktree-heavy workflow (streams, the
378
+ * slice runner, orchestration/human-verified teardown), but the harness auto-mode
379
+ * classifier soft-denies `git worktree remove` for a worktree the session didn't
380
+ * create in-session, and a Remote-Control / detached session cannot enter
381
+ * bypassPermissions after launch. A NARROW allow rule survives auto mode and runs
382
+ * before the classifier, so the automating session can clean up its own worktrees.
383
+ * These are read/teardown of Forge-managed worktrees only — `remove` is git-guarded
384
+ * (refuses on a dirty/untracked tree without --force) and, in the Forge flow, only
385
+ * ever runs on a merged, human-verified worktree. It never touches commits or
386
+ * branches — worktree removal drops a checkout, not history.
387
+ */
388
+ function mergeManagedAllow(destSettings, srcSettings) {
389
+ const srcAllow = (srcSettings.permissions && srcSettings.permissions.allow) || [];
390
+ if (!srcAllow.length) return;
391
+ destSettings.permissions = destSettings.permissions || {};
392
+ const destAllow = destSettings.permissions.allow || (destSettings.permissions.allow = []);
393
+ for (const rule of srcAllow) {
394
+ if (!destAllow.includes(rule)) destAllow.push(rule);
395
+ }
396
+ }
397
+
377
398
  /**
378
399
  * Strip the legacy *inline* active-skill guard from PreToolUse (forge#12). It was
379
400
  * an inline `if [ ! -f .../.forge/.active-skill ]; then ... exit 2; fi` one-liner;
@@ -437,6 +458,7 @@ function upgradeSettings() {
437
458
  // Additively install framework-managed safety guardrails (ADR-017)
438
459
  mergeManagedHooks(destSettings, srcSettings);
439
460
  mergeManagedDeny(destSettings, srcSettings);
461
+ mergeManagedAllow(destSettings, srcSettings);
440
462
 
441
463
  const after = JSON.stringify(destSettings);
442
464
  if (before === after) return 'unchanged';
@@ -949,7 +971,7 @@ async function upgrade() {
949
971
  if (results.preserved.length > 0) {
950
972
  say(` Preserved (${results.preserved.length}):`);
951
973
  for (const f of results.preserved) {
952
- say(` ${f} (kept — opt-in experimental skill)`);
974
+ say(` ${f}`);
953
975
  }
954
976
  for (const e of experimental) {
955
977
  say(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.67.0",
3
+ "version": "0.68.1",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "forge": {
3
- "version": "0.58.3",
3
+ "version": "0.68.0",
4
4
  "default_tier": "standard",
5
5
  "beads_integration": false,
6
6
  "context_gates": {
@@ -48,6 +48,11 @@
48
48
  "Edit(**/.secrets/**)",
49
49
  "Edit(**/*.pem)",
50
50
  "Edit(**/*.key)"
51
+ ],
52
+ "allow": [
53
+ "Bash(git worktree remove *)",
54
+ "Bash(git worktree prune)",
55
+ "Bash(git worktree prune *)"
51
56
  ]
52
57
  },
53
58
  "hooks": {
@@ -139,7 +139,7 @@ Source version (`{source}/packages/create-forge/package.json`) **<** installed (
139
139
 
140
140
  Dev mode syncs from a checkout, so it keeps file-classification semantics — **the npm bin's sync behavior is the spec**; do not re-derive it here. Categories:
141
141
 
142
- - **Framework-owned** (`.claude/agents/*.md`, `.claude/skills/*/SKILL.md`, `.forge/FORGE.md`) → overwrite when different; a local skill dir absent from the template defers to the experimental pass below (only no-source dirs report *removed from template* never deleted).
142
+ - **Framework-owned** (`.claude/agents/*.md`, `.claude/skills/*/SKILL.md`, `.forge/FORGE.md`) → overwrite when different. Ownership is per top-level **unit** — a skill DIR under `.claude/skills/`, a flat agent FILE under `.claude/agents/`: a unit the template doesn't ship (user-authored, vendored-parity, or an opt-in experimental skill) is **preserved, never deleted**; only stale files *inside* a still-shipped skill dir are cleaned. Experimental skills additionally get the staleness pass below.
143
143
  - **Template-only** (`.forge/templates/**`, `.forge/migrations/**`, `.forge/adapters/**`, `.forge/gitignore` → `.forge/.gitignore`) → overwrite; `.forge/FORGE.md` lands BEFORE the CLAUDE.md pass.
144
144
  - **Additive** (`.claude/hooks/**` incl. `tests/`, `.claude/rules/**`, `.forge/checks/**`) → add + overwrite template-shipped files, never delete local extras, `chmod +x` synced `*.sh`.
145
145
  - **Import-managed** `CLAUDE.md` → the bin's `ensureClaudeMdImport()` cases verbatim; user content outside a forge section is never modified.
@@ -91,17 +91,25 @@ materialize_work_order() {
91
91
  # Prefer the worktree's OWN milestone — .forge/phases/ carries the repo's whole milestone
92
92
  # history (32 dirs on the forge repo), so "the only milestone dir" is a fixture-world
93
93
  # assumption. The Worktree Convention names the branch forge/{anchor} with anchor =
94
- # {milestone-id}[-{session}]; derive the id and pick that milestone's dir.
94
+ # {milestone-id}[-{session}]; derive the id and pick that milestone's dir. The id's own
95
+ # m- may be a strippable prefix (m-40 → milestone-40) or PART of a labeled id kept
96
+ # verbatim (m-EVT01 → milestone-m-EVT01) — canvaz first run 2026-07-22: stripping it
97
+ # unconditionally missed milestone-m-EVT01 and fell through to a 38-dir refusal. Try the
98
+ # anchor verbatim before the m--stripped form, then both again minus one trailing
99
+ # -{session} token; first existing dir wins (exact/longer forms first).
95
100
  _mdir=""
96
101
  _branch="$(git -C "$WORKTREE" branch --show-current 2>/dev/null)"
97
102
  case "$_branch" in
98
- forge/m-*)
99
- _anchor="${_branch#forge/m-}" # 40 | 40-a1b2c3d4 | R61-002
100
- [ -d "$WORKTREE/.forge/phases/milestone-$_anchor" ] && _mdir="$WORKTREE/.forge/phases/milestone-$_anchor"
101
- if [ -z "$_mdir" ]; then
102
- _short="${_anchor%-*}" # strip one trailing -token (session suffix)
103
- [ "$_short" != "$_anchor" ] && [ -d "$WORKTREE/.forge/phases/milestone-$_short" ] && _mdir="$WORKTREE/.forge/phases/milestone-$_short"
104
- fi ;;
103
+ forge/*)
104
+ _anchor="${_branch#forge/}" # m-40 | m-40-a1b2c3d4 | m-EVT01-e6063597
105
+ _short="${_anchor%-*}" # minus one trailing -token (session suffix)
106
+ for _cand in "$_anchor" "${_anchor#m-}" "$_short" "${_short#m-}"; do
107
+ [ -n "$_cand" ] || continue
108
+ if [ -d "$WORKTREE/.forge/phases/milestone-$_cand" ]; then
109
+ _mdir="$WORKTREE/.forge/phases/milestone-$_cand"
110
+ break
111
+ fi
112
+ done ;;
105
113
  esac
106
114
  # Fallback (non-git fixture dirs / unconventional branches): a SINGLE milestone dir is
107
115
  # unambiguous; several without a branch-derived pick is a refusal, never a guess.
@@ -157,6 +157,41 @@ else
157
157
  bad "dry-run side effects" "dry-run wrote work-order files"
158
158
  fi
159
159
 
160
+ # --- 13-14. BRANCH-DERIVED MILESTONE PICK (canvaz first run 2026-07-22): with several
161
+ # milestone dirs, the forge/{anchor} branch names the one to build. The id's m- may be
162
+ # strippable (m-40 → milestone-40) or part of a labeled id (m-EVT01 → milestone-m-EVT01);
163
+ # unconditional stripping refused a clean labeled-id dispatch. These fixtures are real
164
+ # git repos — cases 9-12 (mktemp dirs) never reach the branch path.
165
+ mkbranch() { git -C "$1" init -q -b "$2" 2>/dev/null || { git -C "$1" init -q && git -C "$1" checkout -q -b "$2"; }; }
166
+
167
+ # 13. labeled id: forge/m-EVT01-<session> resolves milestone-m-EVT01 (m- is part of the id)
168
+ LSLICE="$ROOT/labeled-wt"
169
+ mkdir -p "$LSLICE/.forge/phases/milestone-m-EVT01/1-evt" "$LSLICE/.forge/phases/milestone-5/1-decoy"
170
+ printf 'evt plan\n' > "$LSLICE/.forge/phases/milestone-m-EVT01/1-evt/plan-01.md"
171
+ printf 'decoy\n' > "$LSLICE/.forge/phases/milestone-5/1-decoy/plan-01.md"
172
+ mkbranch "$LSLICE" forge/m-EVT01-e6063597
173
+ err13="$(sh "$DISPATCH" --slice "$LSLICE" --materialize-only 2>&1)"; rc=$?
174
+ if [ "$rc" = 0 ] && grep -q '^ - 1-evt$' "$LSLICE/slice.yml" 2>/dev/null \
175
+ && grep -q 'evt plan' "$LSLICE/phases/1-evt/plan.md" 2>/dev/null && [ ! -d "$LSLICE/phases/1-decoy" ]; then
176
+ ok "labeled milestone id: branch picks milestone-m-EVT01 among several dirs (m- kept)"
177
+ else
178
+ bad "labeled-id branch pick" "rc=$rc err=$err13"
179
+ fi
180
+
181
+ # 14. numeric id + session suffix: forge/m-40-<session> still resolves milestone-40 (m- stripped)
182
+ QSLICE="$ROOT/numeric-wt"
183
+ mkdir -p "$QSLICE/.forge/phases/milestone-40/1-num" "$QSLICE/.forge/phases/milestone-39/1-decoy"
184
+ printf 'num plan\n' > "$QSLICE/.forge/phases/milestone-40/1-num/plan-01.md"
185
+ printf 'decoy\n' > "$QSLICE/.forge/phases/milestone-39/1-decoy/plan-01.md"
186
+ mkbranch "$QSLICE" forge/m-40-a1b2c3d4
187
+ err14="$(sh "$DISPATCH" --slice "$QSLICE" --materialize-only 2>&1)"; rc=$?
188
+ if [ "$rc" = 0 ] && grep -q '^ - 1-num$' "$QSLICE/slice.yml" 2>/dev/null \
189
+ && grep -q 'num plan' "$QSLICE/phases/1-num/plan.md" 2>/dev/null && [ ! -d "$QSLICE/phases/1-decoy" ]; then
190
+ ok "numeric milestone id: branch picks milestone-40 among several dirs (m- stripped)"
191
+ else
192
+ bad "numeric-id branch pick" "rc=$rc err=$err14"
193
+ fi
194
+
160
195
  # --- summary -----------------------------------------------------------------
161
196
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
162
197
  [ "$FAILED" = 0 ]