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.
- package/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
- package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
- package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
- package/.claude/skills/fl/phases.md +51 -17
- package/.claude/skills/optimize-learnings/SKILL.md +220 -0
- package/README.md +95 -1
- package/bin/lib/get-backend.mjs +150 -12
- package/bin/lib/skill-categories.mjs +1 -0
- package/bin/session-start-launcher.mjs +13 -5
- package/dist/src/cli/commands/daemon.js +5 -2
- package/dist/src/cli/commands/epic.js +5 -1
- package/dist/src/cli/commands/hive-mind.js +6 -4
- package/dist/src/cli/commands/hooks.js +8 -8
- package/dist/src/cli/commands/index.js +5 -0
- package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
- package/dist/src/cli/commands/memory.js +71 -10
- package/dist/src/cli/commands/spell-schedule.js +5 -3
- package/dist/src/cli/commands/worktree.js +408 -0
- package/dist/src/cli/config/moflo-config.js +57 -0
- package/dist/src/cli/index.js +4 -2
- package/dist/src/cli/init/executor.js +1 -0
- package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
- package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
- package/dist/src/cli/memory/bridge-entries.js +157 -9
- package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
- package/dist/src/cli/memory/daemon-backend.js +152 -11
- package/dist/src/cli/memory/entries-read.js +47 -2
- package/dist/src/cli/memory/entries-write.js +73 -10
- package/dist/src/cli/memory/hnsw-singleton.js +112 -9
- package/dist/src/cli/memory/learnings-audit.js +420 -0
- package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
- package/dist/src/cli/memory/learnings-tree.js +187 -0
- package/dist/src/cli/memory/memory-bridge.js +37 -27
- package/dist/src/cli/memory/tool-call-markup.js +218 -0
- package/dist/src/cli/parser.js +7 -3
- package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
- package/dist/src/cli/services/durable-reconcile.js +161 -0
- package/dist/src/cli/services/durable-store-io.js +291 -0
- package/dist/src/cli/services/durable-sync.js +159 -24
- package/dist/src/cli/services/team-artifact-sync.js +462 -163
- package/dist/src/cli/services/worktree-provision.js +400 -0
- package/dist/src/cli/version.js +1 -1
- 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: '
|
|
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.
|
|
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: '
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2549
|
-
|
|
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: '
|
|
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
|
-
//
|
|
173
|
-
|
|
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
|