moflo 4.12.11 → 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.
Files changed (43) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
  2. package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
  3. package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
  4. package/.claude/skills/fl/phases.md +51 -17
  5. package/.claude/skills/optimize-learnings/SKILL.md +220 -0
  6. package/README.md +95 -1
  7. package/bin/lib/get-backend.mjs +150 -12
  8. package/bin/lib/skill-categories.mjs +1 -0
  9. package/bin/session-start-launcher.mjs +13 -5
  10. package/dist/src/cli/commands/daemon.js +5 -2
  11. package/dist/src/cli/commands/epic.js +5 -1
  12. package/dist/src/cli/commands/hive-mind.js +6 -4
  13. package/dist/src/cli/commands/hooks.js +8 -8
  14. package/dist/src/cli/commands/index.js +5 -0
  15. package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
  16. package/dist/src/cli/commands/memory.js +71 -10
  17. package/dist/src/cli/commands/spell-schedule.js +5 -3
  18. package/dist/src/cli/commands/worktree.js +408 -0
  19. package/dist/src/cli/config/moflo-config.js +57 -0
  20. package/dist/src/cli/index.js +4 -2
  21. package/dist/src/cli/init/executor.js +1 -0
  22. package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
  23. package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
  24. package/dist/src/cli/memory/bridge-entries.js +157 -9
  25. package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
  26. package/dist/src/cli/memory/daemon-backend.js +152 -11
  27. package/dist/src/cli/memory/entries-read.js +47 -2
  28. package/dist/src/cli/memory/entries-write.js +73 -10
  29. package/dist/src/cli/memory/hnsw-singleton.js +112 -9
  30. package/dist/src/cli/memory/learnings-audit.js +420 -0
  31. package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
  32. package/dist/src/cli/memory/learnings-tree.js +187 -0
  33. package/dist/src/cli/memory/memory-bridge.js +37 -27
  34. package/dist/src/cli/memory/tool-call-markup.js +218 -0
  35. package/dist/src/cli/parser.js +7 -3
  36. package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
  37. package/dist/src/cli/services/durable-reconcile.js +161 -0
  38. package/dist/src/cli/services/durable-store-io.js +291 -0
  39. package/dist/src/cli/services/durable-sync.js +159 -24
  40. package/dist/src/cli/services/team-artifact-sync.js +462 -163
  41. package/dist/src/cli/services/worktree-provision.js +400 -0
  42. package/dist/src/cli/version.js +1 -1
  43. package/package.json +2 -2
@@ -14,6 +14,7 @@ import { memoryDbPath } from '../services/moflo-paths.js';
14
14
  import { resolveBridgeDbPath } from '../memory/bridge-core.js';
15
15
  import { findProjectRoot } from '../services/project-root.js';
16
16
  import { generateId } from '../shared/utils/id.js';
17
+ import { auditLearningsCommand } from './memory-audit-learnings.js';
17
18
  // Memory backends
18
19
  const BACKENDS = [
19
20
  { value: 'agentdb', label: 'AgentDB', hint: 'Vector database with HNSW approximate-nearest-neighbor (ANN) indexing' },
@@ -514,6 +515,13 @@ const deleteCommand = {
514
515
  if (result.deleted) {
515
516
  output.printSuccess(`Deleted "${key}" from namespace "${namespace}"`);
516
517
  output.printInfo(`Remaining entries: ${result.remainingEntries}`);
518
+ // Durable deletes are retained as an archived row so the deletion can
519
+ // reach the team artifact and sibling worktrees (#1463). Say so — a
520
+ // user auditing the DB should not be surprised to find the row.
521
+ const { isDurableNamespace } = await import('../services/cherry-pick-learnings.js');
522
+ if (isDurableNamespace(namespace)) {
523
+ output.printInfo('Retained as an archived row so the deletion propagates on the next share; invisible to search and purged after 90 days.');
524
+ }
517
525
  }
518
526
  else {
519
527
  output.printWarning(`Key not found: "${key}" in namespace "${namespace}"`);
@@ -762,6 +770,17 @@ const cleanupCommand = {
762
770
  { category: output.bold('Total'), count: output.bold(String(result.candidates.total)) }
763
771
  ]
764
772
  });
773
+ // #1464 — say what was withheld. A zero-candidate result on a store full
774
+ // of old learnings is otherwise read as "already tidy" when the truth is
775
+ // "durable entries were never examined".
776
+ if (result.durableHeldBack) {
777
+ output.writeln();
778
+ output.printInfo(`${result.durableHeldBack} durable ${result.durableHeldBack === 1 ? 'entry' : 'entries'} `
779
+ + `(learnings, knowledge) held back — age is not evidence of staleness there.`);
780
+ output.printList([
781
+ 'Include them deliberately: flo memory cleanup --older-than <age> --namespace learnings',
782
+ ]);
783
+ }
765
784
  if (dryRun)
766
785
  return { success: true, data: result };
767
786
  if (result.candidates.total === 0) {
@@ -1585,7 +1604,7 @@ const indexGuidanceCommand = {
1585
1604
  type: 'string'
1586
1605
  },
1587
1606
  {
1588
- name: 'no-embeddings',
1607
+ name: 'embeddings',
1589
1608
  description: 'Skip embedding generation after indexing',
1590
1609
  type: 'boolean',
1591
1610
  default: false
@@ -1605,7 +1624,7 @@ const indexGuidanceCommand = {
1605
1624
  action: async (ctx) => {
1606
1625
  const forceReindex = ctx.flags.force;
1607
1626
  const specificFile = ctx.flags.file;
1608
- const skipEmbeddings = ctx.flags.noEmbeddings;
1627
+ const skipEmbeddings = ctx.flags.embeddings === false;
1609
1628
  const overlapPercent = ctx.flags.overlap || DEFAULT_OVERLAP_PERCENT;
1610
1629
  const NAMESPACE = 'guidance';
1611
1630
  const fs = await import('fs');
@@ -2057,7 +2076,7 @@ const codeMapCommand = {
2057
2076
  default: false
2058
2077
  },
2059
2078
  {
2060
- name: 'no-embeddings',
2079
+ name: 'embeddings',
2061
2080
  description: 'Skip embedding generation after mapping',
2062
2081
  type: 'boolean',
2063
2082
  default: false
@@ -2072,7 +2091,7 @@ const codeMapCommand = {
2072
2091
  const forceRegen = ctx.flags.force;
2073
2092
  const verbose = ctx.flags.verbose;
2074
2093
  const statsOnly = ctx.flags.stats;
2075
- const skipEmbeddings = ctx.flags.noEmbeddings;
2094
+ const skipEmbeddings = ctx.flags.embeddings === false;
2076
2095
  const cwd = ctx.cwd || process.cwd();
2077
2096
  output.writeln();
2078
2097
  output.writeln(output.bold('Generating Code Map'));
@@ -2502,15 +2521,45 @@ const teamExportCommand = {
2502
2521
  const projectRoot = findProjectRoot();
2503
2522
  const artifactPath = await resolveTeamArtifact(projectRoot, ctx.flags.to);
2504
2523
  try {
2505
- const { exportTeamArtifact, ensureSharedArtifactTracked } = await import('../services/team-artifact-sync.js');
2524
+ const { exportTeamArtifact, ensureSharedArtifactTracked, ensureSharedArtifactEol } = await import('../services/team-artifact-sync.js');
2506
2525
  const report = exportTeamArtifact({ projectRoot, artifactPath, sharedAt: new Date().toISOString() });
2507
2526
  const gitignore = ensureSharedArtifactTracked(projectRoot, artifactPath);
2527
+ const gitattributes = ensureSharedArtifactEol(projectRoot, artifactPath);
2508
2528
  const rel = pathModule.relative(projectRoot, artifactPath) || artifactPath;
2509
2529
  output.printSuccess(`Shared ${report.added} new durable entr${report.added === 1 ? 'y' : 'ies'} → ${rel}`);
2510
- output.printInfo(`Artifact now holds ${report.total} entr${report.total === 1 ? 'y' : 'ies'}.`);
2530
+ // Report every category, not just the appends (#1463). The old summary
2531
+ // named only `added`, which read as "everything was shared" while 32
2532
+ // corrections and 34 deletions sat unpropagated for weeks.
2533
+ const changes = [];
2534
+ if (report.updated > 0)
2535
+ changes.push(`${report.updated} corrected`);
2536
+ if (report.deleted > 0)
2537
+ changes.push(`${report.deleted} retired`);
2538
+ if (report.resurrected > 0)
2539
+ changes.push(`${report.resurrected} restored`);
2540
+ if (changes.length > 0)
2541
+ output.printInfo(`Also propagated: ${changes.join(', ')}.`);
2542
+ if (report.keptRemote > 0) {
2543
+ output.printWarning(`${report.keptRemote} local change${report.keptRemote === 1 ? '' : 's'} NOT shared — the artifact's version is newer. Run \`flo memory team-import\` first.`);
2544
+ }
2545
+ if (report.skippedMalformed > 0) {
2546
+ output.printWarning(`${report.skippedMalformed} malformed line${report.skippedMalformed === 1 ? '' : 's'} skipped.`);
2547
+ }
2548
+ if (report.skippedCorrupt > 0) {
2549
+ output.printWarning(`${report.skippedCorrupt} entr${report.skippedCorrupt === 1 ? 'y' : 'ies'} NOT shared — captured tool-call markup in the value (#1467). `
2550
+ + `Run \`flo memory list --namespace learnings\` to find and rewrite them.`);
2551
+ }
2552
+ if (!report.wrote) {
2553
+ output.printInfo('Nothing changed — the artifact was left untouched.');
2554
+ }
2555
+ const tombstoneNote = report.tombstones > 0 ? ` (+ ${report.tombstones} tombstone${report.tombstones === 1 ? '' : 's'})` : '';
2556
+ output.printInfo(`Artifact now holds ${report.total} entr${report.total === 1 ? 'y' : 'ies'}${tombstoneNote}.`);
2511
2557
  if (gitignore !== 'unchanged') {
2512
2558
  output.printInfo(`.gitignore ${gitignore} so the shared artifact is tracked while the rest of .moflo/ stays ignored.`);
2513
2559
  }
2560
+ if (gitattributes !== 'unchanged') {
2561
+ output.printInfo(`.gitattributes ${gitattributes} to pin the artifact to LF — without it a Windows checkout conflicts on every line when two teammates' artifacts merge.`);
2562
+ }
2514
2563
  output.printInfo(`Commit it to share: git add ${rel} && git commit -m "share learnings"`);
2515
2564
  return { success: true, data: report };
2516
2565
  }
@@ -2545,16 +2594,28 @@ const teamImportCommand = {
2545
2594
  const { importTeamArtifact } = await import('../services/team-artifact-sync.js');
2546
2595
  const report = importTeamArtifact({ projectRoot, artifactPath });
2547
2596
  output.printSuccess(`Merged ${report.imported} durable entr${report.imported === 1 ? 'y' : 'ies'} from the team artifact`);
2548
- if (report.considered > report.imported) {
2549
- output.printInfo(`${report.considered - report.imported} already present (skipped, conflict-free).`);
2597
+ const applied = [];
2598
+ if (report.updated > 0)
2599
+ applied.push(`${report.updated} corrected`);
2600
+ if (report.deleted > 0)
2601
+ applied.push(`${report.deleted} retired locally`);
2602
+ if (report.resurrected > 0)
2603
+ applied.push(`${report.resurrected} restored`);
2604
+ if (applied.length > 0)
2605
+ output.printInfo(`Also applied: ${applied.join(', ')}.`);
2606
+ if (report.keptLocal > 0) {
2607
+ output.printInfo(`${report.keptLocal} artifact change${report.keptLocal === 1 ? '' : 's'} skipped — the local entry is newer.`);
2550
2608
  }
2551
2609
  if (report.skippedMalformed > 0) {
2552
2610
  output.printWarning(`${report.skippedMalformed} malformed line${report.skippedMalformed === 1 ? '' : 's'} skipped.`);
2553
2611
  }
2612
+ if (report.skippedCorrupt > 0) {
2613
+ output.printWarning(`${report.skippedCorrupt} artifact line${report.skippedCorrupt === 1 ? '' : 's'} NOT imported — captured tool-call markup in the content (#1467).`);
2614
+ }
2554
2615
  if (report.skippedNonDurable > 0) {
2555
2616
  output.printWarning(`${report.skippedNonDurable} non-durable entr${report.skippedNonDurable === 1 ? 'y' : 'ies'} skipped (only learnings/knowledge are shared).`);
2556
2617
  }
2557
- if (report.imported > 0) {
2618
+ if (report.imported > 0 || report.updated > 0 || report.resurrected > 0) {
2558
2619
  output.printInfo('Restart your Claude Code session (or run `flo memory rebuild-index`) so the merged learnings are embedded + searchable.');
2559
2620
  }
2560
2621
  return { success: true, data: report };
@@ -2663,7 +2724,7 @@ const restoreCommand = {
2663
2724
  export const memoryCommand = {
2664
2725
  name: 'memory',
2665
2726
  description: 'Memory management commands',
2666
- subcommands: [initMemoryCommand, storeCommand, retrieveCommand, searchCommand, listCommand, deleteCommand, statsCommand, configureCommand, cleanupCommand, compressCommand, exportCommand, importCommand, indexGuidanceCommand, rebuildIndexCommand, codeMapCommand, refreshCommand, restoreLearningsCommand, syncCommand, teamExportCommand, teamImportCommand, backupCommand, restoreCommand],
2727
+ subcommands: [initMemoryCommand, storeCommand, retrieveCommand, searchCommand, listCommand, deleteCommand, statsCommand, configureCommand, cleanupCommand, auditLearningsCommand, compressCommand, exportCommand, importCommand, indexGuidanceCommand, rebuildIndexCommand, codeMapCommand, refreshCommand, restoreLearningsCommand, syncCommand, teamExportCommand, teamImportCommand, backupCommand, restoreCommand],
2667
2728
  options: [],
2668
2729
  examples: [
2669
2730
  { command: 'flo memory store -k "key" -v "value"', description: 'Store data' },
@@ -78,7 +78,7 @@ const createCommand = {
78
78
  { name: 'cron', short: 'c', description: 'Cron expression (5-field)', type: 'string' },
79
79
  { name: 'interval', short: 'i', description: 'Interval (e.g., "6h", "30m", "1d")', type: 'string' },
80
80
  { name: 'at', short: 'a', description: 'One-time ISO 8601 datetime', type: 'string' },
81
- { name: 'no-autostart', description: 'Do not register the daemon as an OS login service', type: 'boolean' },
81
+ { name: 'autostart', description: 'Register the daemon as an OS login service (--no-autostart to skip)', type: 'boolean', default: true },
82
82
  ],
83
83
  examples: [
84
84
  { command: 'moflo spell schedule create -n audit --cron "0 9 * * *"', description: 'Daily at 9am' },
@@ -169,8 +169,10 @@ const createCommand = {
169
169
  // Short-circuit: a fresh create can only ever trigger an install (count
170
170
  // just went up). If the service is already installed, the reconcile is a
171
171
  // guaranteed noop — skip the count fetch entirely.
172
- // Note: parser normalises --no-autostart to ctx.flags.noAutostart (#787).
173
- const skipAutostart = ctx.flags.noAutostart === true;
172
+ // The parser turns `--no-autostart` into `autostart = false`; it has never
173
+ // produced a `noAutostart` key, so the previous read here was always
174
+ // undefined and the flag was a no-op (#1474).
175
+ const skipAutostart = ctx.flags.autostart === false;
174
176
  const alreadyInstalled = readiness.daemonInstalled;
175
177
  let reconcileTransition = 'noop';
176
178
  if (!skipAutostart && !alreadyInstalled) {
@@ -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