moflo 4.12.12 → 4.13.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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  ---
6
6
 
7
- ## CLI Commands (26 Commands, 140+ Subcommands)
7
+ ## CLI Commands (27 Commands, 140+ Subcommands)
8
8
 
9
9
  ### Core Commands
10
10
 
@@ -40,6 +40,7 @@
40
40
  | `epic` | 3 | Epic orchestrator — run/status/reset with single-branch or auto-merge strategy |
41
41
  | `doctor` | 1 | System diagnostics with health checks |
42
42
  | `completions` | 4 | Shell completions (bash, zsh, fish, powershell) |
43
+ | `worktree` | 3 | Provisioned git worktrees (add, list, remove) — alias `wt` |
43
44
 
44
45
  ### Quick Examples (MCP Preferred)
45
46
 
@@ -59,6 +60,49 @@ npx flo daemon start
59
60
 
60
61
  ---
61
62
 
63
+ ## Provisioned Worktrees (`flo worktree`)
64
+
65
+ A bare `git worktree add` produces a valid checkout and an unrunnable workspace — no
66
+ `node_modules`, none of the gitignored `.env` files, and dev servers that collide with the
67
+ primary checkout on fixed ports. `flo worktree add` creates the worktree **and** provisions it.
68
+
69
+ ```bash
70
+ flo worktree add feature/123-thing # create + provision (this is what /flo -wt runs)
71
+ flo worktree add feature/123 --json # {"path":…,"branch":…,"index":0,"provisioned":true}
72
+ flo worktree list # every worktree + its provisioning state
73
+ flo worktree remove feature/123-thing # refuses a dirty tree unless --force
74
+ ```
75
+
76
+ Provisioning is driven by an optional `worktree:` block in `moflo.yaml`. **With no block, `add`
77
+ creates the worktree and provisions nothing** — identical to a plain `git worktree add`.
78
+
79
+ ```yaml
80
+ worktree:
81
+ dir: ../myrepo-worktrees # default: <repo-parent>/<repo>-worktrees
82
+ copy: [".env", ".env.*"] # gitignored files copied from the primary checkout
83
+ link: ["node_modules"] # symlinked (junctioned on Windows) from the primary checkout
84
+ setup: "npm ci" # run in the new worktree, with MOFLO_WORKTREE_INDEX in its env
85
+ ```
86
+
87
+ | Key | Use it when | Watch out for |
88
+ |-----|-------------|---------------|
89
+ | `copy` | A fresh checkout is missing gitignored config a build needs | It relocates **secrets** outside the repo and outside its `.gitignore`. Sources must live inside the primary checkout; `../secrets` is rejected. |
90
+ | `link` | `node_modules` is large and the project does not use npm workspaces | A symlinked root `node_modules` is fragile under npm/yarn workspaces — prefer `setup: npm ci` there. An existing destination is never clobbered. |
91
+ | `setup` | Install/build steps must run per workspace | A non-zero exit marks the provision failed but leaves the worktree in place. |
92
+
93
+ **Port collisions.** moflo cannot rewrite a project's hardcoded ports. It gives each worktree a
94
+ small stable integer — unique among live worktrees, reused when one is removed — as
95
+ `MOFLO_WORKTREE_INDEX` in the `setup` command's environment. Offset your own ports from it:
96
+
97
+ ```yaml
98
+ worktree:
99
+ setup: "npm ci && node -e \"require('fs').writeFileSync('.env.local','PORT='+(3500+Number(process.env.MOFLO_WORKTREE_INDEX)*20))\""
100
+ ```
101
+
102
+ Memory needs no setup: durable learnings already converge across a repo's worktrees
103
+ automatically. See `moflo-cross-install-memory-sharing.md` for the snapshot recipe that also
104
+ skips the structural cold-start.
105
+
62
106
  ## Available Agents
63
107
 
64
108
  The shipped agent roster — each is invoked via the `Agent` tool with `subagent_type: <name>`. The canonical handle is the `name:` frontmatter inside `.claude/agents/**/*.md` (filename may differ from agent name). Aspirational agents that never shipped were retired — `retired-files.json` enforces auto-prune on consumer upgrade.
@@ -138,3 +138,4 @@ A foreign-writer warning from the Writers Audit check (`flo doctor -c writers`)
138
138
  - `.claude/guidance/moflo-memory-strategy.md` — Namespaces, RAG indexing, and the durable-vs-derived split this doc builds on
139
139
  - `.claude/guidance/moflo-memory-protocol.md` — Search-and-traverse protocol for the shared `learnings` once it is populated
140
140
  - `.claude/guidance/moflo-core-guidance.md` — CLI, daemon, and `moflo.yaml` reference (the `memory` config block)
141
+ - `.claude/guidance/moflo-cli-reference.md` — `flo worktree`, which provisions a new worktree's gitignored files, `node_modules`, and per-workspace port index (memory sharing needs no setup; the rest of the workspace does)
@@ -106,30 +106,64 @@ implementation, tests, simplify, commit, and PR from inside it. The current chec
106
106
  untouched. Durable learnings still converge automatically — a worktree shares the repo's
107
107
  `<git-common-dir>/moflo/durable.db` (see `/memory-worktree`).
108
108
 
109
- Compute paths with Node, never string-concatenate the branch name contains `/`, and the
110
- worktree dir must sit **outside** the checkout on every OS (Rule #1: no hardcoded separators,
111
- no `/tmp`, no `mkdir -p`):
109
+ Use `flo worktree add`. It computes the path, creates the branch off the repo's default
110
+ branch, and **provisions** the tree per the optional `worktree:` block in `moflo.yaml` — copying
111
+ gitignored `.env` material, linking `node_modules`, running a `setup` command. A fresh worktree is
112
+ otherwise a valid checkout and an unrunnable workspace. Do not hand-roll the path or shell
113
+ `git worktree add` directly: the path computation and the copy/link/setup steps are
114
+ platform-sensitive (Rule #1) and live in tested code.
112
115
 
113
116
  ```bash
114
- # 1. Fresh base update main in the current checkout first (worktrees share objects).
115
- git fetch origin main
117
+ cd "<repo-root>" && flo worktree add "<type>/<issue-number>-<short-desc>" --json
118
+ ```
119
+
120
+ Bind the repo root to the call. `flo worktree` resolves the repo from its working directory, and in
121
+ Claude Code that resets between calls — run it from the wrong place and it silently targets a
122
+ different repository (`Not a registered worktree of this repo` on the good day, the wrong repo's
123
+ worktree on the bad one).
116
124
 
117
- # 2. Resolve a sibling worktree path: <repo-parent>/<repo>-worktrees/<slugged-branch>
118
- # Slug the branch's "/" to "-" so the dir is flat and valid on Windows/macOS/Linux.
119
- node -e "const p=require('path'),cp=require('child_process');const root=cp.execSync('git rev-parse --show-toplevel').toString().trim();const branch=process.argv[1];const slug=branch.replace(/[\\\\/]/g,'-');const dir=p.join(p.dirname(root),p.basename(root)+'-worktrees',slug);console.log(dir)" "<type>/<issue-number>-<short-desc>"
125
+ It prints one JSON object read `path` from it:
120
126
 
121
- # 3. Create the worktree + branch off origin/main in one step (the printed path from step 2).
122
- git worktree add -b <type>/<issue-number>-<short-desc> "<computed-path>" origin/main
127
+ ```json
128
+ {"path":"/abs/path/to/repo-worktrees/type-123-slug","branch":"type/123-slug","index":0,"provisioned":true}
123
129
  ```
124
130
 
125
- Then `cd "<computed-path>"` and run **every** remaining phase (implement → tests → simplify →
126
- commit PR) from there. Report the worktree path to the user. Leave the worktree in place
127
- after the PR — the user may want to inspect it; note that `git worktree remove "<path>"` cleans
128
- it up when done.
131
+ Then run **every** remaining phase (implement → tests → simplify → commit → PR) against that
132
+ `path`, and report it to the user.
133
+
134
+ **A bare `cd` does not stick.** In Claude Code the Bash working directory resets to the project root
135
+ after each call, so `cd <path>` in one call and `npm test` in the next runs the test in the WRONG
136
+ tree — the primary checkout — and everything looks fine until the PR contains no changes. Bind the
137
+ directory to each command instead:
138
+
139
+ - shell commands — put the `cd` in the *same* call: `cd "<path>" && npm test`
140
+ - git — prefer `git -C "<path>" status` over cd'ing at all
141
+ - file edits — use the absolute path under `<path>`; never a repo-relative one
142
+
143
+ **Fallback — `flo worktree` not available.** The command ships in the same package as this skill,
144
+ so normally they move together. They can still drift apart: a `flo` binary on PATH older than the
145
+ synced `.claude/skills/`, or a moflo source checkout whose change has not been published and
146
+ reinstalled yet. If the command errors with `Unknown command: worktree`, do NOT stop — create the
147
+ worktree the plain way and continue the run, noting to the user that provisioning was skipped:
148
+
149
+ ```bash
150
+ git fetch origin
151
+ node -e "const p=require('path'),cp=require('child_process');const root=cp.execSync('git rev-parse --show-toplevel').toString().trim();const branch=process.argv[1];const dir=p.join(p.dirname(root),p.basename(root)+'-worktrees',branch.replace(/[\\/]/g,'-'));console.log(dir)" "<type>/<issue-number>-<short-desc>"
152
+ git worktree add -b "<type>/<issue-number>-<short-desc>" "<computed-path>" origin/main
153
+ ```
129
154
 
130
- If `git worktree add` fails because the path already exists (a prior run), reuse it:
131
- `cd "<computed-path>" && git status` and continue, or pick a `-2` suffix — do not delete a dir
132
- you did not just create.
155
+ The tree is then a valid checkout with no `node_modules` and no gitignored `.env` files — fine for a
156
+ typecheck-only ticket, and not for one that runs the app.
157
+
158
+ Notes:
159
+ - `--from <ref>` overrides the base ref (default: the repo's default branch via `origin/HEAD`).
160
+ - `provisioned: false` means a copy/link/setup step failed — the worktree is still a usable
161
+ checkout. Surface the failing step to the user rather than silently continuing to run tests that
162
+ will fail for want of a dependency.
163
+ - If the branch's worktree already exists (a prior run), `add` reuses it rather than deleting it.
164
+ - Leave the worktree in place after the PR — the user may want to inspect it. Clean up with
165
+ `flo worktree remove "<branch>"` (it refuses a tree with uncommitted changes unless `--force`).
166
+ `flo worktree list` shows every worktree and its provisioning state.
133
167
 
134
168
  ### 3.3 Implement
135
169
  Follow the plan from the ticket.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/eric-cielo/moflo/main/docs/Moflo_md.png?v=6" alt="MoFlo" />
2
+ <img src="https://raw.githubusercontent.com/eric-cielo/moflo/main/docs/Moflo_wide.png?v=7" alt="MoFlo" />
3
3
  </p>
4
4
 
5
5
  # MoFlo
@@ -349,6 +349,89 @@ Inside Claude Code, the `/flo` (or `/fl`) slash command drives GitHub issue exec
349
349
 
350
350
  Flags compose: e.g. `/flo -sd -m <issue>` runs the SDD cycle and auto-merges. Each modifier has a `--no-*` form (`--no-sdd`, `--no-verify`, `--no-merge`) to override a `moflo.yaml` default for a single run — including `--no-verify`, since verify-before-done is on by default. For full options and details, type `/flo` with no arguments — Claude Code will display the complete skill documentation. Also available as `/fl`.
351
351
 
352
+ ### Provisioned worktrees (`-w` / `flo worktree`)
353
+
354
+ Running two tickets at once means two worktrees, and a bare `git worktree add` gives you a valid
355
+ checkout that you cannot actually run: no `node_modules`, none of your gitignored `.env` files, and
356
+ dev servers that fight the primary checkout over the same ports. `/flo -w` drives `flo worktree add`,
357
+ which creates the worktree **and** provisions it.
358
+
359
+ Provisioning is opt-in per project. **With no `worktree:` block in `moflo.yaml`, `flo worktree add`
360
+ creates the worktree and provisions nothing** — exactly what a plain `git worktree add` would do.
361
+
362
+ ```yaml
363
+ worktree:
364
+ dir: ../myrepo-worktrees # default: <repo-parent>/<repo>-worktrees
365
+ copy: [".env", ".env.*"] # gitignored files copied from the primary checkout
366
+ link: ["node_modules"] # symlinked (junctioned on Windows) from the primary checkout
367
+ setup: "npm ci" # run inside the new worktree after copy/link
368
+ ```
369
+
370
+ | Key | Reach for it when | Watch out for |
371
+ |-----|-------------------|---------------|
372
+ | `copy` | A fresh checkout is missing gitignored config your build needs | It relocates **secrets** outside the repo, and outside its `.gitignore`. Sources must live inside the primary checkout — `../secrets` is refused. |
373
+ | `link` | `node_modules` is large and you are not using npm workspaces | A symlinked root `node_modules` is fragile under npm/yarn workspaces — prefer `setup: npm ci` there. An existing path is never clobbered. |
374
+ | `setup` | Install or build steps must run per workspace | A non-zero exit marks the provision failed but leaves the worktree in place. |
375
+
376
+ **Ports.** MoFlo cannot rewrite your hardcoded ports — it has no way to know which files hold them.
377
+ Instead each worktree gets a small integer, unique among live worktrees and reused when one is
378
+ removed, exported to the `setup` command as `MOFLO_WORKTREE_INDEX`. Offset your own ports from it:
379
+
380
+ ```yaml
381
+ worktree:
382
+ setup: "npm ci && node -e \"require('fs').writeFileSync('.env.local','PORT='+(3000+Number(process.env.MOFLO_WORKTREE_INDEX)*20))\""
383
+ ```
384
+
385
+ `flo worktree remove` refuses a tree with uncommitted changes unless you pass `--force`, and names
386
+ any gitignored files it discarded that provisioning did not create. MoFlo's own
387
+ `.moflo/worktree.json` never counts as "uncommitted work" — otherwise every worktree it created
388
+ would demand `--force` — but un-pushed specs under `.moflo/specs/` do.
389
+
390
+ Memory needs no setup at all: durable learnings already converge across a repo's worktrees
391
+ automatically — see [Sharing learnings across installations](#sharing-learnings-across-installations).
392
+
393
+ #### Running it inside a Claude session
394
+
395
+ `flo worktree` is a plain CLI command with no MCP wrapper, so it works the same whether you type it
396
+ in a terminal or Claude runs it through Bash. `/flo -w` takes the second path — that is all the flag
397
+ does.
398
+
399
+ One thing to know if you drive it yourself from a session, and the reason `/flo -w` handles it for
400
+ you: **Claude Code resets the Bash working directory to the project root after every call.** Two
401
+ consequences, both silent when you get them wrong:
402
+
403
+ ```bash
404
+ # ✗ the cd is gone by the next call — this resolves the WRONG repo
405
+ cd path/to/repo
406
+ flo worktree add feature/42-thing --json
407
+
408
+ # ✓ bind the directory to the call
409
+ cd path/to/repo && flo worktree add feature/42-thing --json
410
+ ```
411
+
412
+ ```bash
413
+ # ✗ runs the tests in the PRIMARY checkout; the run goes green and the PR is empty
414
+ cd "$WORKTREE"
415
+ npm test
416
+
417
+ # ✓ bind it, or skip cd entirely where the tool supports it
418
+ cd "$WORKTREE" && npm test
419
+ git -C "$WORKTREE" status
420
+ ```
421
+
422
+ `flo worktree` resolves the repository from its working directory, so a call made from the wrong
423
+ place targets a different repository — usually reported as `Not a registered worktree of this repo`,
424
+ occasionally as work landing somewhere you did not intend. When editing files in the worktree, use
425
+ absolute paths under the path `add` printed rather than repo-relative ones.
426
+
427
+ Read the worktree location from `--json` rather than reconstructing it; the sibling-directory
428
+ convention lives in one tested function, and a second copy of that rule will drift from it.
429
+
430
+ If you are on a `flo` older than the skills synced into `.claude/` — which happens in a MoFlo source
431
+ checkout before a change is published, or when a global `flo` lags the project's devDependency —
432
+ the command reports `Unknown command: worktree`. `/flo -w` falls back to a plain `git worktree add`
433
+ and tells you provisioning was skipped, rather than failing the run.
434
+
352
435
  ### Spec-Driven Development (SDD)
353
436
 
354
437
  `/flo` can run the full **spec → plan → (review) → implement → verify** cycle — the 2026 agentic-coding pattern — with two independent modifiers. Turning SDD on is opt-in, but once a run is armed for it (via `-sd` or `sdd.default`) **both halves are enforced**: the front half by an implement gate, the back half by verify-before-done.
@@ -637,6 +720,17 @@ flo gate prompt-reminder # Context bracket tracking
637
720
  flo gate session-reset # Reset gate state
638
721
  ```
639
722
 
723
+ ### Worktrees
724
+
725
+ ```bash
726
+ flo worktree add feature/123-thing # Create a worktree and provision it (alias: flo wt)
727
+ flo worktree add feature/123 --json # {"path":…,"branch":…,"index":0,"provisioned":true}
728
+ flo worktree add feature/123 --from v2.1.0 # Branch off a specific ref
729
+ flo worktree add feature/123 --no-provision # Create it, skip copy/link/setup
730
+ flo worktree list # Every worktree and its provisioning state
731
+ flo worktree remove feature/123-thing # Refuses a dirty tree unless --force
732
+ ```
733
+
640
734
  ### Diagnostics
641
735
 
642
736
  ```bash
@@ -68,6 +68,11 @@ const commandLoaders = {
68
68
  epic: () => import('./epic.js'),
69
69
  // Spec-Driven Development artifacts (Epic #1269)
70
70
  sdd: () => import('./sdd.js'),
71
+ worktree: () => import('./worktree.js'),
72
+ // Alias key, not just `aliases: ['wt']` on the command: lazy commands resolve
73
+ // through `commandLoaders` by name, and an alias declared only on the command
74
+ // object is unreachable until something has already loaded it.
75
+ wt: () => import('./worktree.js'),
71
76
  // GitHub Repository Setup
72
77
  github: () => import('./github.js'),
73
78
  // /flo run ledger + per-run token rollup (#1333).
@@ -0,0 +1,408 @@
1
+ /**
2
+ * MoFlo Worktree Command — #1481.
3
+ *
4
+ * Lifecycle + provisioning for git worktrees, so `/flo -wt` produces a RUNNABLE
5
+ * workspace instead of a bare checkout. This file owns git invocation and output
6
+ * formatting only; every platform-sensitive filesystem decision lives in
7
+ * `../services/worktree-provision.ts` where a unit test can reach it (Rule #1).
8
+ *
9
+ * Usage:
10
+ * flo worktree add <branch> [--from <ref>] [--no-provision] [--json]
11
+ * flo worktree list [--json]
12
+ * flo worktree remove <branch|path> [--force] [--json]
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import { existsSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { findProjectRoot } from '../services/project-root.js';
18
+ import { loadMofloConfig } from '../config/moflo-config.js';
19
+ import { WORKTREE_STATE_FILE_POSIX, allocateIndex, computeWorktreePath, isProvisionedPath, resolveForCompare, provisionWorktree, readWorktreeState, writeWorktreeState, } from '../services/worktree-provision.js';
20
+ /**
21
+ * Run a git command. Never `shell: true` — args are passed as an array so a
22
+ * branch name containing shell metacharacters cannot be reinterpreted, and so
23
+ * the same call works identically on all three platforms.
24
+ */
25
+ function git(args, cwd) {
26
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
27
+ return {
28
+ ok: !result.error && result.status === 0,
29
+ stdout: (result.stdout ?? '').trim(),
30
+ stderr: (result.stderr ?? result.error?.message ?? '').trim(),
31
+ };
32
+ }
33
+ /**
34
+ * Parse `git worktree list --porcelain`. The first record is always the primary
35
+ * working tree; linked worktrees follow. Records are blank-line separated, and
36
+ * `branch` is a full ref (`refs/heads/x`) or absent when detached.
37
+ */
38
+ function listWorktrees(repoRoot) {
39
+ const result = git(['worktree', 'list', '--porcelain'], repoRoot);
40
+ if (!result.ok)
41
+ return [];
42
+ const entries = [];
43
+ let current = {};
44
+ const flush = () => {
45
+ if (!current.path)
46
+ return;
47
+ entries.push({
48
+ path: current.path,
49
+ branch: current.branch ? current.branch.replace(/^refs\/heads\//, '') : null,
50
+ state: readWorktreeState(current.path),
51
+ primary: entries.length === 0,
52
+ });
53
+ current = {};
54
+ };
55
+ for (const line of result.stdout.split(/\r?\n/)) {
56
+ if (line.startsWith('worktree ')) {
57
+ flush();
58
+ current.path = line.slice('worktree '.length).trim();
59
+ }
60
+ else if (line.startsWith('branch ')) {
61
+ current.branch = line.slice('branch '.length).trim();
62
+ }
63
+ }
64
+ flush();
65
+ return entries;
66
+ }
67
+ /**
68
+ * The ref a new worktree branches from when `--from` is not given.
69
+ *
70
+ * `origin/HEAD` is the authoritative answer but is not always configured in a
71
+ * fresh clone, so fall back to `gh` (which the rest of this repo's tooling
72
+ * already assumes) and finally to whichever of `origin/main`/`origin/master`
73
+ * exists. Returns null when none resolve — better a clear error than silently
74
+ * branching off the wrong ref.
75
+ *
76
+ * Deliberately NOT shared with `getDefaultBranch` in `commands/github.ts`: that
77
+ * one returns a bare branch name and falls back to the literal `'main'`, which
78
+ * is right for generating a CI workflow and wrong here — silently branching a
79
+ * user's work off a guessed ref is the failure this returns null to avoid. It
80
+ * also tries `gh` first, where this prefers git (faster, and works offline).
81
+ */
82
+ function resolveDefaultBase(repoRoot) {
83
+ const head = git(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], repoRoot);
84
+ if (head.ok && head.stdout)
85
+ return head.stdout.replace(/^refs\/remotes\//, '');
86
+ const gh = spawnSync('gh', ['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name'], {
87
+ cwd: repoRoot,
88
+ encoding: 'utf8',
89
+ });
90
+ if (!gh.error && gh.status === 0) {
91
+ const name = (gh.stdout ?? '').trim();
92
+ if (name)
93
+ return `origin/${name}`;
94
+ }
95
+ for (const candidate of ['origin/main', 'origin/master']) {
96
+ if (git(['rev-parse', '--verify', '--quiet', candidate], repoRoot).ok)
97
+ return candidate;
98
+ }
99
+ return null;
100
+ }
101
+ function renderSteps(steps) {
102
+ return steps
103
+ .map(step => {
104
+ const mark = step.status === 'done' ? '✓' : step.status === 'skipped' ? '·' : '✗';
105
+ const detail = step.detail ? ` (${step.detail})` : '';
106
+ return ` ${mark} ${step.kind} ${step.target}${detail}`;
107
+ })
108
+ .join('\n');
109
+ }
110
+ // =============================================================================
111
+ // add
112
+ // =============================================================================
113
+ async function cmdAdd(ctx) {
114
+ const branch = ctx.args?.[1];
115
+ const json = ctx.flags.json === true;
116
+ if (!branch) {
117
+ return { success: false, message: 'Usage: flo worktree add <branch> [--from <ref>]', exitCode: 1 };
118
+ }
119
+ const repoRoot = findProjectRoot({ cwd: ctx.cwd });
120
+ const config = loadMofloConfig(repoRoot);
121
+ const worktreeConfig = config.worktree;
122
+ const target = computeWorktreePath(repoRoot, branch, worktreeConfig?.dir);
123
+ const existing = listWorktrees(repoRoot);
124
+ // Resolve the needle once, then compare resolved strings — realpathing both
125
+ // sides inside the scan costs 4 walks per worktree for the same answer.
126
+ const resolvedTarget = resolveForCompare(target);
127
+ const alreadyRegistered = existing.find(entry => resolveForCompare(entry.path) === resolvedTarget);
128
+ // Reuse rather than recreate: a prior run may have left work in this tree, and
129
+ // deleting a directory we did not just create is never this command's call.
130
+ if (!alreadyRegistered) {
131
+ if (existsSync(target)) {
132
+ return {
133
+ success: false,
134
+ message: `Path already exists but is not a registered worktree: ${target}\nRemove it by hand, or pass a different branch name.`,
135
+ exitCode: 1,
136
+ };
137
+ }
138
+ const explicitFrom = typeof ctx.flags.from === 'string';
139
+ const base = explicitFrom ? ctx.flags.from : resolveDefaultBase(repoRoot);
140
+ if (!base) {
141
+ return {
142
+ success: false,
143
+ message: 'Could not resolve a default base ref (no origin/HEAD, no gh, no origin/main or origin/master). Pass --from <ref>.',
144
+ exitCode: 1,
145
+ };
146
+ }
147
+ // Fetch so the DEFAULT base is current — branching a ticket off a stale
148
+ // origin/main is the failure this guards. An explicit `--from` that already
149
+ // resolves locally (a tag, another branch) is the user naming a specific
150
+ // commit, so skip the network round trip there. A fetch failure is never
151
+ // fatal: an offline machine should still get its worktree.
152
+ const baseIsLocal = git(['rev-parse', '--verify', '--quiet', base], repoRoot).ok;
153
+ if (!(explicitFrom && baseIsLocal))
154
+ git(['fetch', 'origin'], repoRoot);
155
+ const created = git(['worktree', 'add', '-b', branch, target, base], repoRoot);
156
+ if (!created.ok) {
157
+ return { success: false, message: `git worktree add failed: ${created.stderr}`, exitCode: 1 };
158
+ }
159
+ }
160
+ // Re-adding an existing worktree MUST keep its index. `existing` includes that
161
+ // worktree, so allocating afresh would hand it a new number and rewrite
162
+ // worktree.json — silently shifting every port a consumer derived from
163
+ // MOFLO_WORKTREE_INDEX in a tree they are already working in.
164
+ const index = alreadyRegistered?.state?.index ??
165
+ allocateIndex(existing.map(entry => entry.state?.index).filter((n) => typeof n === 'number'));
166
+ let provisioned = true;
167
+ // Distinct from `!provisioned`: skipping provisioning by request is not a
168
+ // failure, so it must not colour the exit code.
169
+ let provisionFailed = false;
170
+ let steps = [];
171
+ // Positive name, negative read (#1474): the parser turns `--no-provision`
172
+ // into `flags.provision = false`; an option DECLARED `no-provision` would be
173
+ // an unreachable no-op.
174
+ if (ctx.flags.provision === false) {
175
+ // Still record state so `list` reports the tree as moflo-created and the
176
+ // index stays allocated against it.
177
+ writeWorktreeState(target, { branch, index, primaryRoot: repoRoot, provisioned: false });
178
+ provisioned = false;
179
+ }
180
+ else {
181
+ const result = provisionWorktree({
182
+ primaryRoot: repoRoot,
183
+ worktreePath: target,
184
+ branch,
185
+ index,
186
+ config: worktreeConfig,
187
+ jsonMode: json,
188
+ });
189
+ provisioned = result.provisioned;
190
+ provisionFailed = !result.provisioned;
191
+ steps = result.steps;
192
+ }
193
+ if (json) {
194
+ console.log(JSON.stringify({ path: target, branch, index, provisioned, steps }));
195
+ return { success: !provisionFailed, exitCode: provisionFailed ? 1 : 0 };
196
+ }
197
+ const lines = [`Worktree: ${target}`, `Branch: ${branch}`, `Index: ${index}`];
198
+ if (steps.length > 0)
199
+ lines.push('Provisioning:', renderSteps(steps));
200
+ else if (ctx.flags.provision === false)
201
+ lines.push('Provisioning: skipped (--no-provision)');
202
+ else if (!worktreeConfig) {
203
+ lines.push('Provisioning: none configured (add a `worktree:` block to moflo.yaml)');
204
+ }
205
+ lines.push(`Remove with: flo worktree remove ${branch}`);
206
+ console.log(lines.join('\n'));
207
+ return { success: !provisionFailed, exitCode: provisionFailed ? 1 : 0 };
208
+ }
209
+ // =============================================================================
210
+ // list
211
+ // =============================================================================
212
+ async function cmdList(ctx) {
213
+ const repoRoot = findProjectRoot({ cwd: ctx.cwd });
214
+ const entries = listWorktrees(repoRoot);
215
+ if (ctx.flags.json === true) {
216
+ console.log(JSON.stringify(entries.map(entry => ({
217
+ path: entry.path,
218
+ branch: entry.branch,
219
+ primary: entry.primary,
220
+ provisioned: entry.state?.provisioned ?? false,
221
+ managed: entry.state !== null,
222
+ index: entry.state?.index ?? null,
223
+ }))));
224
+ return { success: true, exitCode: 0 };
225
+ }
226
+ if (entries.length === 0) {
227
+ console.log('No worktrees.');
228
+ return { success: true, exitCode: 0 };
229
+ }
230
+ const lines = entries.map(entry => {
231
+ const tag = entry.primary
232
+ ? 'primary'
233
+ : entry.state === null
234
+ ? 'unmanaged'
235
+ : entry.state.provisioned
236
+ ? `provisioned #${entry.state.index}`
237
+ : `unprovisioned #${entry.state.index}`;
238
+ return ` ${entry.branch ?? '(detached)'} [${tag}]\n ${entry.path}`;
239
+ });
240
+ console.log(lines.join('\n'));
241
+ return { success: true, exitCode: 0 };
242
+ }
243
+ // =============================================================================
244
+ // remove
245
+ // =============================================================================
246
+ /**
247
+ * Porcelain status lines that represent the USER's work.
248
+ *
249
+ * `flo worktree add` writes `.moflo/worktree.json` into the tree it creates, and
250
+ * `.moflo/` is not gitignored in every project — so a freshly created, untouched
251
+ * worktree reports as dirty. Counting moflo's own bookkeeping as user work would
252
+ * make `remove` demand `--force` on every worktree this command produced, which
253
+ * trains the user to always pass it and defeats the guard entirely.
254
+ *
255
+ * Only that ONE file is excused, never the whole `.moflo/` directory: a worktree
256
+ * may also hold un-pushed SDD specs and plans under `.moflo/specs/`, and those
257
+ * are user-authored work that must still block removal. Reaching that precision
258
+ * requires `-uall` at the call site — porcelain otherwise collapses an untracked
259
+ * directory to a single `?? .moflo/` line, which cannot be told apart from spec
260
+ * work living inside it.
261
+ *
262
+ * Each line is `XY <path>`; a rename is `XY <old> -> <new>`, and a path with
263
+ * unusual characters is quoted with C-style escapes. Only the leading two
264
+ * status columns are fixed width, so the path starts at index 3. A filename
265
+ * containing a literal ` -> ` inside quotes would mis-split — harmless, because
266
+ * the mis-split value simply fails to equal the state file and the line counts
267
+ * as user work, which is the safe direction (refuse removal, never delete).
268
+ */
269
+ function userChanges(porcelain) {
270
+ const stateFile = WORKTREE_STATE_FILE_POSIX;
271
+ return porcelain
272
+ .split(/\r?\n/)
273
+ .filter(line => line.trim().length > 0)
274
+ .filter(line => {
275
+ const entry = line.slice(3).trim();
276
+ const target = (entry.includes(' -> ') ? entry.split(' -> ')[1] : entry).replace(/^"|"$/g, '');
277
+ return target !== stateFile;
278
+ });
279
+ }
280
+ /**
281
+ * Gitignored paths in the worktree that `remove` is about to destroy and that
282
+ * provisioning did not put there.
283
+ *
284
+ * `git status --porcelain` never lists ignored files, so the dirty gate above
285
+ * cannot see them — yet removing the worktree deletes them (stock
286
+ * `git worktree remove` does the same; this is inherent to worktree removal,
287
+ * not something --force introduces). Anything `copy:` or `link:` created is
288
+ * excluded: it either still exists in the primary checkout or is a symlink
289
+ * whose target is untouched, so naming it would be noise on every removal.
290
+ *
291
+ * Warns; never blocks. A project whose `setup:` ran `npm ci` has a legitimate
292
+ * `node_modules` here on every single removal, and blocking on that would just
293
+ * teach the user to always pass --force.
294
+ */
295
+ function unprovisionedIgnoredPaths(worktreePath, config) {
296
+ const status = git(['status', '--porcelain', '--ignored=matching', '-uall'], worktreePath);
297
+ if (!status.ok)
298
+ return [];
299
+ return status.stdout
300
+ .split(/\r?\n/)
301
+ .filter(line => line.startsWith('!! '))
302
+ .map(line => line.slice(3).trim().replace(/^"|"$/g, ''))
303
+ .filter(target => target !== WORKTREE_STATE_FILE_POSIX)
304
+ .filter(target => !isProvisionedPath(target, config));
305
+ }
306
+ async function cmdRemove(ctx) {
307
+ const which = ctx.args?.[1];
308
+ const force = ctx.flags.force === true;
309
+ if (!which) {
310
+ return { success: false, message: 'Usage: flo worktree remove <branch|path> [--force]', exitCode: 1 };
311
+ }
312
+ const repoRoot = findProjectRoot({ cwd: ctx.cwd });
313
+ const entries = listWorktrees(repoRoot);
314
+ // Match by branch first, then by path. The path comparison is realpath-based,
315
+ // so a symlinked tempdir on macOS still matches; the needle resolves once.
316
+ const resolvedCandidate = resolveForCompare(path.resolve(ctx.cwd, which));
317
+ const match = entries.find(entry => entry.branch === which || resolveForCompare(entry.path) === resolvedCandidate);
318
+ if (!match) {
319
+ return { success: false, message: `Not a registered worktree of this repo: ${which}`, exitCode: 1 };
320
+ }
321
+ if (match.primary) {
322
+ return { success: false, message: 'Refusing to remove the primary working tree.', exitCode: 1 };
323
+ }
324
+ if (!force) {
325
+ // `-uall` so an untracked directory is not collapsed to one line — see userChanges().
326
+ const status = git(['status', '--porcelain', '-uall'], match.path);
327
+ const dirty = status.ok ? userChanges(status.stdout) : [];
328
+ if (dirty.length > 0) {
329
+ return {
330
+ success: false,
331
+ message: `Worktree has uncommitted changes: ${match.path}\n ${dirty.slice(0, 5).join('\n ')}\nCommit them, or re-run with --force.`,
332
+ exitCode: 1,
333
+ };
334
+ }
335
+ }
336
+ // Always `--force` at the git layer. `userChanges()` above is the real gate and
337
+ // has already refused anything the user would miss; git's own check cannot tell
338
+ // moflo's untracked `.moflo/worktree.json` from user work, so without this every
339
+ // worktree this command created would be unremovable without `--force`.
340
+ const doomed = unprovisionedIgnoredPaths(match.path, loadMofloConfig(repoRoot).worktree);
341
+ const removed = git(['worktree', 'remove', '--force', match.path], repoRoot);
342
+ if (!removed.ok) {
343
+ return { success: false, message: `git worktree remove failed: ${removed.stderr}`, exitCode: 1 };
344
+ }
345
+ if (ctx.flags.json === true) {
346
+ console.log(JSON.stringify({ removed: match.path, branch: match.branch, discardedIgnored: doomed }));
347
+ return { success: true, exitCode: 0 };
348
+ }
349
+ console.log(`Removed worktree: ${match.path}`);
350
+ if (doomed.length > 0) {
351
+ console.log(` also discarded ${doomed.length} gitignored path(s) that were not provisioned: ` +
352
+ `${doomed.slice(0, 5).join(', ')}${doomed.length > 5 ? ', …' : ''}`);
353
+ }
354
+ return { success: true, exitCode: 0 };
355
+ }
356
+ // =============================================================================
357
+ // Command definition
358
+ // =============================================================================
359
+ const HELP = `Usage: flo worktree <command>
360
+
361
+ Git worktrees as provisioned workspaces (moflo.yaml \`worktree:\` block):
362
+ add <branch> [--from <ref>] [--no-provision] [--json]
363
+ Create a worktree at <repo-parent>/<repo>-worktrees/<branch>
364
+ and provision it (copy / link / setup)
365
+ list [--json] List this repo's worktrees and their provisioning state
366
+ remove <branch|path> [--force] [--json]
367
+ Remove a worktree (refuses a dirty tree without --force)
368
+
369
+ With no \`worktree:\` block in moflo.yaml, \`add\` creates the worktree and
370
+ provisions nothing.`;
371
+ const worktreeCommand = {
372
+ name: 'worktree',
373
+ description: 'Create, list, and remove provisioned git worktrees',
374
+ aliases: ['wt'],
375
+ options: [
376
+ { name: 'from', description: 'Base ref for the new branch (default: origin/HEAD)', type: 'string' },
377
+ {
378
+ name: 'provision',
379
+ description: 'Run copy/link/setup after creating the worktree (--no-provision to skip)',
380
+ type: 'boolean',
381
+ default: true,
382
+ },
383
+ { name: 'force', description: 'Remove even with uncommitted changes', type: 'boolean' },
384
+ { name: 'json', description: 'Emit machine-readable JSON', type: 'boolean' },
385
+ ],
386
+ examples: [
387
+ { command: 'flo worktree add feature/1481-provisioning', description: 'Create + provision a worktree' },
388
+ { command: 'flo worktree list', description: 'Show every worktree and its state' },
389
+ { command: 'flo worktree remove feature/1481-provisioning', description: 'Clean up when the PR is merged' },
390
+ ],
391
+ action: async (ctx) => {
392
+ const sub = ctx.args?.[0];
393
+ switch (sub) {
394
+ case 'add':
395
+ return cmdAdd(ctx);
396
+ case 'list':
397
+ return cmdList(ctx);
398
+ case 'remove':
399
+ return cmdRemove(ctx);
400
+ default:
401
+ console.log(HELP);
402
+ return { success: !sub, exitCode: sub ? 1 : 0 };
403
+ }
404
+ },
405
+ };
406
+ export default worktreeCommand;
407
+ export { worktreeCommand };
408
+ //# sourceMappingURL=worktree.js.map
@@ -190,6 +190,39 @@ function coerceMemoryBackend(raw) {
190
190
  }
191
191
  return DEFAULT_CONFIG.memory.backend;
192
192
  }
193
+ /**
194
+ * Coerce a `worktree.copy` / `worktree.link` entry to a string array (#1481).
195
+ * Both keys accept a bare string for the common single-entry case; anything
196
+ * that is neither a string nor an array of strings is dropped rather than
197
+ * throwing — a malformed entry must never stop a consumer's config loading.
198
+ */
199
+ function coercePathList(raw) {
200
+ const list = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw : undefined;
201
+ if (!list)
202
+ return undefined;
203
+ const cleaned = list
204
+ .filter((v) => typeof v === 'string')
205
+ .map(v => v.trim())
206
+ .filter(v => v.length > 0);
207
+ return cleaned.length > 0 ? cleaned : undefined;
208
+ }
209
+ /**
210
+ * Parse the optional `worktree:` block (#1481). Returns `undefined` when the
211
+ * block is absent or contains nothing usable, so "not configured" stays
212
+ * distinguishable from "configured empty". Unknown sub-keys are ignored.
213
+ */
214
+ function parseWorktreeConfig(raw) {
215
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
216
+ return undefined;
217
+ const block = raw;
218
+ const dir = typeof block.dir === 'string' && block.dir.trim().length > 0 ? block.dir.trim() : undefined;
219
+ const copy = coercePathList(block.copy);
220
+ const link = coercePathList(block.link);
221
+ const setup = typeof block.setup === 'string' && block.setup.trim().length > 0 ? block.setup.trim() : undefined;
222
+ if (!dir && !copy && !link && !setup)
223
+ return undefined;
224
+ return { ...(dir && { dir }), ...(copy && { copy }), ...(link && { link }), ...(setup && { setup }) };
225
+ }
193
226
  /**
194
227
  * Parse raw config object into typed config, merging with defaults.
195
228
  */
@@ -265,6 +298,10 @@ function mergeConfig(raw, root) {
265
298
  return typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined;
266
299
  })(),
267
300
  },
301
+ // #1481 — optional worktree provisioning. The whole block stays `undefined`
302
+ // when absent so an existing consumer `moflo.yaml` is unaffected, and so
303
+ // `flo worktree add` can distinguish "not configured" from "configured empty".
304
+ worktree: parseWorktreeConfig(raw.worktree),
268
305
  hooks: {
269
306
  pre_edit: raw.hooks?.pre_edit ?? raw.hooks?.preEdit ?? DEFAULT_CONFIG.hooks.pre_edit,
270
307
  post_edit: raw.hooks?.post_edit ?? raw.hooks?.postEdit ?? DEFAULT_CONFIG.hooks.post_edit,
@@ -508,6 +545,26 @@ memory:
508
545
  # worktrees never produce. Conductor recipe: set hydrate_from AND snapshot_to
509
546
  # to the SAME absolute path. Overridable per-process via MOFLO_SNAPSHOT_TO.
510
547
 
548
+ # Worktree provisioning (#1481) — makes "flo worktree add" (and "/flo -wt")
549
+ # produce a RUNNABLE workspace, not just a valid checkout. Entirely optional:
550
+ # with this block absent, a new worktree is created and nothing is provisioned.
551
+ # worktree:
552
+ # dir: ../myrepo-worktrees
553
+ # Where worktrees are created. Defaults to <repo-parent>/<repo>-worktrees.
554
+ # copy: [".env", ".env.*"]
555
+ # Gitignored files a fresh checkout lacks, copied from the primary checkout.
556
+ # Sources must live inside the primary checkout. NOTE: this relocates secret
557
+ # material to a directory OUTSIDE the repo and outside its .gitignore — keep
558
+ # the worktree dir out of any repo you commit.
559
+ # link: ["node_modules"]
560
+ # Symlinked (junctioned on Windows) from the primary checkout. Opt-in with no
561
+ # default: a symlinked root node_modules is fragile under npm workspaces —
562
+ # prefer "setup: npm ci" if your project uses them.
563
+ # setup: "npm ci"
564
+ # Run inside the new worktree after copy/link, with MOFLO_WORKTREE_INDEX in
565
+ # its environment (a small integer unique among live worktrees) so a project
566
+ # with fixed dev-server ports can offset them per workspace.
567
+
511
568
  # Hook toggles (all on by default — disable to slim down)
512
569
  hooks:
513
570
  pre_edit: true # Track file edits for learning
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Worktree provisioning (#1481).
3
+ *
4
+ * `/flo -wt` and `flo worktree add` create a git worktree; on its own that is a
5
+ * valid checkout and an unrunnable workspace — no `node_modules`, none of the
6
+ * gitignored `.env` files, and no notion that a second worktree's dev servers
7
+ * will collide with the first on fixed ports. This service closes that gap,
8
+ * driven by the optional `worktree:` block in `moflo.yaml`.
9
+ *
10
+ * Every platform-sensitive decision lives here rather than in the command, so
11
+ * the Windows-vs-POSIX branches are reachable from a unit test (Rule #1):
12
+ * - paths built with `path.*`, never separator concatenation
13
+ * - directory links are junctions on Windows (no admin/developer mode needed,
14
+ * and the target must be absolute), plain symlinks on POSIX
15
+ * - copies via `fs.cpSync`; no `cp`/`ln -s`/`mkdir -p`/`find` shell-outs
16
+ * - `setup` runs through a shell on both platforms (it is a user-authored
17
+ * command string, not an argv array) — see `runSetup`
18
+ * - containment checks realpath BOTH sides before comparing (#1145: macOS
19
+ * `/var/folders` vs `/private/var/folders` otherwise false-positives)
20
+ */
21
+ import { spawnSync } from 'node:child_process';
22
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, symlinkSync, } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
25
+ import { globToRegExp } from '../guidance/retriever.js';
26
+ /** Relative path of the per-worktree state file, from the worktree root. */
27
+ export const WORKTREE_STATE_FILE = path.join('.moflo', 'worktree.json');
28
+ /**
29
+ * The same path in git's spelling. `git status --porcelain` reports forward
30
+ * slashes on every platform, so a caller comparing against porcelain output
31
+ * needs this form rather than the host-separator one above.
32
+ */
33
+ export const WORKTREE_STATE_FILE_POSIX = '.moflo/worktree.json';
34
+ /**
35
+ * Turn a branch name into a single flat directory name. Both separators are
36
+ * replaced: a branch is always `/`-delimited, but a caller may hand us a
37
+ * Windows-style string, and `\` is invalid in an NTFS directory name anyway.
38
+ */
39
+ export function slugifyBranch(branch) {
40
+ return branch.replace(/[\\/]/g, '-');
41
+ }
42
+ /**
43
+ * Resolve where a worktree for `branch` belongs.
44
+ *
45
+ * Default: `<repo-parent>/<repo-basename>-worktrees/<slugged-branch>` — a
46
+ * sibling of the checkout, so it is never inside the repo (and so never picked
47
+ * up by the repo's own tooling). `configuredDir` overrides the parent directory
48
+ * and is resolved relative to `repoRoot` when relative.
49
+ */
50
+ export function computeWorktreePath(repoRoot, branch, configuredDir) {
51
+ const parent = configuredDir
52
+ ? path.resolve(repoRoot, configuredDir)
53
+ : path.join(path.dirname(repoRoot), `${path.basename(repoRoot)}-worktrees`);
54
+ return path.join(parent, slugifyBranch(branch));
55
+ }
56
+ /**
57
+ * Smallest non-negative integer not already taken. Reusing a freed slot (rather
58
+ * than incrementing a counter) keeps the index small and stable, which matters
59
+ * because consumers offset fixed ports from it — an unbounded counter would
60
+ * eventually push a derived port out of range.
61
+ */
62
+ export function allocateIndex(existing) {
63
+ const taken = new Set(existing.filter(n => Number.isInteger(n) && n >= 0));
64
+ let candidate = 0;
65
+ while (taken.has(candidate))
66
+ candidate++;
67
+ return candidate;
68
+ }
69
+ /**
70
+ * Resolve a path as far as it exists. `realpathSync` throws on a missing path,
71
+ * but a containment check must still work for a destination that has not been
72
+ * created yet — so walk up to the nearest existing ancestor, resolve that, and
73
+ * re-append the remainder.
74
+ */
75
+ function realpathBestEffort(target) {
76
+ let current = path.resolve(target);
77
+ const trailing = [];
78
+ // Bounded by the path depth: each iteration removes one segment, and
79
+ // `path.dirname` is a fixed point at the filesystem root.
80
+ for (;;) {
81
+ if (existsSync(current))
82
+ return path.join(realpathSync(current), ...trailing.reverse());
83
+ const parent = path.dirname(current);
84
+ if (parent === current)
85
+ return path.resolve(target);
86
+ trailing.push(path.basename(current));
87
+ current = parent;
88
+ }
89
+ }
90
+ /**
91
+ * A canonical key for path identity: realpath'd, and case-folded on the two
92
+ * platforms whose filesystems are case-insensitive by default.
93
+ *
94
+ * Exported so a caller scanning a list resolves its needle ONCE and compares
95
+ * keys, rather than realpathing both sides on every iteration. Case-folding is
96
+ * what makes plain string equality safe here — `C:\Repo` and `C:\repo` are the
97
+ * same directory on Windows, and `/Users/x/Repo` and `/Users/x/repo` are the
98
+ * same directory on stock APFS.
99
+ */
100
+ export function resolveForCompare(target) {
101
+ const resolved = realpathBestEffort(target);
102
+ return process.platform === 'win32' || process.platform === 'darwin'
103
+ ? resolved.toLowerCase()
104
+ : resolved;
105
+ }
106
+ /**
107
+ * Is `candidate` inside `root`? Both sides go through
108
+ * {@link resolveForCompare} first — the #1145 shape, where an unresolved
109
+ * `/var/folders/...` compared against a resolved `/private/var/folders/...` on
110
+ * macOS made two identical paths look different.
111
+ */
112
+ export function isInside(root, candidate) {
113
+ const resolvedRoot = resolveForCompare(root);
114
+ const resolvedCandidate = resolveForCompare(candidate);
115
+ if (resolvedCandidate === resolvedRoot)
116
+ return true;
117
+ const rel = path.relative(resolvedRoot, resolvedCandidate);
118
+ // No `rel.length > 0` guard: `path.relative` returns '' for two spellings of
119
+ // the SAME directory that the equality check above missed, and treating that
120
+ // as "not inside" would break every identity test built on this.
121
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
122
+ }
123
+ /**
124
+ * Expand one `copy:` entry against the primary checkout.
125
+ *
126
+ * Glob support is deliberately narrow — a single `*` in the final segment, so
127
+ * `.env.*` works — because the alternative is either a shell-out (`find` does
128
+ * not exist on Windows) or a full tree walk on a pattern the user thought was
129
+ * cheap. A pattern needing more than this returns nothing rather than silently
130
+ * matching a subset; the caller reports it as skipped.
131
+ */
132
+ function expandCopyEntry(primaryRoot, entry) {
133
+ const normalized = entry.split(/[\\/]/).filter(Boolean);
134
+ if (normalized.length === 0)
135
+ return [];
136
+ const last = normalized[normalized.length - 1];
137
+ if (!last.includes('*')) {
138
+ const full = path.join(primaryRoot, ...normalized);
139
+ return existsSync(full) ? [full] : [];
140
+ }
141
+ const dir = path.join(primaryRoot, ...normalized.slice(0, -1));
142
+ if (!existsSync(dir))
143
+ return [];
144
+ // Reuse the guidance retriever's translator rather than hand-rolling one: it
145
+ // is anchored, escapes every metacharacter, and was already hardened for the
146
+ // `docs/*.md` matching `docsXmd` bug. Its `*` becomes `[^/]*`, which is exact
147
+ // here because these patterns match bare readdir NAMES, never a path.
148
+ const pattern = globToRegExp(last);
149
+ return readdirSync(dir)
150
+ .filter(name => pattern.test(name))
151
+ .sort()
152
+ .map(name => path.join(dir, name));
153
+ }
154
+ /**
155
+ * Does a config entry escape `root` before any symlink resolution?
156
+ *
157
+ * Deliberately LEXICAL, unlike {@link isInside}. "Did the user write a path that
158
+ * climbs out of the tree" is a property of the string they wrote, and resolving
159
+ * it first gets the answer wrong in the normal case: on a re-add, the worktree's
160
+ * `node_modules` is already a symlink into the primary checkout, so realpathing
161
+ * the destination reports it as outside the worktree and rejects a link that is
162
+ * exactly the one provisioning just made. A primary checkout whose own
163
+ * `node_modules` is a symlink (pnpm, a shared store) fails the same way.
164
+ */
165
+ function escapesRoot(root, entry) {
166
+ const rel = path.relative(root, path.resolve(root, entry));
167
+ return rel.startsWith('..') || path.isAbsolute(rel);
168
+ }
169
+ /**
170
+ * Copy the configured gitignored material into the new worktree.
171
+ *
172
+ * Sources are guarded on both ends: each must resolve inside the primary
173
+ * checkout (so `../secrets` is rejected), and the destination is always the
174
+ * worktree we just created. A missing source is skipped rather than fatal —
175
+ * `.env.local` legitimately does not exist on every machine.
176
+ */
177
+ function runCopy(primaryRoot, worktreePath, entries) {
178
+ const steps = [];
179
+ for (const entry of entries) {
180
+ if (!isInside(primaryRoot, path.resolve(primaryRoot, entry))) {
181
+ steps.push({
182
+ kind: 'copy',
183
+ target: entry,
184
+ status: 'failed',
185
+ detail: 'resolves outside the primary checkout',
186
+ });
187
+ continue;
188
+ }
189
+ const matches = expandCopyEntry(primaryRoot, entry);
190
+ if (matches.length === 0) {
191
+ steps.push({ kind: 'copy', target: entry, status: 'skipped', detail: 'no match' });
192
+ continue;
193
+ }
194
+ for (const source of matches) {
195
+ const dest = path.join(worktreePath, path.relative(primaryRoot, source));
196
+ try {
197
+ mkdirSync(path.dirname(dest), { recursive: true });
198
+ cpSync(source, dest, { recursive: true });
199
+ steps.push({ kind: 'copy', target: path.relative(primaryRoot, source), status: 'done' });
200
+ }
201
+ catch (error) {
202
+ steps.push({
203
+ kind: 'copy',
204
+ target: path.relative(primaryRoot, source),
205
+ status: 'failed',
206
+ detail: error instanceof Error ? error.message : String(error),
207
+ });
208
+ }
209
+ }
210
+ }
211
+ return steps;
212
+ }
213
+ /**
214
+ * The `fs.symlinkSync` type argument for a directory link on this platform.
215
+ *
216
+ * Windows gets a junction: unlike a `'dir'` symlink it needs no admin rights or
217
+ * developer mode. Exported and pure so the Windows branch is assertable from a
218
+ * unit test on any host — the alternative, spying on an ESM `fs` export, is not
219
+ * possible, and gating the assertion on a Windows runner would leave the branch
220
+ * unverified on the two CI legs that run most often.
221
+ */
222
+ export function linkTypeForPlatform(platform) {
223
+ return platform === 'win32' ? 'junction' : undefined;
224
+ }
225
+ /**
226
+ * Link the configured paths from the primary checkout into the new worktree.
227
+ * The target is resolved to an ABSOLUTE path before the call on every platform:
228
+ * a relative target silently produces a broken junction on Windows.
229
+ */
230
+ function runLink(primaryRoot, worktreePath, entries) {
231
+ const steps = [];
232
+ const linkType = linkTypeForPlatform(process.platform);
233
+ for (const entry of entries) {
234
+ const source = path.resolve(primaryRoot, entry);
235
+ const dest = path.resolve(worktreePath, entry);
236
+ // Guard BOTH ends against an escaping entry: `link: ["../x"]` would
237
+ // otherwise source from outside the checkout and write the link outside the
238
+ // worktree. Lexical on purpose — see escapesRoot().
239
+ if (escapesRoot(primaryRoot, entry) || escapesRoot(worktreePath, entry)) {
240
+ steps.push({
241
+ kind: 'link',
242
+ target: entry,
243
+ status: 'failed',
244
+ detail: 'resolves outside the primary checkout or the worktree',
245
+ });
246
+ continue;
247
+ }
248
+ if (!existsSync(source)) {
249
+ steps.push({ kind: 'link', target: entry, status: 'skipped', detail: 'no such path' });
250
+ continue;
251
+ }
252
+ // lstat, not existsSync: a broken symlink left by an earlier run still
253
+ // occupies the name, and clobbering it is not ours to decide.
254
+ let occupied = false;
255
+ try {
256
+ lstatSync(dest);
257
+ occupied = true;
258
+ }
259
+ catch {
260
+ occupied = false;
261
+ }
262
+ if (occupied) {
263
+ steps.push({ kind: 'link', target: entry, status: 'skipped', detail: 'already exists' });
264
+ continue;
265
+ }
266
+ try {
267
+ mkdirSync(path.dirname(dest), { recursive: true });
268
+ symlinkSync(source, dest, linkType);
269
+ steps.push({ kind: 'link', target: entry, status: 'done' });
270
+ }
271
+ catch (error) {
272
+ steps.push({
273
+ kind: 'link',
274
+ target: entry,
275
+ status: 'failed',
276
+ detail: error instanceof Error ? error.message : String(error),
277
+ });
278
+ }
279
+ }
280
+ return steps;
281
+ }
282
+ /**
283
+ * Run the configured `setup` command inside the new worktree.
284
+ *
285
+ * `shell: true` on BOTH platforms, unlike the daemon-spawning code this repo
286
+ * models elsewhere. That rule ("shell on Windows, detached on POSIX") is about
287
+ * spawning a known binary with an argv array; `setup` is a user-authored shell
288
+ * command *string* from `moflo.yaml` — `npm ci && npm run build` is a legitimate
289
+ * value, and it needs `cmd.exe` or `/bin/sh` to mean anything. Running it
290
+ * without a shell would exec a file literally named `npm ci && npm run build`.
291
+ * On Windows a shell is required regardless, since `npm` there is `npm.cmd`.
292
+ * Trust boundary: the same as a `package.json` script — the project's own config.
293
+ *
294
+ * `jsonMode` sends the child's stdout to our stderr so a `--json` caller still
295
+ * gets parseable JSON on stdout.
296
+ */
297
+ function runSetup(worktreePath, command, index, jsonMode) {
298
+ const result = spawnSync(command, {
299
+ cwd: worktreePath,
300
+ shell: true,
301
+ stdio: jsonMode ? ['ignore', 2, 2] : 'inherit',
302
+ env: { ...process.env, MOFLO_WORKTREE_INDEX: String(index) },
303
+ });
304
+ if (result.error) {
305
+ return { kind: 'setup', target: command, status: 'failed', detail: result.error.message };
306
+ }
307
+ if (result.status !== 0) {
308
+ return {
309
+ kind: 'setup',
310
+ target: command,
311
+ status: 'failed',
312
+ detail: `exited with code ${result.status ?? 'null'}`,
313
+ };
314
+ }
315
+ return { kind: 'setup', target: command, status: 'done' };
316
+ }
317
+ /**
318
+ * Provision a freshly created worktree: copy, then link, then setup.
319
+ *
320
+ * Order matters — `setup` (typically `npm ci`) may depend on the `.env` files
321
+ * `copy` brings in, and must not race the `link` that would otherwise supply
322
+ * `node_modules`. A failed step never unwinds the worktree: it is a valid
323
+ * checkout either way, and deleting a tree the user may have started working in
324
+ * is far worse than leaving it under-provisioned.
325
+ */
326
+ export function provisionWorktree(opts) {
327
+ const { primaryRoot, worktreePath, branch, index, config, jsonMode = false } = opts;
328
+ const steps = [];
329
+ if (config?.copy?.length)
330
+ steps.push(...runCopy(primaryRoot, worktreePath, config.copy));
331
+ if (config?.link?.length)
332
+ steps.push(...runLink(primaryRoot, worktreePath, config.link));
333
+ if (config?.setup)
334
+ steps.push(runSetup(worktreePath, config.setup, index, jsonMode));
335
+ const provisioned = steps.every(step => step.status !== 'failed');
336
+ writeWorktreeState(worktreePath, { branch, index, primaryRoot, provisioned });
337
+ return { provisioned, steps };
338
+ }
339
+ /**
340
+ * Did `copy:`/`link:` put this worktree-relative path there?
341
+ *
342
+ * Lives here, next to {@link provisionWorktree}, because answering it needs the
343
+ * same glob translation provisioning used: a raw config entry is not a filename.
344
+ * Comparing `.env.*` literally against the `.env.local` git reports never
345
+ * matches, which would blame provisioning's own files on the user.
346
+ *
347
+ * Every ancestor prefix is tested, so a directory entry (glob or literal) also
348
+ * claims the files inside it — `git status -uall` reports those individually.
349
+ *
350
+ * @param target a path in git's spelling (forward slashes), relative to the worktree
351
+ */
352
+ export function isProvisionedPath(target, config) {
353
+ const entries = [...(config?.copy ?? []), ...(config?.link ?? [])]
354
+ .map(entry => entry.split(/[\\/]/).filter(Boolean).join('/'))
355
+ .filter(Boolean);
356
+ if (entries.length === 0)
357
+ return false;
358
+ const segments = target.split('/').filter(Boolean);
359
+ for (const entry of entries) {
360
+ const matcher = entry.includes('*') ? globToRegExp(entry) : null;
361
+ for (let depth = 1; depth <= segments.length; depth++) {
362
+ const prefix = segments.slice(0, depth).join('/');
363
+ if (matcher ? matcher.test(prefix) : prefix === entry)
364
+ return true;
365
+ }
366
+ }
367
+ return false;
368
+ }
369
+ /** Write `.moflo/worktree.json` into a worktree. Atomic — the daemon may read it. */
370
+ export function writeWorktreeState(worktreePath, state) {
371
+ const statePath = path.join(worktreePath, WORKTREE_STATE_FILE);
372
+ mkdirSync(path.dirname(statePath), { recursive: true });
373
+ atomicWriteFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
374
+ }
375
+ /**
376
+ * Read a worktree's moflo state. `null` means moflo did not create this tree —
377
+ * which is exactly how `flo worktree list` reports an externally-created
378
+ * worktree as unprovisioned, so a malformed file is treated the same as a
379
+ * missing one rather than failing the listing.
380
+ */
381
+ export function readWorktreeState(worktreePath) {
382
+ const statePath = path.join(worktreePath, WORKTREE_STATE_FILE);
383
+ if (!existsSync(statePath))
384
+ return null;
385
+ try {
386
+ const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
387
+ if (typeof parsed.branch !== 'string' || !Number.isInteger(parsed.index))
388
+ return null;
389
+ return {
390
+ branch: parsed.branch,
391
+ index: parsed.index,
392
+ primaryRoot: typeof parsed.primaryRoot === 'string' ? parsed.primaryRoot : '',
393
+ provisioned: parsed.provisioned === true,
394
+ };
395
+ }
396
+ catch {
397
+ return null;
398
+ }
399
+ }
400
+ //# sourceMappingURL=worktree-provision.js.map
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.12.12';
5
+ export const VERSION = '4.13.0';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.12.12",
3
+ "version": "4.13.0",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -99,7 +99,7 @@
99
99
  "@typescript-eslint/parser": "^8.65.0",
100
100
  "eslint": "^10.8.0",
101
101
  "glob": "^11.1.0",
102
- "moflo": "^4.12.11",
102
+ "moflo": "^4.12.12",
103
103
  "tsx": "^4.21.0",
104
104
  "typescript": "^5.9.3",
105
105
  "vitest": "^4.0.0"