moflo 4.12.12 → 4.13.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.
@@ -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