@magnusekdahl/parallix 1.2.0 → 1.3.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 (127) hide show
  1. package/index.js +190 -185
  2. package/lib/agents/agents.js +737 -838
  3. package/lib/agents/agents.ts +930 -0
  4. package/lib/agents/claude-telemetry.js +149 -154
  5. package/lib/agents/claude-telemetry.ts +233 -0
  6. package/lib/agents/claude.js +121 -131
  7. package/lib/agents/claude.ts +156 -0
  8. package/lib/agents/codex-telemetry.js +176 -164
  9. package/lib/agents/codex-telemetry.ts +205 -0
  10. package/lib/agents/codex.js +168 -201
  11. package/lib/agents/codex.ts +236 -0
  12. package/lib/agents/limit-hit.js +216 -226
  13. package/lib/agents/limit-hit.ts +262 -0
  14. package/lib/agents/mistral-telemetry.js +7 -10
  15. package/lib/agents/mistral-telemetry.ts +44 -0
  16. package/lib/agents/mistral.js +40 -58
  17. package/lib/agents/mistral.ts +84 -0
  18. package/lib/agents/opencode-export.js +126 -127
  19. package/lib/agents/opencode-export.ts +160 -0
  20. package/lib/agents/opencode-telemetry.js +282 -261
  21. package/lib/agents/opencode-telemetry.ts +350 -0
  22. package/lib/agents/opencode.js +263 -305
  23. package/lib/agents/opencode.ts +370 -0
  24. package/lib/agents/stage-telemetry.js +23 -24
  25. package/lib/agents/stage-telemetry.ts +47 -0
  26. package/lib/commands/active.js +502 -579
  27. package/lib/commands/active.ts +665 -0
  28. package/lib/commands/checkpoint.js +98 -74
  29. package/lib/commands/checkpoint.ts +79 -0
  30. package/lib/commands/config.js +61 -31
  31. package/lib/commands/config.ts +47 -0
  32. package/lib/commands/coverage-gate.js +271 -246
  33. package/lib/commands/coverage-gate.ts +362 -0
  34. package/lib/commands/diff.js +140 -119
  35. package/lib/commands/diff.ts +125 -0
  36. package/lib/commands/draft.js +770 -923
  37. package/lib/commands/draft.ts +1021 -0
  38. package/lib/commands/handoff.js +588 -608
  39. package/lib/commands/handoff.ts +694 -0
  40. package/lib/commands/integrate.js +1332 -1463
  41. package/lib/commands/integrate.ts +1688 -0
  42. package/lib/commands/mission-start.js +279 -237
  43. package/lib/commands/mission-start.ts +263 -0
  44. package/lib/commands/rebase.js +606 -582
  45. package/lib/commands/rebase.ts +635 -0
  46. package/lib/commands/repair-handoff.js +181 -179
  47. package/lib/commands/repair-handoff.ts +228 -0
  48. package/lib/commands/resolve-conflict.js +121 -102
  49. package/lib/commands/resolve-conflict.ts +112 -0
  50. package/lib/commands/review.js +3 -4
  51. package/lib/commands/review.ts +14 -0
  52. package/lib/commands/setup-review.js +4 -5
  53. package/lib/commands/setup-review.ts +14 -0
  54. package/lib/commands/setup.js +3 -3
  55. package/lib/commands/setup.ts +2 -0
  56. package/lib/commands/stats-backfill.js +394 -359
  57. package/lib/commands/stats-backfill.ts +418 -0
  58. package/lib/commands/stats.js +1424 -1665
  59. package/lib/commands/stats.ts +2186 -0
  60. package/lib/commands/status.js +219 -200
  61. package/lib/commands/status.ts +219 -0
  62. package/lib/commands/verify.js +3 -1
  63. package/lib/commands/verify.ts +2 -0
  64. package/lib/core/fmt.js +140 -211
  65. package/lib/core/fmt.ts +189 -0
  66. package/lib/core/git.js +121 -102
  67. package/lib/core/git.ts +146 -0
  68. package/lib/core/gitignore.js +59 -106
  69. package/lib/core/gitignore.ts +103 -0
  70. package/lib/core/mission-utils.js +833 -875
  71. package/lib/core/mission-utils.ts +998 -0
  72. package/lib/core/nels.js +112 -127
  73. package/lib/core/nels.ts +189 -0
  74. package/lib/core/persistent-data-migration.js +247 -205
  75. package/lib/core/persistent-data-migration.ts +255 -0
  76. package/lib/core/product-config.js +400 -476
  77. package/lib/core/product-config.ts +518 -0
  78. package/lib/core/runtime-matrix.js +41 -87
  79. package/lib/core/runtime-matrix.ts +84 -0
  80. package/lib/core/spawn-tee.js +152 -192
  81. package/lib/core/spawn-tee.ts +202 -0
  82. package/lib/core/state-map.js +92 -92
  83. package/lib/core/state-map.ts +115 -0
  84. package/lib/core/storage.js +101 -129
  85. package/lib/core/storage.ts +182 -0
  86. package/lib/core/subagent-limit.js +15 -16
  87. package/lib/core/subagent-limit.ts +25 -0
  88. package/lib/core/verification.js +148 -138
  89. package/lib/core/verification.ts +200 -0
  90. package/lib/index.js +142 -59
  91. package/lib/index.ts +134 -0
  92. package/lib/review/rebase.js +142 -145
  93. package/lib/review/rebase.ts +188 -0
  94. package/lib/review/review-adapter.js +99 -87
  95. package/lib/review/review-adapter.ts +198 -0
  96. package/lib/review/review-artifacts.js +486 -629
  97. package/lib/review/review-artifacts.ts +622 -0
  98. package/lib/review/review-commands.js +1220 -1373
  99. package/lib/review/review-commands.ts +1579 -0
  100. package/lib/review/review-events.js +544 -886
  101. package/lib/review/review-events.ts +954 -0
  102. package/lib/review/review-loop.js +945 -1020
  103. package/lib/review/review-loop.ts +1174 -0
  104. package/lib/review/review-polling.js +86 -115
  105. package/lib/review/review-polling.ts +194 -0
  106. package/lib/review/review-prompts.js +154 -189
  107. package/lib/review/review-prompts.ts +182 -0
  108. package/lib/review/review-state.js +246 -225
  109. package/lib/review/review-state.ts +353 -0
  110. package/lib/review/review.js +63 -84
  111. package/lib/review/review.ts +138 -0
  112. package/lib/tools/backlog.js +694 -680
  113. package/lib/tools/backlog.ts +835 -0
  114. package/lib/tools/forgejo.js +1270 -1405
  115. package/lib/tools/forgejo.ts +1793 -0
  116. package/lib/tools/gatekeeper.js +106 -96
  117. package/lib/tools/gatekeeper.ts +124 -0
  118. package/lib/tools/redgreen.js +200 -181
  119. package/lib/tools/redgreen.ts +221 -0
  120. package/lib/tools/sessions.js +58 -52
  121. package/lib/tools/sessions.ts +81 -0
  122. package/lib/tools/setup-review.js +902 -975
  123. package/lib/tools/setup-review.ts +1152 -0
  124. package/package.json +7 -5
  125. package/prompts/draft.md +1 -0
  126. package/prompts/portfolio.md +1 -1
  127. package/px.js +226 -210
@@ -1,196 +1,264 @@
1
- const fs = require('fs');
2
- const os = require('os');
3
- const path = require('path');
4
- const fmt = require('./fmt');
5
- const { loadAdapterConfig, resolveTaskStorage } = require('./product-config');
6
-
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.SUPPORTED_VERIFY_AREAS = void 0;
40
+ exports.missionBaseDir = missionBaseDir;
41
+ exports.missionUsesYearTier = missionUsesYearTier;
42
+ exports.missionBranchPrefix = missionBranchPrefix;
43
+ exports.missionBranchName = missionBranchName;
44
+ exports.missionBranchRef = missionBranchRef;
45
+ exports.isMissionSlugCandidate = isMissionSlugCandidate;
46
+ exports.extractSlugFromBranch = extractSlugFromBranch;
47
+ exports.getPrimaryBranch = getPrimaryBranch;
48
+ exports.resolveMainRepo = resolveMainRepo;
49
+ exports.getPrimaryWorktree = getPrimaryWorktree;
50
+ exports.conventionalWorktreePath = conventionalWorktreePath;
51
+ exports.conventionalBaseWorktreePath = conventionalBaseWorktreePath;
52
+ exports.detectLaunchBaseBranch = detectLaunchBaseBranch;
53
+ exports.parseBaseBranchLine = parseBaseBranchLine;
54
+ exports.readRecordedBaseBranch = readRecordedBaseBranch;
55
+ exports.resolveMissionBaseBranch = resolveMissionBaseBranch;
56
+ exports.resolveBaseWorktree = resolveBaseWorktree;
57
+ exports.getMissionYear = getMissionYear;
58
+ exports.findMissionDir = findMissionDir;
59
+ exports.resolveWorktree = resolveWorktree;
60
+ exports.findCheckpoints = findCheckpoints;
61
+ exports.compareCheckpointFiles = compareCheckpointFiles;
62
+ exports.checkpointOrder = checkpointOrder;
63
+ exports.getFirstLine = getFirstLine;
64
+ exports.missionTitle = missionTitle;
65
+ exports.normalizeVerifyArea = normalizeVerifyArea;
66
+ exports.detectMissionAreaFromContent = detectMissionAreaFromContent;
67
+ exports.findMissionArea = findMissionArea;
68
+ exports.graphifyAvailable = graphifyAvailable;
69
+ exports.graphifyCommandCandidates = graphifyCommandCandidates;
70
+ exports.probeGraphifyAvailability = probeGraphifyAvailability;
71
+ exports.updateGraphifyKnowledgeGraph = updateGraphifyKnowledgeGraph;
72
+ exports.missionPathForSlug = missionPathForSlug;
73
+ exports.missionDirForSlug = missionDirForSlug;
74
+ exports.inferSlug = inferSlug;
75
+ exports.parseConflictFilesFromMergeOutput = parseConflictFilesFromMergeOutput;
76
+ exports.getConflictFiles = getConflictFiles;
77
+ exports.findLastNonNoiseCommit = findLastNonNoiseCommit;
78
+ exports.squashTrailingBacklogNoiseIntoPreviousMission = squashTrailingBacklogNoiseIntoPreviousMission;
79
+ exports.softResetTrailingBacklogNoise = softResetTrailingBacklogNoise;
80
+ exports.findMissionDocInBranches = findMissionDocInBranches;
81
+ exports.isMissionArtifact = isMissionArtifact;
82
+ exports.isWorkflowGeneratedArtifact = isWorkflowGeneratedArtifact;
83
+ const fs = __importStar(require("node:fs"));
84
+ const node_os_1 = __importDefault(require("node:os"));
85
+ const node_path_1 = __importDefault(require("node:path"));
86
+ const fmt = __importStar(require("./fmt.js"));
87
+ const product_config_js_1 = require("./product-config.js");
88
+ const gitModule = __importStar(require("./git.js"));
7
89
  /** @param {string} prefix */
8
90
  function normalizeBranchPrefix(prefix) {
9
- if (typeof prefix !== 'string' || !prefix.trim()) {
10
- return 'mission/';
11
- }
12
- return prefix.endsWith('/') ? prefix : `${prefix}/`;
91
+ if (typeof prefix !== 'string' || !prefix.trim()) {
92
+ return 'mission/';
93
+ }
94
+ return prefix.endsWith('/') ? prefix : prefix + '/';
13
95
  }
14
-
15
96
  function missionAdapterDefaults() {
16
- return {
17
- baseDir: 'missions',
18
- branchPrefix: 'mission/',
19
- worktreePattern: '../<repo>-<slug>',
20
- };
97
+ return {
98
+ baseDir: 'missions',
99
+ branchPrefix: 'mission/',
100
+ worktreePattern: '../<repo>-<slug>',
101
+ };
21
102
  }
22
-
23
103
  function resolveMissionAdapter(rootDir = process.cwd()) {
24
- const adapters = loadAdapterConfig(rootDir);
25
- const missions = adapters.missions || {};
26
- const defaults = missionAdapterDefaults();
27
- return {
28
- baseDir: typeof missions.baseDir === 'string' && missions.baseDir.trim()
29
- ? missions.baseDir
30
- : defaults.baseDir,
31
- branchPrefix: normalizeBranchPrefix(missions.branchPrefix || defaults.branchPrefix),
32
- worktreePattern: typeof missions.worktreePattern === 'string' && missions.worktreePattern.trim()
33
- ? missions.worktreePattern
34
- : defaults.worktreePattern,
35
- };
104
+ const adapters = (0, product_config_js_1.loadAdapterConfig)(rootDir);
105
+ const missions = adapters.missions || {};
106
+ const defaults = missionAdapterDefaults();
107
+ return {
108
+ baseDir: typeof missions.baseDir === 'string' && missions.baseDir.trim()
109
+ ? missions.baseDir
110
+ : defaults.baseDir,
111
+ branchPrefix: normalizeBranchPrefix(missions.branchPrefix || defaults.branchPrefix),
112
+ worktreePattern: typeof missions.worktreePattern === 'string' && missions.worktreePattern.trim()
113
+ ? missions.worktreePattern
114
+ : defaults.worktreePattern,
115
+ };
36
116
  }
37
-
38
117
  function missionBaseDir(rootDir = process.cwd()) {
39
- return path.resolve(rootDir, resolveMissionAdapter(rootDir).baseDir);
118
+ return node_path_1.default.resolve(rootDir, resolveMissionAdapter(rootDir).baseDir);
40
119
  }
41
-
42
120
  function missionUsesYearTier(rootDir = process.cwd()) {
43
- return resolveMissionAdapter(rootDir).baseDir !== missionAdapterDefaults().baseDir;
121
+ return resolveMissionAdapter(rootDir).baseDir !== missionAdapterDefaults().baseDir;
44
122
  }
45
-
46
123
  function missionBranchPrefix(rootDir = process.cwd()) {
47
- return resolveMissionAdapter(rootDir).branchPrefix;
124
+ return resolveMissionAdapter(rootDir).branchPrefix;
48
125
  }
49
-
50
126
  /** @param {string} slug @param {string} [rootDir] */
51
127
  function missionBranchName(slug, rootDir = process.cwd()) {
52
- return `${missionBranchPrefix(rootDir)}${slug}`;
128
+ return missionBranchPrefix(rootDir) + slug;
53
129
  }
54
-
55
130
  /** @param {string} slug @param {string} [rootDir] */
56
131
  function missionBranchRef(slug, rootDir = process.cwd()) {
57
- return `refs/heads/${missionBranchName(slug, rootDir)}`;
132
+ return 'refs/heads/' + missionBranchName(slug, rootDir);
58
133
  }
59
-
60
134
  /** @param {unknown} value */
61
135
  function isMissionSlugCandidate(value) {
62
- return typeof value === 'string' && /^(task|adhoc)-[a-z0-9][a-z0-9-]*$/i.test(value.trim());
136
+ return typeof value === 'string' && /^(task|adhoc)-[a-z0-9][a-z0-9-]*$/i.test(value.trim());
63
137
  }
64
-
65
138
  /** @param {string} branch @param {string} [rootDir] */
66
139
  function extractSlugFromBranch(branch, rootDir = process.cwd()) {
67
- const prefix = missionBranchPrefix(rootDir);
68
- if (!branch || !branch.startsWith(prefix)) {return null;}
69
- return branch.slice(prefix.length).toLowerCase();
140
+ const prefix = missionBranchPrefix(rootDir);
141
+ if (!branch || !branch.startsWith(prefix)) {
142
+ return null;
143
+ }
144
+ return branch.slice(prefix.length).toLowerCase();
70
145
  }
71
-
72
146
  /** @param {string|Function} rootDirOrGitFn @param {Function|null} [maybeGitFn] */
73
147
  function getPrimaryBranch(rootDirOrGitFn = process.cwd(), maybeGitFn = null) {
74
- const { git } = require('./git');
75
- const rootDir = typeof rootDirOrGitFn === 'function' ? process.cwd() : rootDirOrGitFn;
76
- /** @type {Function} */
77
- const runner = typeof rootDirOrGitFn === 'function' ? rootDirOrGitFn : (maybeGitFn || git);
78
- const listLocalBranches = () => {
79
- const result = runner(['-C', rootDir, 'branch', '--list', '--format=%(refname:short)', 'main', 'master']);
80
- return (result.stdout || '').split('\n').map((/** @type {string} */ b) => b.trim()).filter(Boolean);
81
- };
82
-
83
- try {
84
- const missions = loadAdapterConfig(rootDir).missions || {};
85
- if (typeof missions.primaryBranch === 'string' && missions.primaryBranch.trim()) {
86
- const configured = missions.primaryBranch.trim();
87
- const branches = listLocalBranches();
88
- if (branches.includes(configured)) {
89
- return configured;
90
- }
91
- if (configured !== 'main' && configured !== 'master') {
92
- return configured;
93
- }
94
- }
95
- } catch (_) {
96
- // fall through to git-based detection
97
- }
98
- try {
99
- const branches = listLocalBranches();
100
- if (branches.includes('main')) {return 'main';}
101
- if (branches.includes('master')) {return 'master';}
102
- } catch (_) {
103
- // fall through
104
- }
105
- throw new Error(
106
- "Could not detect primary branch. Neither 'main' nor 'master' exists as a local branch."
107
- );
148
+ const rootDir = typeof rootDirOrGitFn === 'function' ? process.cwd() : rootDirOrGitFn;
149
+ /** @type {Function} */
150
+ const runner = typeof rootDirOrGitFn === 'function' ? rootDirOrGitFn : (maybeGitFn || gitModule.git);
151
+ const listLocalBranches = () => {
152
+ const result = runner(['-C', rootDir, 'branch', '--list', '--format=%(refname:short)', 'main', 'master']);
153
+ return (result.stdout || '').split('\n').map((b) => b.trim()).filter(Boolean);
154
+ };
155
+ try {
156
+ const config = (0, product_config_js_1.loadAdapterConfig)(rootDir);
157
+ const missions = config.missions || {};
158
+ if (typeof missions.primaryBranch === 'string' && missions.primaryBranch.trim()) {
159
+ const configured = missions.primaryBranch.trim();
160
+ const branches = listLocalBranches();
161
+ if (branches.includes(configured)) {
162
+ return configured;
163
+ }
164
+ if (configured !== 'main' && configured !== 'master') {
165
+ return configured;
166
+ }
167
+ }
168
+ }
169
+ catch (_) {
170
+ // fall through to git-based detection
171
+ }
172
+ try {
173
+ const branches = listLocalBranches();
174
+ if (branches.includes('main')) {
175
+ return 'main';
176
+ }
177
+ if (branches.includes('master')) {
178
+ return 'master';
179
+ }
180
+ }
181
+ catch (_) {
182
+ // fall through
183
+ }
184
+ throw new Error("Could not detect primary branch. Neither 'main' nor 'master' exists as a local branch.");
108
185
  }
109
-
110
186
  function resolveMainRepo() {
111
- if (process.env.PRIMARY_WORKTREE) {return process.env.PRIMARY_WORKTREE;}
112
-
113
- const { git, getCurrentBranch } = require('./git');
114
- const { isStandaloneWorkflowLayout } = require('./product-config');
115
- const cwd = process.cwd();
116
- const primaryBranch = getPrimaryBranch(process.cwd(), git);
117
- try {
118
- const lines = git(['worktree', 'list', '--porcelain']).stdout.split('\n');
119
- const worktrees = [];
120
- let current = null;
121
-
122
- for (const line of lines) {
123
- if (line.startsWith('worktree ')) {
124
- if (current) {worktrees.push(current);}
125
- current = { path: line.slice('worktree '.length).trim(), branch: /** @type {string|null} */(null), bare: false };
126
- continue;
127
- }
128
-
129
- if (!current) {continue;}
130
-
131
- if (line.startsWith('branch ')) {
132
- current.branch = line.slice('branch '.length).trim();
133
- } else if (line === 'bare') {
134
- current.bare = true;
135
- } else if (line === '') {
136
- worktrees.push(current);
137
- current = null;
138
- }
139
- }
140
-
141
- if (current) {worktrees.push(current);}
142
-
143
- const primaryWorktree = worktrees.find(worktree => !worktree.bare && worktree.branch === `refs/heads/${primaryBranch}`);
144
- if (primaryWorktree) {return primaryWorktree.path;}
145
- } catch (error) {
146
- // ignore git errors, fall through to error
147
- }
148
-
149
- try {
150
- const currentBranch = getCurrentBranch(cwd);
151
- const toplevel = git(['-C', cwd, 'rev-parse', '--show-toplevel']);
152
- const repoRoot = (toplevel.stdout || '').trim();
153
- if (toplevel.status === 0 && repoRoot) {
154
- if (currentBranch === primaryBranch) {
155
- return repoRoot;
156
- }
157
-
158
- // Fresh exported repos can already have a `.git` directory but still lack
159
- // stable worktree metadata or a readable current branch. When draft runs
160
- // from that standalone repo root, treat the current checkout as the
161
- // primary repository so draft can create the first mission worktree.
162
- if (
163
- path.resolve(repoRoot) === path.resolve(cwd) &&
164
- isStandaloneWorkflowLayout(repoRoot) &&
165
- !currentBranch
166
- ) {
167
- return repoRoot;
168
- }
169
- }
170
- } catch (_) {
171
- // ignore git errors, fall through to error
172
- }
173
-
174
- throw new Error(
175
- `Could not resolve primary repository. No worktree on '${primaryBranch}' branch found and PRIMARY_WORKTREE is not set. ` +
176
- "Verify your worktree setup or set the PRIMARY_WORKTREE environment variable."
177
- );
187
+ if (process.env.PRIMARY_WORKTREE) {
188
+ return process.env.PRIMARY_WORKTREE;
189
+ }
190
+ const cwd = process.cwd();
191
+ const primaryBranch = getPrimaryBranch(process.cwd(), gitModule.git);
192
+ try {
193
+ const lines = gitModule.git(['worktree', 'list', '--porcelain']).stdout.split('\n');
194
+ const worktrees = [];
195
+ let current = null;
196
+ for (const line of lines) {
197
+ if (line.startsWith('worktree ')) {
198
+ if (current) {
199
+ worktrees.push(current);
200
+ }
201
+ current = { path: line.slice('worktree '.length).trim(), branch: null, bare: false };
202
+ continue;
203
+ }
204
+ if (!current) {
205
+ continue;
206
+ }
207
+ if (line.startsWith('branch ')) {
208
+ current.branch = line.slice('branch '.length).trim();
209
+ }
210
+ else if (line === 'bare') {
211
+ current.bare = true;
212
+ }
213
+ else if (line === '') {
214
+ worktrees.push(current);
215
+ current = null;
216
+ }
217
+ }
218
+ if (current) {
219
+ worktrees.push(current);
220
+ }
221
+ const primaryWorktree = worktrees.find(wt => !wt.bare && wt.branch === `refs/heads/${primaryBranch}`);
222
+ if (primaryWorktree) {
223
+ return primaryWorktree.path;
224
+ }
225
+ }
226
+ catch (_) {
227
+ // ignore git errors, fall through to error
228
+ }
229
+ try {
230
+ const currentBranch = gitModule.getCurrentBranch(cwd);
231
+ const toplevel = gitModule.git(['-C', cwd, 'rev-parse', '--show-toplevel']);
232
+ const repoRoot = (toplevel.stdout || '').trim();
233
+ if (toplevel.status === 0 && repoRoot) {
234
+ if (currentBranch === primaryBranch) {
235
+ return repoRoot;
236
+ }
237
+ if (node_path_1.default.resolve(repoRoot) === node_path_1.default.resolve(cwd) &&
238
+ (0, product_config_js_1.isStandaloneWorkflowLayout)(repoRoot) &&
239
+ !currentBranch) {
240
+ return repoRoot;
241
+ }
242
+ }
243
+ }
244
+ catch (_) {
245
+ // ignore git errors, fall through to error
246
+ }
247
+ throw new Error(`Could not resolve primary repository. No worktree on '${primaryBranch}' branch found and PRIMARY_WORKTREE is not set. ` +
248
+ "Verify your worktree setup or set the PRIMARY_WORKTREE environment variable.");
178
249
  }
179
-
180
250
  function getPrimaryWorktree() {
181
- return resolveMainRepo();
251
+ return resolveMainRepo();
182
252
  }
183
-
184
253
  /** @param {string} slug @param {string} [mainRepo] */
185
254
  function conventionalWorktreePath(slug, mainRepo = getPrimaryWorktree()) {
186
- const projectName = path.basename(mainRepo);
187
- const pattern = resolveMissionAdapter(mainRepo).worktreePattern;
188
- const rendered = pattern
189
- .replaceAll('<repo>', projectName)
190
- .replaceAll('<slug>', slug);
191
- return path.resolve(mainRepo, rendered);
255
+ const projectName = node_path_1.default.basename(mainRepo);
256
+ const pattern = resolveMissionAdapter(mainRepo).worktreePattern;
257
+ const rendered = pattern
258
+ .replaceAll('<repo>', projectName)
259
+ .replaceAll('<slug>', slug);
260
+ return node_path_1.default.resolve(mainRepo, rendered);
192
261
  }
193
-
194
262
  /**
195
263
  * Conventional path for an auto-created *base* feature-branch worktree.
196
264
  *
@@ -201,10 +269,9 @@ function conventionalWorktreePath(slug, mainRepo = getPrimaryWorktree()) {
201
269
  */
202
270
  /** @param {string} baseBranch @param {string} [mainRepo] */
203
271
  function conventionalBaseWorktreePath(baseBranch, mainRepo = getPrimaryWorktree()) {
204
- const safeName = String(baseBranch).replace(/[\\/]+/g, '-');
205
- return conventionalWorktreePath(`base-${safeName}`, mainRepo);
272
+ const safeName = String(baseBranch).replace(/[\\/]+/g, '-');
273
+ return conventionalWorktreePath(`base-${safeName}`, mainRepo);
206
274
  }
207
-
208
275
  /**
209
276
  * Detect the branch HEAD is on at draft time, to be used as the mission base.
210
277
  *
@@ -219,31 +286,28 @@ function conventionalBaseWorktreePath(baseBranch, mainRepo = getPrimaryWorktree(
219
286
  */
220
287
  /** @param {string} [cwd] @param {{gitFn?: Function}} [options] */
221
288
  function detectLaunchBaseBranch(cwd = process.cwd(), options = {}) {
222
- const { git } = require('./git');
223
- /** @type {Function} */
224
- const runner = options.gitFn || git;
225
- const result = runner(['-C', cwd, 'branch', '--show-current']);
226
- const branch = ((result && result.stdout) || '').trim();
227
- if (!branch) {
228
- return null;
229
- }
230
- const prefix = missionBranchPrefix(cwd);
231
- if (branch.startsWith(prefix)) {
232
- throw new Error(
233
- `Cannot launch a mission from mission branch '${branch}': the '${prefix}' prefix is reserved. ` +
234
- 'Check out the feature branch or primary branch you want as the base before running draft.'
235
- );
236
- }
237
- return branch;
289
+ /** @type {Function} */
290
+ const runner = options.gitFn || gitModule.git;
291
+ const result = runner(['-C', cwd, 'branch', '--show-current']);
292
+ const branch = ((result && result.stdout) || '').trim();
293
+ if (!branch) {
294
+ return null;
295
+ }
296
+ const prefix = missionBranchPrefix(cwd);
297
+ if (branch.startsWith(prefix)) {
298
+ throw new Error(`Cannot launch a mission from mission branch '${branch}': the '${prefix}' prefix is reserved. ` +
299
+ 'Check out the feature branch or primary branch you want as the base before running draft.');
300
+ }
301
+ return branch;
238
302
  }
239
-
240
303
  /** @param {string} content */
241
304
  function parseBaseBranchLine(content) {
242
- if (!content) {return null;}
243
- const match = content.match(/^Base-Branch:\s*(\S+)\s*$/m);
244
- return match ? match[1].trim() : null;
305
+ if (!content) {
306
+ return null;
307
+ }
308
+ const match = content.match(/^Base-Branch:\s*(\S+)\s*$/m);
309
+ return match ? match[1].trim() : null;
245
310
  }
246
-
247
311
  /**
248
312
  * Read the `Base-Branch:` line recorded in the mission's MISSION.md.
249
313
  *
@@ -251,48 +315,47 @@ function parseBaseBranchLine(content) {
251
315
  * is not on disk, falls back to reading it from the mission branch via
252
316
  * `git show <branch>:<path>`. Returns `null` when no `Base-Branch:` line exists
253
317
  * (every pre-existing mission), so callers fall back to the primary branch.
254
- */
318
+ */
255
319
  /** @param {string} slug @param {string} [rootDir] @param {{gitFn?: Function}} [options] */
256
320
  function readRecordedBaseBranch(slug, rootDir = process.cwd(), options = {}) {
257
- /** @type {Function | null} */
258
- const gitFn = options.gitFn ?? null;
259
- if (!slug) {return null;}
260
-
261
- const missionDir = findMissionDir(slug, rootDir);
262
- if (missionDir) {
263
- const missionPath = path.join(missionDir, 'MISSION.md');
264
- if (fs.existsSync(missionPath)) {
265
- return parseBaseBranchLine(fs.readFileSync(missionPath, 'utf8'));
266
- }
267
- }
268
-
269
- const { git } = require('./git');
270
- const runner = gitFn || git;
271
- const branch = missionBranchName(slug, rootDir);
272
- const baseSlugMatch = slug.match(/^(task-\d+)/i);
273
- const baseSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : slug;
274
- const year = getMissionYear(slug, rootDir);
275
- const adapterBaseDir = (path.relative(rootDir, missionBaseDir(rootDir)) || '.').split(path.sep).join('/');
276
- const paths = Array.from(new Set([
277
- path.posix.join(adapterBaseDir, year, slug, 'MISSION.md'),
278
- path.posix.join(adapterBaseDir, year, baseSlug, 'MISSION.md')
279
- ]));
280
-
281
- for (const p of paths) {
282
- try {
283
- const res = runner(['-C', rootDir, 'show', `${branch}:${p}`]);
284
- if (res && res.status === 0) {
285
- const parsed = parseBaseBranchLine(res.stdout);
286
- if (parsed) {return parsed;}
287
- }
288
- } catch (_) {
289
- // ignore and try the next candidate
290
- }
291
- }
292
-
293
- return null;
321
+ /** @type {Function | null} */
322
+ const gitFn = options.gitFn ?? null;
323
+ if (!slug) {
324
+ return null;
325
+ }
326
+ const missionDir = findMissionDir(slug, rootDir);
327
+ if (missionDir) {
328
+ const missionPath = node_path_1.default.join(missionDir, 'MISSION.md');
329
+ if (fs.existsSync(missionPath)) {
330
+ return parseBaseBranchLine(fs.readFileSync(missionPath, 'utf8'));
331
+ }
332
+ }
333
+ const runner = gitFn || gitModule.git;
334
+ const branch = missionBranchName(slug, rootDir);
335
+ const baseSlugMatch = slug.match(/^(task-\d+)/i);
336
+ const baseSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : slug;
337
+ const year = getMissionYear(slug, rootDir);
338
+ const adapterBaseDir = (node_path_1.default.relative(rootDir, missionBaseDir(rootDir)) || '.').split(node_path_1.default.sep).join('/');
339
+ const files = Array.from(new Set([
340
+ node_path_1.default.posix.join(adapterBaseDir, year, slug, 'MISSION.md'),
341
+ node_path_1.default.posix.join(adapterBaseDir, year, baseSlug, 'MISSION.md')
342
+ ]));
343
+ for (const f of files) {
344
+ try {
345
+ const res = runner(['-C', rootDir, 'show', `${branch}:${f}`]);
346
+ if (res && res.status === 0) {
347
+ const parsed = parseBaseBranchLine(res.stdout);
348
+ if (parsed) {
349
+ return parsed;
350
+ }
351
+ }
352
+ }
353
+ catch (_) {
354
+ // ignore and try the next candidate
355
+ }
356
+ }
357
+ return null;
294
358
  }
295
-
296
359
  /**
297
360
  * Resolve the base branch a mission was drafted from.
298
361
  *
@@ -301,34 +364,41 @@ function readRecordedBaseBranch(slug, rootDir = process.cwd(), options = {}) {
301
364
  */
302
365
  /** @param {string} slug @param {string} [rootDir] @param {{gitFn?: Function}} [options] */
303
366
  function resolveMissionBaseBranch(slug, rootDir = process.cwd(), options = {}) {
304
- /** @type {Function | null} */
305
- const gitFn = options.gitFn ?? null;
306
- const recorded = readRecordedBaseBranch(slug, rootDir, { gitFn: /** @type {Function|undefined} */(gitFn) });
307
- if (recorded) {return recorded;}
308
- return gitFn ? getPrimaryBranch(rootDir, gitFn) : getPrimaryBranch(rootDir);
367
+ /** @type {Function | null} */
368
+ const gitFn = options.gitFn ?? null;
369
+ const recorded = readRecordedBaseBranch(slug, rootDir, { gitFn: gitFn });
370
+ if (recorded) {
371
+ return recorded;
372
+ }
373
+ return gitFn ? getPrimaryBranch(rootDir, gitFn) : getPrimaryBranch(rootDir);
309
374
  }
310
-
311
375
  /** @param {string} branchRef @param {Function} runner @param {string} mainRepo */
312
376
  function findWorktreeForBranch(branchRef, runner, mainRepo) {
313
- const result = runner(['-C', mainRepo, 'worktree', 'list', '--porcelain']);
314
- const lines = ((result && result.stdout) || '').split('\n');
315
- let current = null;
316
- for (const line of lines) {
317
- if (line.startsWith('worktree ')) {
318
- current = { path: line.slice('worktree '.length).trim(), branch: null };
319
- } else if (!current) {
320
- continue;
321
- } else if (line.startsWith('branch ')) {
322
- current.branch = line.slice('branch '.length).trim();
323
- } else if (line === '') {
324
- if (current.branch === branchRef) {return current.path;}
325
- current = null;
326
- }
327
- }
328
- if (current && current.branch === branchRef) {return current.path;}
329
- return null;
377
+ const result = runner(['-C', mainRepo, 'worktree', 'list', '--porcelain']);
378
+ const lines = ((result && result.stdout) || '').split('\n');
379
+ let current = null;
380
+ for (const line of lines) {
381
+ if (line.startsWith('worktree ')) {
382
+ current = { path: line.slice('worktree '.length).trim(), branch: null };
383
+ }
384
+ else if (!current) {
385
+ continue;
386
+ }
387
+ else if (line.startsWith('branch ')) {
388
+ current.branch = line.slice('branch '.length).trim();
389
+ }
390
+ else if (line === '') {
391
+ if (current.branch === branchRef) {
392
+ return current.path;
393
+ }
394
+ current = null;
395
+ }
396
+ }
397
+ if (current && current.branch === branchRef) {
398
+ return current.path;
399
+ }
400
+ return null;
330
401
  }
331
-
332
402
  /**
333
403
  * Resolve the worktree the mission integrates back into.
334
404
  *
@@ -340,376 +410,336 @@ function findWorktreeForBranch(branchRef, runner, mainRepo) {
340
410
  */
341
411
  /** @param {string} slug @param {{rootDir?: string, gitFn?: Function}} [options] */
342
412
  function resolveBaseWorktree(slug, options = {}) {
343
- /** @type {string} */
344
- const rootDir = options.rootDir || process.cwd();
345
- /** @type {Function | null} */
346
- const gitFn = options.gitFn ?? null;
347
- const { git } = require('./git');
348
- const runner = gitFn || git;
349
- const base = resolveMissionBaseBranch(slug, rootDir, { gitFn: /** @type {Function|undefined} */(gitFn) });
350
- const primary = gitFn ? getPrimaryBranch(rootDir, gitFn) : getPrimaryBranch(rootDir);
351
- if (base === primary) {
352
- return getPrimaryWorktree();
353
- }
354
-
355
- const mainRepo = getPrimaryWorktree();
356
- const baseRef = `refs/heads/${base}`;
357
-
358
- const existing = findWorktreeForBranch(baseRef, runner, mainRepo);
359
- if (existing) {return existing;}
360
-
361
- const branchExists = runner(['-C', mainRepo, 'show-ref', '--verify', '--quiet', baseRef]);
362
- if (!branchExists || branchExists.status !== 0) {
363
- throw new Error(
364
- `Mission ${slug} records base branch '${base}' but it does not exist locally. ` +
365
- `Create or fetch the '${base}' base branch before integrating.`
366
- );
367
- }
368
-
369
- const worktreePath = conventionalBaseWorktreePath(base, mainRepo);
370
- const addResult = runner(['-C', mainRepo, 'worktree', 'add', worktreePath, base]);
371
- if (!addResult || addResult.status !== 0) {
372
- const detail = addResult ? [addResult.stdout, addResult.stderr].filter(Boolean).join('\n').trim() : '';
373
- throw new Error(
374
- `Could not create base worktree for base branch '${base}' at ${worktreePath}${detail ? `: ${detail}` : '.'}`
375
- );
376
- }
377
- return worktreePath;
413
+ const rootDir = options.rootDir || process.cwd();
414
+ /** @type {Function | null} */
415
+ const gitFn = options.gitFn ?? null;
416
+ const runner = gitFn || gitModule.git;
417
+ const base = resolveMissionBaseBranch(slug, rootDir, { gitFn: gitFn });
418
+ const primary = gitFn ? getPrimaryBranch(rootDir, gitFn) : getPrimaryBranch(rootDir);
419
+ if (base === primary) {
420
+ return getPrimaryWorktree();
421
+ }
422
+ const mainRepo = getPrimaryWorktree();
423
+ const baseRef = `refs/heads/${base}`;
424
+ const existing = findWorktreeForBranch(baseRef, runner, mainRepo);
425
+ if (existing) {
426
+ return existing;
427
+ }
428
+ const branchExists = runner(['-C', mainRepo, 'show-ref', '--verify', '--quiet', baseRef]);
429
+ if (!branchExists || branchExists.status !== 0) {
430
+ throw new Error(`Mission ${slug} records base branch '${base}' but it does not exist locally. ` +
431
+ `Create or fetch the '${base}' base branch before integrating.`);
432
+ }
433
+ const worktreePath = conventionalBaseWorktreePath(base, mainRepo);
434
+ const addResult = runner(['-C', mainRepo, 'worktree', 'add', worktreePath, base]);
435
+ if (!addResult || addResult.status !== 0) {
436
+ const detail = addResult ? [addResult.stdout, addResult.stderr].filter(Boolean).join('\n').trim() : '';
437
+ throw new Error(`Could not create base worktree for base branch '${base}' at ${worktreePath}${detail ? ': ' + detail : '.'}`);
438
+ }
439
+ return worktreePath;
378
440
  }
379
-
380
441
  /** @param {string|undefined} [slug] @param {string} [rootDir] */
381
442
  function getMissionYear(slug = undefined, rootDir = process.cwd()) {
382
- if (process.env.MISSION_YEAR_OVERRIDE) {
383
- return process.env.MISSION_YEAR_OVERRIDE;
384
- }
385
-
386
- // Flat default layout has no year tier. Keep returning the current year for
387
- // callers that use it as metadata, but do not scan it as a path component.
388
- const hasExplicitConfig = fs.existsSync(path.join(rootDir, 'workflow.config.json'));
389
- if (slug && (missionUsesYearTier(rootDir) || (!hasExplicitConfig && fs.existsSync(path.join(rootDir, 'docs', 'missions'))))) {
390
- const baseDir = missionUsesYearTier(rootDir)
391
- ? missionBaseDir(rootDir)
392
- : path.join(rootDir, 'docs', 'missions');
393
- if (fs.existsSync(baseDir)) {
394
- try {
395
- const stat = fs.statSync(baseDir);
396
- if (!stat.isDirectory()) {
397
- return new Date().getFullYear().toString();
398
- }
399
- } catch (_) {
400
- return new Date().getFullYear().toString();
401
- }
402
- const years = fs.readdirSync(baseDir)
403
- .filter(d => /^\d{4}$/.test(d))
404
- .sort((/** @type {string} */ a, /** @type {string} */ b) => b.localeCompare(a)); // Descending order (newest first)
405
-
406
- /** @type {string} */
407
- const slugStr = slug;
408
- const candidateSlugs = [slugStr.toLowerCase()];
409
- const baseTaskMatch = slugStr.match(/^(task-\d+)/i);
410
- if (baseTaskMatch) {
411
- candidateSlugs.push(baseTaskMatch[1].toLowerCase());
412
- }
413
-
414
- for (const year of years) {
415
- for (const s of candidateSlugs) {
416
- const missionDir = path.join(baseDir, year, s);
417
- if (fs.existsSync(missionDir)) {
418
- return year;
419
- }
443
+ if (process.env.MISSION_YEAR_OVERRIDE) {
444
+ return process.env.MISSION_YEAR_OVERRIDE;
445
+ }
446
+ const hasExplicitConfig = fs.existsSync(node_path_1.default.join(rootDir, 'workflow.config.json'));
447
+ if (slug && (missionUsesYearTier(rootDir) || (!hasExplicitConfig && fs.existsSync(node_path_1.default.join(rootDir, 'docs', 'missions'))))) {
448
+ const baseDir = missionUsesYearTier(rootDir)
449
+ ? missionBaseDir(rootDir)
450
+ : node_path_1.default.join(rootDir, 'docs', 'missions');
451
+ if (fs.existsSync(baseDir)) {
452
+ try {
453
+ const stat = fs.statSync(baseDir);
454
+ if (!stat.isDirectory()) {
455
+ return new Date().getFullYear().toString();
456
+ }
457
+ }
458
+ catch (_) {
459
+ return new Date().getFullYear().toString();
460
+ }
461
+ const years = fs.readdirSync(baseDir)
462
+ .filter(d => /^\d{4}$/.test(d))
463
+ .sort((a, b) => b.localeCompare(a));
464
+ const slugStr = slug;
465
+ const candidateSlugs = [slugStr.toLowerCase()];
466
+ const baseTaskMatch = slugStr.match(/^(task-\d+)/i);
467
+ if (baseTaskMatch) {
468
+ candidateSlugs.push(baseTaskMatch[1].toLowerCase());
469
+ }
470
+ for (const year of years) {
471
+ for (const s of candidateSlugs) {
472
+ const missionDir = node_path_1.default.join(baseDir, year, s);
473
+ if (fs.existsSync(missionDir)) {
474
+ return year;
475
+ }
476
+ }
477
+ }
420
478
  }
421
- }
422
479
  }
423
- }
424
-
425
- return new Date().getFullYear().toString();
480
+ return new Date().getFullYear().toString();
426
481
  }
427
-
428
482
  /** @param {string} slug @param {string} [rootDir] @param {{missionPath?: string}} options */
429
483
  function findMissionDir(slug, rootDir = process.cwd(), options = {}) {
430
- /** @type {{missionPath?: string}} */
431
- const opts = options;
432
- if (opts.missionPath && fs.existsSync(opts.missionPath)) {
433
- return fs.statSync(opts.missionPath).isDirectory() ? opts.missionPath : path.dirname(opts.missionPath);
434
- }
435
- if (!slug) {return null;}
436
- const missionDir = missionDirForSlug(rootDir, slug);
437
- if (fs.existsSync(missionDir)) {return missionDir;}
438
-
439
- // Hardening: Try base task ID if slug has a suffix (e.g., task-1004-modern -> task-1004)
440
- const baseTaskMatch = slug.match(/^(task-\d+)/i);
441
- if (baseTaskMatch) {
442
- const baseSlug = baseTaskMatch[1].toLowerCase();
443
- const baseMissionDir = missionDirForSlug(rootDir, baseSlug);
444
- if (fs.existsSync(baseMissionDir)) {return baseMissionDir;}
445
- }
446
-
447
- // Read compatibility during the TASK-1243 migration window. New no-config
448
- // missions are created flat, but existing year-tier history remains readable.
449
- const legacyBaseDir = path.join(rootDir, 'docs', 'missions');
450
- if (!missionUsesYearTier(rootDir) && !fs.existsSync(path.join(rootDir, 'workflow.config.json')) && fs.existsSync(legacyBaseDir)) {
451
- const year = getMissionYear(slug, rootDir);
452
- const legacyMissionDir = path.join(legacyBaseDir, year, slug);
453
- if (fs.existsSync(legacyMissionDir)) {return legacyMissionDir;}
484
+ const opts = options;
485
+ if (opts.missionPath && fs.existsSync(opts.missionPath)) {
486
+ return fs.statSync(opts.missionPath).isDirectory() ? opts.missionPath : node_path_1.default.dirname(opts.missionPath);
487
+ }
488
+ if (!slug) {
489
+ return null;
490
+ }
491
+ const missionDir = missionDirForSlug(rootDir, slug);
492
+ if (fs.existsSync(missionDir)) {
493
+ return missionDir;
494
+ }
495
+ const baseTaskMatch = slug.match(/^(task-\d+)/i);
454
496
  if (baseTaskMatch) {
455
- const legacyBaseMissionDir = path.join(legacyBaseDir, year, baseTaskMatch[1].toLowerCase());
456
- if (fs.existsSync(legacyBaseMissionDir)) {return legacyBaseMissionDir;}
497
+ const baseSlug = baseTaskMatch[1].toLowerCase();
498
+ const baseMissionDir = missionDirForSlug(rootDir, baseSlug);
499
+ if (fs.existsSync(baseMissionDir)) {
500
+ return baseMissionDir;
501
+ }
457
502
  }
458
- }
459
-
460
- return null;
503
+ const legacyBaseDir = node_path_1.default.join(rootDir, 'docs', 'missions');
504
+ if (!missionUsesYearTier(rootDir) && !fs.existsSync(node_path_1.default.join(rootDir, 'workflow.config.json')) && fs.existsSync(legacyBaseDir)) {
505
+ const year = getMissionYear(slug, rootDir);
506
+ const legacyMissionDir = node_path_1.default.join(legacyBaseDir, year, slug);
507
+ if (fs.existsSync(legacyMissionDir)) {
508
+ return legacyMissionDir;
509
+ }
510
+ if (baseTaskMatch) {
511
+ const legacyBaseMissionDir = node_path_1.default.join(legacyBaseDir, year, baseTaskMatch[1].toLowerCase());
512
+ if (fs.existsSync(legacyBaseMissionDir)) {
513
+ return legacyBaseMissionDir;
514
+ }
515
+ }
516
+ }
517
+ return null;
461
518
  }
462
-
463
519
  /** @param {string} slug @param {{cwd?: string, gitFn?: Function}} [options] */
464
520
  function resolveWorktree(slug, options = {}) {
465
- /** @type {string} */
466
- const cwd = options.cwd || process.cwd();
467
- /** @type {Function | null} */
468
- const gitFn = options.gitFn ?? null;
469
- const { git, getCurrentBranch } = require('./git');
470
- const runGit = gitFn || git;
471
- const branchRef = missionBranchRef(slug, cwd);
472
-
473
- try {
474
- const lines = runGit(['worktree', 'list', '--porcelain']).stdout.split('\n');
475
- const matches = [];
476
- let current = null;
477
-
478
- for (const line of lines) {
479
- if (line.startsWith('worktree ')) {
480
- current = { path: line.slice('worktree '.length).trim(), branch: null, prunable: false };
481
- continue;
482
- }
483
- if (!current) {continue;}
484
- if (line.startsWith('branch ')) {
485
- current.branch = line.slice('branch '.length).trim();
486
- } else if (line.startsWith('prunable ')) {
487
- current.prunable = true;
488
- } else if (line === '') {
489
- if (current.branch === branchRef) {matches.push(current);}
490
- current = null;
491
- }
492
- }
493
-
494
- if (current && current.branch === branchRef) {matches.push(current);}
495
-
496
- const liveMatches = matches.filter(match => !match.prunable);
497
- if (liveMatches.length > 0) {
498
- const cwdMatch = liveMatches.find(match => cwd === match.path || cwd.startsWith(`${match.path}/`));
499
- if (cwdMatch) {return cwdMatch.path;}
500
- return liveMatches[0].path;
501
- }
502
- } catch (_) {
503
- // fall through to branch-based cwd fallback
504
- }
505
-
506
- try {
507
- if (getCurrentBranch(cwd) === missionBranchName(slug, cwd)) {
508
- return cwd;
509
- }
510
- } catch (_) {
511
- // fall through to null
512
- }
513
-
514
- return null;
521
+ const cwd = options.cwd || process.cwd();
522
+ /** @type {Function | null} */
523
+ const gitFn = options.gitFn ?? null;
524
+ const runGit = gitFn || gitModule.git;
525
+ const branchRef = missionBranchRef(slug, cwd);
526
+ try {
527
+ const lines = runGit(['worktree', 'list', '--porcelain']).stdout.split('\n');
528
+ const matches = [];
529
+ let current = null;
530
+ for (const line of lines) {
531
+ if (line.startsWith('worktree ')) {
532
+ current = { path: line.slice('worktree '.length).trim(), branch: null, prunable: false };
533
+ continue;
534
+ }
535
+ if (!current) {
536
+ continue;
537
+ }
538
+ if (line.startsWith('branch ')) {
539
+ current.branch = line.slice('branch '.length).trim();
540
+ }
541
+ else if (line.startsWith('prunable ')) {
542
+ current.prunable = true;
543
+ }
544
+ else if (line === '') {
545
+ if (current.branch === branchRef) {
546
+ matches.push(current);
547
+ }
548
+ current = null;
549
+ }
550
+ }
551
+ if (current && current.branch === branchRef) {
552
+ matches.push(current);
553
+ }
554
+ const liveMatches = matches.filter(m => !m.prunable);
555
+ if (liveMatches.length > 0) {
556
+ const cwdMatch = liveMatches.find(m => cwd === m.path || cwd.startsWith(m.path + '/'));
557
+ if (cwdMatch) {
558
+ return cwdMatch.path;
559
+ }
560
+ return liveMatches[0].path;
561
+ }
562
+ }
563
+ catch (_) {
564
+ // fall through to branch-based cwd fallback
565
+ }
566
+ try {
567
+ if (gitModule.getCurrentBranch(cwd) === missionBranchName(slug, cwd)) {
568
+ return cwd;
569
+ }
570
+ }
571
+ catch (_) {
572
+ // fall through to null
573
+ }
574
+ return null;
515
575
  }
516
-
517
576
  /** @param {string} missionDir */
518
577
  function findCheckpoints(missionDir) {
519
- const files = fs.readdirSync(missionDir);
520
- return files
521
- .filter(f => /^(CHECKPOINT_|CP-\d+).*\.md$/i.test(f))
522
- .sort(compareCheckpointFiles)
523
- .map(f => path.join(missionDir, f));
578
+ const files = fs.readdirSync(missionDir);
579
+ return files
580
+ .filter(f => /^(CHECKPOINT_|CP-\d+).*\.md$/i.test(f))
581
+ .sort(compareCheckpointFiles)
582
+ .map(f => node_path_1.default.join(missionDir, f));
524
583
  }
525
-
526
584
  /** @param {string} a @param {string} b */
527
585
  function compareCheckpointFiles(a, b) {
528
- const aOrder = checkpointOrder(a);
529
- const bOrder = checkpointOrder(b);
530
-
531
- if (aOrder !== bOrder) {
532
- return aOrder - bOrder;
533
- }
534
-
535
- return a.localeCompare(b);
586
+ const aOrder = checkpointOrder(a);
587
+ const bOrder = checkpointOrder(b);
588
+ if (aOrder !== bOrder) {
589
+ return aOrder - bOrder;
590
+ }
591
+ return a.localeCompare(b);
536
592
  }
537
-
538
593
  /** @param {string} filename */
539
594
  function checkpointOrder(filename) {
540
- const numericMatch = filename.match(/(?:CP-|CHECKPOINT_)(\d+)/i);
541
- if (numericMatch) {
542
- return Number(numericMatch[1]);
543
- }
544
-
545
- return Number.MAX_SAFE_INTEGER;
595
+ const numericMatch = filename.match(/(?:CP-|CHECKPOINT_)(\d+)/i);
596
+ if (numericMatch) {
597
+ return Number(numericMatch[1]);
598
+ }
599
+ return Number.MAX_SAFE_INTEGER;
546
600
  }
547
-
548
601
  /** @param {string} filePath */
549
602
  function getFirstLine(filePath) {
550
- const content = fs.readFileSync(filePath, 'utf8');
551
- return content.split('\n')[0].replace(/^#+\s*/, '').trim();
603
+ const content = fs.readFileSync(filePath, 'utf8');
604
+ return content.split('\n')[0].replace(/^#+\s*/, '').trim();
552
605
  }
553
-
554
606
  /** @param {string} [slug] */
555
607
  function missionTitle(slug) {
556
- if (!slug) {return null;}
557
- const missionDir = findMissionDir(slug);
558
- if (!missionDir) {return null;}
559
-
560
- const missionPath = path.join(missionDir, 'MISSION.md');
561
- if (!fs.existsSync(missionPath)) {return null;}
562
-
563
- const firstLine = fs.readFileSync(missionPath, 'utf8').split('\n')[0] || '';
564
- return firstLine.replace(/^#\s*Mission:\s*/i, '').trim() || null;
608
+ if (!slug) {
609
+ return null;
610
+ }
611
+ const missionDir = findMissionDir(slug);
612
+ if (!missionDir) {
613
+ return null;
614
+ }
615
+ const missionPath = node_path_1.default.join(missionDir, 'MISSION.md');
616
+ if (!fs.existsSync(missionPath)) {
617
+ return null;
618
+ }
619
+ const firstLine = fs.readFileSync(missionPath, 'utf8').split('\n')[0] || '';
620
+ return firstLine.replace(/^#\s*Mission:\s*/i, '').trim() || null;
565
621
  }
566
-
567
- const SUPPORTED_VERIFY_AREAS = new Set(['docs', 'workflow', 'web', 'server', 'auth', 'android', 'k8s', 'deps', 'all']);
568
-
622
+ exports.SUPPORTED_VERIFY_AREAS = new Set(['docs', 'workflow', 'web', 'server', 'auth', 'android', 'k8s', 'deps', 'all']);
569
623
  /** @param {string} [area] */
570
624
  function normalizeVerifyArea(area) {
571
- if (!area) {return 'docs';}
572
- if (area === 'auth-server') {return 'auth';}
573
- return SUPPORTED_VERIFY_AREAS.has(area) ? area : area;
625
+ if (!area) {
626
+ return 'docs';
627
+ }
628
+ if (area === 'auth-server') {
629
+ return 'auth';
630
+ }
631
+ return exports.SUPPORTED_VERIFY_AREAS.has(area) ? area : area;
574
632
  }
575
-
576
633
  /** @param {string} content */
577
634
  function detectMissionAreaFromContent(content) {
578
- // Capture the area argument after a verification-script invocation. Matches only
579
- // scripts with recognized extensions (.sh, .bash, .py, .rb) or bare executables
580
- // (no dots) referenced via `./` or `../`. The area argument must sit at end-of-line
581
- // to prevent prose-matched paths (e.g. "run ./scripts/deploy.sh server before merge")
582
- // from being mistaken for gate invocations. Falls back to `docs` when none found.
583
- const gateMatch = content.match(
584
- /(?:^|\s)(?:\.{1,2}\/[\w.\/-]+\.(?:sh|bash|py|rb)|\.{1,2}\/[a-z][\w-]*)\s+([a-zA-Z0-9_-]+)\s*(?:$|\n)/m
585
- );
586
- return normalizeVerifyArea(gateMatch ? gateMatch[1] : 'docs');
635
+ const gateMatch = content.match(/(?:^|\s)(?:\.{1,2}\/[\w.\/-]+\.(?:sh|bash|py|rb)|\.{1,2}\/[a-z][\w-]*)\s+([a-zA-Z0-9_-]+)\s*(?:$|\n)/m);
636
+ return normalizeVerifyArea(gateMatch ? gateMatch[1] : 'docs');
587
637
  }
588
-
589
- function findMissionArea(/** @type {string} */ missionDir) {
590
- const missionPath = path.join(missionDir, 'MISSION.md');
591
- if (!fs.existsSync(missionPath)) {return 'docs';}
592
-
593
- const content = fs.readFileSync(missionPath, 'utf8');
594
- return detectMissionAreaFromContent(content);
638
+ function findMissionArea(missionDir) {
639
+ const missionPath = node_path_1.default.join(missionDir, 'MISSION.md');
640
+ if (!fs.existsSync(missionPath)) {
641
+ return 'docs';
642
+ }
643
+ const content = fs.readFileSync(missionPath, 'utf8');
644
+ return detectMissionAreaFromContent(content);
595
645
  }
596
-
597
646
  /** @param {{commandRunner?: Function}} [options] */
598
647
  function graphifyAvailable(options = {}) {
599
- /** @type {Function | null} */
600
- const commandRunner = options.commandRunner ?? null;
601
- const run = commandRunner || require('./git').run;
602
- return probeGraphifyAvailability({ commandRunner: run }).available;
648
+ const commandRunner = options.commandRunner ?? null;
649
+ const cmdRunner = commandRunner || gitModule.run;
650
+ return probeGraphifyAvailability({ commandRunner: cmdRunner }).available;
603
651
  }
604
-
605
652
  /** @returns {string[]} */
606
653
  function graphifyCommandCandidates() {
607
- /** @type {string[]} */
608
- const candidates = [];
609
- const seen = new Set();
610
-
611
- /** @param {string} candidate */
612
- const pushCandidate = candidate => {
613
- if (!candidate || seen.has(candidate)) {return;}
614
- seen.add(candidate);
615
- candidates.push(candidate);
616
- };
617
-
618
- if (process.env.GRAPHIFY_BIN) {pushCandidate(process.env.GRAPHIFY_BIN);}
619
- pushCandidate('graphify');
620
- pushCandidate(path.join(os.homedir(), '.local', 'bin', 'graphify'));
621
-
622
- return candidates;
654
+ const candidates = [];
655
+ const seen = new Set();
656
+ const pushCandidate = (candidate) => {
657
+ if (!candidate || seen.has(candidate)) {
658
+ return;
659
+ }
660
+ seen.add(candidate);
661
+ candidates.push(candidate);
662
+ };
663
+ if (process.env.GRAPHIFY_BIN) {
664
+ pushCandidate(process.env.GRAPHIFY_BIN);
665
+ }
666
+ pushCandidate('graphify');
667
+ pushCandidate(node_path_1.default.join(node_os_1.default.homedir(), '.local', 'bin', 'graphify'));
668
+ return candidates;
623
669
  }
624
-
625
670
  /** @param {{commandRunner?: Function}} [options] */
626
671
  function probeGraphifyAvailability(options = {}) {
627
- /** @type {Function | null} */
628
- const commandRunner = options.commandRunner ?? null;
629
- const run = commandRunner || require('./git').run;
630
- for (const command of graphifyCommandCandidates()) {
631
- try {
632
- const result = run(command, ['--help']);
633
- return {
634
- available: result.status !== null && result.status !== undefined,
635
- command,
636
- status: result.status ?? null
637
- };
638
- } catch (/** @type {unknown} */ error) {
639
- /** @type {Error & {code?: string}} */
640
- const e = /** @type {Error & {code?: string}} */(error);
641
- if (e && e.code === 'ENOENT') {
642
- continue;
643
- }
644
- return {
645
- available: false,
646
- reason: 'probe-failed',
647
- command,
648
- error
649
- };
650
- }
651
- }
652
-
653
- return { available: false, reason: 'missing-command' };
672
+ const commandRunner = options.commandRunner ?? null;
673
+ const cmdRunner = commandRunner || gitModule.run;
674
+ for (const command of graphifyCommandCandidates()) {
675
+ try {
676
+ const result = cmdRunner(command, ['--help']);
677
+ return {
678
+ available: result.status !== null && result.status !== undefined,
679
+ command,
680
+ status: result.status ?? null
681
+ };
682
+ }
683
+ catch (error) {
684
+ const e = error;
685
+ if (e && e.code === 'ENOENT') {
686
+ continue;
687
+ }
688
+ return {
689
+ available: false,
690
+ reason: 'probe-failed',
691
+ command,
692
+ error
693
+ };
694
+ }
695
+ }
696
+ return { available: false, reason: 'missing-command' };
654
697
  }
655
-
656
698
  /**
657
699
  * @param {{rootDir?: string, commandRunner?: Function, log?: Function, startMessage?: string, failureHint?: string}} [options]
658
700
  */
701
+ /** @param {{rootDir?: string, commandRunner?: Function, log?: Function, startMessage?: string, failureHint?: string}} [options] */
659
702
  function updateGraphifyKnowledgeGraph(options = {}) {
660
- /** @type {string} */
661
- const rootDir = options.rootDir || process.cwd();
662
- /** @type {Function | undefined} */
663
- const commandRunner = options.commandRunner;
664
- /** @type {Function} */
665
- const log = options.log || fmt.log.plain;
666
- /** @type {string} */
667
- const startMessage = options.startMessage || 'Updating graphify knowledge graph...';
668
- /** @type {string} */
669
- const failureHint = options.failureHint || 'Continuing without blocking workflow.';
670
- /** @param {string} command @param {string[]} args @param {{stdio?: string}} [opts] */
671
- const run = commandRunner || ((/** @type {string} */ command, /** @type {string[]} */ args, /** @type {{stdio?: string}} */ opts) => require('./git').run(command, args, opts));
672
- const probe = probeGraphifyAvailability({ commandRunner: run });
673
- if (!probe.available) {
674
- if (probe.reason === 'missing-command') {
675
- log(fmt.status('WARN', 'graphify not found in PATH. Skipping knowledge graph update.'));
676
- return { updated: false, skipped: true, reason: 'missing-command' };
677
- }
678
-
679
- const probeError = probe.error && typeof probe.error === 'object' && 'message' in probe.error ? /** @type{{message:string}} */(probe.error).message : 'unknown probe failure';
680
- log(fmt.status('WARN', `graphify probe failed (${probeError}). Skipping knowledge graph update.`));
681
- return { updated: false, skipped: true, reason: 'probe-failed' };
682
- }
683
-
684
- log(fmt.status('INFO', startMessage));
685
- const result = run(probe.command, ['update', '.'], {
686
- cwd: rootDir,
687
- stdio: 'inherit'
688
- });
689
-
690
- if (result.status !== 0) {
691
- log(fmt.status('WARN', `graphify update failed with status ${result.status}. ${failureHint}`));
692
- return { updated: false, skipped: true, reason: 'update-failed', status: result.status };
693
- }
694
-
695
- return { updated: true, skipped: false };
703
+ const rootDir = options.rootDir || process.cwd();
704
+ const commandRunner = options.commandRunner;
705
+ const logFn = options.log || fmt.log.plain;
706
+ const startMessage = options.startMessage || 'Updating graphify knowledge graph...';
707
+ const failureHint = options.failureHint || 'Continuing without blocking workflow.';
708
+ const cmdRunner = commandRunner || gitModule.run;
709
+ const probe = probeGraphifyAvailability({ commandRunner: cmdRunner });
710
+ if (!probe.available) {
711
+ if (probe.reason === 'missing-command') {
712
+ logFn(fmt.status('WARN', 'graphify not found in PATH. Skipping knowledge graph update.'));
713
+ return { updated: false, skipped: true, reason: 'missing-command' };
714
+ }
715
+ const probeError = probe.error && typeof probe.error === 'object' && 'message' in probe.error ? probe.error.message : 'unknown probe failure';
716
+ logFn(fmt.status('WARN', `graphify probe failed (${probeError}). Skipping knowledge graph update.`));
717
+ return { updated: false, skipped: true, reason: 'probe-failed' };
718
+ }
719
+ logFn(fmt.status('INFO', startMessage));
720
+ const result = cmdRunner(probe.command, ['update', '.'], {
721
+ cwd: rootDir,
722
+ stdio: 'inherit'
723
+ });
724
+ if (result.status !== 0) {
725
+ logFn(fmt.status('WARN', `graphify update failed with status ${result.status}. ${failureHint}`));
726
+ return { updated: false, skipped: true, reason: 'update-failed', status: result.status };
727
+ }
728
+ return { updated: true, skipped: false };
696
729
  }
697
-
698
730
  /** @param {string} rootDir @param {string} slug */
699
731
  function missionPathForSlug(rootDir, slug) {
700
- return path.join(missionDirForSlug(rootDir, slug), 'MISSION.md');
732
+ return node_path_1.default.join(missionDirForSlug(rootDir, slug), 'MISSION.md');
701
733
  }
702
-
703
734
  /** @param {string} rootDir @param {string} slug */
704
735
  function missionDirForSlug(rootDir, slug) {
705
- const parts = [missionBaseDir(rootDir)];
706
- if (missionUsesYearTier(rootDir)) {
707
- parts.push(getMissionYear(slug, rootDir));
708
- }
709
- parts.push(slug);
710
- return path.join(...parts);
736
+ const parts = [missionBaseDir(rootDir)];
737
+ if (missionUsesYearTier(rootDir)) {
738
+ parts.push(getMissionYear(slug, rootDir));
739
+ }
740
+ parts.push(slug);
741
+ return node_path_1.default.join(...parts);
711
742
  }
712
-
713
743
  /**
714
744
  * Infer mission slug from current context:
715
745
  * 1. Explicit slugCandidate (task-NNN)
@@ -722,53 +752,53 @@ function missionDirForSlug(rootDir, slug) {
722
752
  */
723
753
  /** @param {string} [slugCandidate] */
724
754
  function inferSlug(slugCandidate) {
725
- if (isMissionSlugCandidate(slugCandidate)) {
726
- return /** @type{string} */(slugCandidate).toLowerCase();
727
- }
728
-
729
- const { git, getCurrentBranch } = require('./git');
730
-
731
- // 1. Check branch
732
- try {
733
- const branch = getCurrentBranch();
734
- const fromBranch = extractSlugFromBranch(branch);
735
- if (fromBranch) {return fromBranch;}
736
- } catch (_) {
737
- // ignore
738
- }
739
-
740
- // 3. Check directory name
741
- const cwd = process.cwd();
742
- const dirName = path.basename(cwd);
743
- const dirSlugMatch = dirName.match(/((?:task|adhoc)-[a-z0-9][a-z0-9-]*)$/i);
744
- if (dirSlugMatch) {
745
- return dirSlugMatch[1].toLowerCase();
746
- }
747
-
748
-
749
- // 3. Check worktree registry
750
- try {
751
- const lines = git(['worktree', 'list', '--porcelain']).stdout.split('\n');
752
- let currentPath = null;
753
- for (const line of lines) {
754
- if (line.startsWith('worktree ')) {
755
- currentPath = line.slice('worktree '.length).trim();
756
- } else if (line.startsWith('branch ') && currentPath === cwd) {
757
- const branch = line.slice('branch '.length).trim();
758
- const shortBranch = branch.replace(/^refs\/heads\//, '');
759
- const fromBranch = extractSlugFromBranch(shortBranch, cwd);
760
- if (fromBranch) {return fromBranch;}
761
- } else if (line === '') {
762
- currentPath = null;
763
- }
764
- }
765
- } catch (_) {
766
- // ignore
767
- }
768
-
769
- return null;
755
+ if (slugCandidate && isMissionSlugCandidate(slugCandidate)) {
756
+ return slugCandidate.toLowerCase();
757
+ }
758
+ // 1. Check branch
759
+ try {
760
+ const branch = gitModule.getCurrentBranch();
761
+ const fromBranch = extractSlugFromBranch(branch);
762
+ if (fromBranch) {
763
+ return fromBranch;
764
+ }
765
+ }
766
+ catch (_) {
767
+ // ignore
768
+ }
769
+ // 3. Check directory name
770
+ const cwd = process.cwd();
771
+ const dirName = node_path_1.default.basename(cwd);
772
+ const dirSlugMatch = dirName.match(/((?:task|adhoc)-[a-z0-9][a-z0-9-]*)$/i);
773
+ if (dirSlugMatch) {
774
+ return dirSlugMatch[1].toLowerCase();
775
+ }
776
+ // 3. Check worktree registry
777
+ try {
778
+ const lines = gitModule.git(['worktree', 'list', '--porcelain']).stdout.split('\n');
779
+ let currentPath = null;
780
+ for (const line of lines) {
781
+ if (line.startsWith('worktree ')) {
782
+ currentPath = line.slice('worktree '.length).trim();
783
+ }
784
+ else if (line.startsWith('branch ') && currentPath === cwd) {
785
+ const branch = line.slice('branch '.length).trim();
786
+ const shortBranch = branch.replace(/^refs\/heads\//, '');
787
+ const fromBranch = extractSlugFromBranch(shortBranch, cwd);
788
+ if (fromBranch) {
789
+ return fromBranch;
790
+ }
791
+ }
792
+ else if (line === '') {
793
+ currentPath = null;
794
+ }
795
+ }
796
+ }
797
+ catch (_) {
798
+ // ignore
799
+ }
800
+ return null;
770
801
  }
771
-
772
802
  /**
773
803
  * Parse files with merge conflicts from the output of `git merge --no-commit --no-ff`.
774
804
  *
@@ -781,28 +811,33 @@ function inferSlug(slugCandidate) {
781
811
  */
782
812
  /** @param {string} output */
783
813
  function parseConflictFilesFromMergeOutput(output) {
784
- const seen = new Set();
785
- const files = [];
786
- for (const line of output.split('\n')) {
787
- if (!line.startsWith('CONFLICT')) {continue;}
788
- // "Merge conflict in <file>" pattern (content, add/add, rename/rename, etc.)
789
- const inMatch = line.match(/Merge conflict in (.+)$/);
790
- if (inMatch) {
791
- const f = inMatch[1].trim();
792
- if (f && !seen.has(f)) { seen.add(f); files.push(f); }
793
- continue;
794
- }
795
- // "CONFLICT (modify/delete): <file> deleted in ..." — file is right after the colon
796
- const colonIdx = line.indexOf(':');
797
- if (colonIdx !== -1) {
798
- const rest = line.slice(colonIdx + 1).trim();
799
- const f = rest.split(/\s+/)[0];
800
- if (f && !seen.has(f)) { seen.add(f); files.push(f); }
801
- }
802
- }
803
- return files;
814
+ const seen = new Set();
815
+ const files = [];
816
+ for (const line of output.split('\n')) {
817
+ if (!line.startsWith('CONFLICT')) {
818
+ continue;
819
+ }
820
+ const inMatch = line.match(/Merge conflict in (.+)$/);
821
+ if (inMatch) {
822
+ const f = inMatch[1].trim();
823
+ if (f && !seen.has(f)) {
824
+ seen.add(f);
825
+ files.push(f);
826
+ }
827
+ continue;
828
+ }
829
+ const colonIdx = line.indexOf(':');
830
+ if (colonIdx !== -1) {
831
+ const rest = line.slice(colonIdx + 1).trim();
832
+ const f = rest.split(/\s+/)[0];
833
+ if (f && !seen.has(f)) {
834
+ seen.add(f);
835
+ files.push(f);
836
+ }
837
+ }
838
+ }
839
+ return files;
804
840
  }
805
-
806
841
  /**
807
842
  * Identify files that would have merge conflicts when merging `branch` into `rootDir`.
808
843
  *
@@ -814,202 +849,166 @@ function parseConflictFilesFromMergeOutput(output) {
814
849
  * @param {{ gitRunner?: Function }} [options]
815
850
  * @returns {string[]} Relative paths of conflicting files (empty if no conflicts).
816
851
  */
817
- function getConflictFiles(rootDir, branch, { gitRunner } = {}) {
818
- const { git: defaultGit } = require('./git');
819
- const runner = gitRunner || defaultGit;
820
-
821
- const merge = runner(['-C', rootDir, 'merge', '--no-commit', '--no-ff', branch]);
822
- // Always clean up the in-progress merge (ignore abort errors for clean merges)
823
- runner(['-C', rootDir, 'merge', '--abort']);
824
-
825
- if (merge.status === 0) {return [];}
826
-
827
- const output = [merge.stdout, merge.stderr].filter(Boolean).join('\n');
828
- const conflictFiles = parseConflictFilesFromMergeOutput(output);
829
-
830
- if (conflictFiles.length === 0) {
831
- // Merge failed but no CONFLICT lines found — a non-conflict failure (dirty worktree,
832
- // unfinished rebase, locked index, etc.). Surface the raw failure rather than returning
833
- // an empty list, which callers would interpret as "no conflicts / merge was clean".
834
- const summary = output.slice(0, 500) || '(no output)';
835
- throw new Error(`git merge exited ${merge.status} with no CONFLICT lines — raw output:\n${summary}`);
836
- }
837
-
838
- return conflictFiles;
852
+ /** @param {string} rootDir @param {string} branch @param {{gitRunner?: Function}} [options] */
853
+ function getConflictFiles(rootDir, branch, options = {}) {
854
+ const runner = options.gitRunner || gitModule.git;
855
+ const merge = runner(['-C', rootDir, 'merge', '--no-commit', '--no-ff', branch]);
856
+ runner(['-C', rootDir, 'merge', '--abort']);
857
+ if (merge.status === 0) {
858
+ return [];
859
+ }
860
+ const output = [merge.stdout, merge.stderr].filter(Boolean).join('\n');
861
+ const conflictFiles = parseConflictFilesFromMergeOutput(output);
862
+ if (conflictFiles.length === 0) {
863
+ const summary = output.slice(0, 500) || '(no output)';
864
+ throw new Error(`git merge exited ${merge.status} with no CONFLICT lines — raw output:\n${summary}`);
865
+ }
866
+ return conflictFiles;
839
867
  }
840
-
841
868
  /** @param {string} rootDir @param {Function} [gitRunner] */
842
869
  function findLastNonNoiseCommit(rootDir, gitRunner) {
843
- if (!gitRunner) {gitRunner = require('./git').git;}
844
-
845
- const currentFullRef = gitRunner(['-C', rootDir, 'rev-parse', '--symbolic-full-name', 'HEAD'], { stdio: 'pipe' }).stdout.trim();
846
-
847
- let commit = 'HEAD';
848
- // safety break after 100 commits
849
- for (let i = 0; i < 100; i++) {
850
- const commitSha = gitRunner(['-C', rootDir, 'rev-parse', commit], { stdio: 'pipe' }).stdout.trim();
851
-
852
- // Safety: If any OTHER branch (local or remote) contains this commit, it's a branch-off point.
853
- // We must stop here to avoid rewriting history that other branches depend on.
854
- const branchesContaining = gitRunner(['-C', rootDir, 'branch', '-a', '--contains', commitSha, '--format=%(refname)'], { stdio: 'pipe' })
855
- .stdout.trim().split('\n').filter(Boolean);
856
-
857
- // Safety: If this commit is already published to ANY remote, we MUST NOT amend it.
858
- const isShared = branchesContaining.some((/** @type {string} */ b) => b.startsWith('refs/remotes/'));
859
- if (isShared) {
860
- return null;
861
- }
862
-
863
- // Safety: If any OTHER local branch contains this commit, it's the branch-off point.
864
- // We must return null (not the commit) so the caller does not amend a commit another
865
- // branch depends on — returning the commit here caused squash to rewrite it.
866
- const otherLocalBranches = branchesContaining.filter((/** @type {string} */ b) => b !== currentFullRef && b.startsWith('refs/heads/'));
867
- if (otherLocalBranches.length > 0) {
868
- return null;
869
- }
870
-
871
- const log = gitRunner(['-C', rootDir, 'log', '-1', '--format=%s', commit], { stdio: 'pipe' });
872
- if (log.status !== 0) {return null;}
873
- const msg = log.stdout.trim();
874
-
875
- const diff = gitRunner(['-C', rootDir, 'diff-tree', '--no-commit-id', '--name-only', '-r', commit], { stdio: 'pipe' });
876
- if (diff.status !== 0) {return null;}
877
-
878
- const files = diff.stdout.trim().split('\n').filter(Boolean);
879
- if (files.length > 0) {
880
- let isNoiseFiles = true;
881
- for (const file of files) {
882
- if (!file.startsWith('backlog/') && !file.endsWith('agents.local.json')) {
883
- isNoiseFiles = false;
884
- break;
870
+ const runner = gitRunner || gitModule.git;
871
+ const currentFullRef = runner(['-C', rootDir, 'rev-parse', '--symbolic-full-name', 'HEAD'], { stdio: 'pipe' }).stdout.trim();
872
+ let commit = 'HEAD';
873
+ for (let i = 0; i < 100; i++) {
874
+ const commitSha = runner(['-C', rootDir, 'rev-parse', commit], { stdio: 'pipe' }).stdout.trim();
875
+ const branchesContaining = runner(['-C', rootDir, 'branch', '-a', '--contains', commitSha, '--format=%(refname)'], { stdio: 'pipe' })
876
+ .stdout.trim().split('\n').filter(Boolean);
877
+ const isShared = branchesContaining.some((b) => b.startsWith('refs/remotes/'));
878
+ if (isShared) {
879
+ return null;
880
+ }
881
+ const otherLocalBranches = branchesContaining.filter((b) => b !== currentFullRef && b.startsWith('refs/heads/'));
882
+ if (otherLocalBranches.length > 0) {
883
+ return null;
884
+ }
885
+ const logResult = runner(['-C', rootDir, 'log', '-1', '--format=%s', commit], { stdio: 'pipe' });
886
+ if (logResult.status !== 0) {
887
+ return null;
888
+ }
889
+ const msg = logResult.stdout.trim();
890
+ const diffResult = runner(['-C', rootDir, 'diff-tree', '--no-commit-id', '--name-only', '-r', commit], { stdio: 'pipe' });
891
+ if (diffResult.status !== 0) {
892
+ return null;
885
893
  }
886
- }
887
-
888
- // Learnings from manual cleanup: check for automated/grooming message patterns
889
- const isNoiseMsg = /^(Create task|Update task|backlog|assign|fixes|backlig|housekeeping|Archive task|fixing tasks|new mission|added new backlog task|docs: move|mission changes|random changes|new\/updated mission|task updates)/i.test(msg);
890
-
891
- if (!isNoiseFiles || !isNoiseMsg) {
892
- return commit;
893
- }
894
- } else {
895
- // Empty commit (e.g. from a merge or manual empty commit) - stop here
896
- return commit;
897
- }
898
- commit = `${commit}^`;
899
- }
900
- return null;
894
+ const files = diffResult.stdout.trim().split('\n').filter(Boolean);
895
+ if (files.length > 0) {
896
+ let isNoiseFiles = true;
897
+ for (const file of files) {
898
+ if (!file.startsWith('backlog/') && !file.endsWith('agents.local.json')) {
899
+ isNoiseFiles = false;
900
+ break;
901
+ }
902
+ }
903
+ const isNoiseMsg = /^(Create task|Update task|backlog|assign|fixes|backlig|housekeeping|Archive task|fixing tasks|new mission|added new backlog task|docs: move|mission changes|random changes|new\/updated mission|task updates)/i.test(msg);
904
+ if (!isNoiseFiles || !isNoiseMsg) {
905
+ return commit;
906
+ }
907
+ }
908
+ else {
909
+ return commit;
910
+ }
911
+ commit = `${commit}^`;
912
+ }
913
+ return null;
901
914
  }
902
-
903
915
  /** @param {string} rootDir @param {Function} [gitRunner] */
904
916
  function squashTrailingBacklogNoiseIntoPreviousMission(rootDir, gitRunner) {
905
- if (!gitRunner) {gitRunner = require('./git').git;}
906
-
907
- // Pattern violation fix: destructive history rewrite without a cleanliness gate.
908
- // Refuse to squash if the index or worktree is dirty to avoid absorbing unrelated changes.
909
- const status = gitRunner(['-C', rootDir, 'status', '--porcelain']).stdout.trim();
910
- if (status) {
911
- fmt.log.warn(`Skipping noise squash in ${rootDir}: worktree is not clean.`);
917
+ const runner = gitRunner || gitModule.git;
918
+ const status = runner(['-C', rootDir, 'status', '--porcelain']).stdout.trim();
919
+ if (status) {
920
+ fmt.log.warn(`Skipping noise squash in ${rootDir}: worktree is not clean.`);
921
+ return false;
922
+ }
923
+ const nonNoiseCommit = findLastNonNoiseCommit(rootDir, runner);
924
+ if (!nonNoiseCommit) {
925
+ return false;
926
+ }
927
+ const headSha = runner(['-C', rootDir, 'rev-parse', 'HEAD']).stdout.trim();
928
+ const baseSha = runner(['-C', rootDir, 'rev-parse', nonNoiseCommit]).stdout.trim();
929
+ if (headSha !== baseSha) {
930
+ fmt.log.info(`Squashing trailing backlog noise into ${baseSha.substring(0, 7)}...`);
931
+ const date = runner(['-C', rootDir, 'log', '-1', '--format=%aD', baseSha]).stdout.trim();
932
+ const resetResult = runner(['-C', rootDir, 'reset', '--soft', baseSha]);
933
+ if (resetResult.status !== 0) {
934
+ fmt.log.fail(`Failed to reset to ${baseSha}: ${resetResult.stderr}`);
935
+ return false;
936
+ }
937
+ const commitResult = runner(['-C', rootDir, 'commit', '--amend', '--no-edit', '--date', date]);
938
+ if (commitResult.status !== 0) {
939
+ fmt.log.fail(`Failed to amend commit: ${commitResult.stderr}`);
940
+ return false;
941
+ }
942
+ return true;
943
+ }
912
944
  return false;
913
- }
914
-
915
- const nonNoiseCommit = findLastNonNoiseCommit(rootDir, gitRunner);
916
- if (!nonNoiseCommit) {return false;}
917
-
918
- const headSha = gitRunner(['-C', rootDir, 'rev-parse', 'HEAD']).stdout.trim();
919
- const baseSha = gitRunner(['-C', rootDir, 'rev-parse', nonNoiseCommit]).stdout.trim();
920
-
921
- if (headSha !== baseSha) {
922
- fmt.log.info(`Squashing trailing backlog noise into ${baseSha.substring(0, 7)}...`);
923
- const date = gitRunner(['-C', rootDir, 'log', '-1', '--format=%aD', baseSha]).stdout.trim();
924
- const resetResult = gitRunner(['-C', rootDir, 'reset', '--soft', baseSha]);
925
- if (resetResult.status !== 0) {
926
- fmt.log.fail(`Failed to reset to ${baseSha}: ${resetResult.stderr}`);
927
- return false;
928
- }
929
- const commitResult = gitRunner(['-C', rootDir, 'commit', '--amend', '--no-edit', '--date', date]);
930
- if (commitResult.status !== 0) {
931
- fmt.log.fail(`Failed to amend commit: ${commitResult.stderr}`);
932
- return false;
933
- }
934
- return true;
935
- }
936
- return false;
937
945
  }
938
-
939
946
  /** @param {string} rootDir @param {Function} [gitRunner] */
940
947
  function softResetTrailingBacklogNoise(rootDir, gitRunner) {
941
- if (!gitRunner) {gitRunner = require('./git').git;}
942
-
943
- // Refuse to reset if the index or worktree is dirty.
944
- const status = gitRunner(['-C', rootDir, 'status', '--porcelain']).stdout.trim();
945
- if (status) {
946
- fmt.log.warn(`Skipping noise reset in ${rootDir}: worktree is not clean.`);
948
+ const runner = gitRunner || gitModule.git;
949
+ const status = runner(['-C', rootDir, 'status', '--porcelain']).stdout.trim();
950
+ if (status) {
951
+ fmt.log.warn(`Skipping noise reset in ${rootDir}: worktree is not clean.`);
952
+ return false;
953
+ }
954
+ const nonNoiseCommit = findLastNonNoiseCommit(rootDir, runner);
955
+ if (!nonNoiseCommit) {
956
+ return false;
957
+ }
958
+ const headSha = runner(['-C', rootDir, 'rev-parse', 'HEAD']).stdout.trim();
959
+ const baseSha = runner(['-C', rootDir, 'rev-parse', nonNoiseCommit]).stdout.trim();
960
+ if (headSha !== baseSha) {
961
+ fmt.log.info(`Resetting trailing backlog noise back to ${baseSha.substring(0, 7)} to include in the integration...`);
962
+ const resetResult = runner(['-C', rootDir, 'reset', '--soft', baseSha]);
963
+ if (resetResult.status !== 0) {
964
+ fmt.log.fail(`Failed to reset to ${baseSha}: ${resetResult.stderr}`);
965
+ return false;
966
+ }
967
+ return true;
968
+ }
947
969
  return false;
948
- }
949
-
950
- const nonNoiseCommit = findLastNonNoiseCommit(rootDir, gitRunner);
951
- if (!nonNoiseCommit) {return false;}
952
-
953
- const headSha = gitRunner(['-C', rootDir, 'rev-parse', 'HEAD']).stdout.trim();
954
- const baseSha = gitRunner(['-C', rootDir, 'rev-parse', nonNoiseCommit]).stdout.trim();
955
-
956
- if (headSha !== baseSha) {
957
- fmt.log.info(`Resetting trailing backlog noise back to ${baseSha.substring(0, 7)} to include in the integration...`);
958
- const resetResult = gitRunner(['-C', rootDir, 'reset', '--soft', baseSha]);
959
- if (resetResult.status !== 0) {
960
- fmt.log.fail(`Failed to reset to ${baseSha}: ${resetResult.stderr}`);
961
- return false;
962
- }
963
- return true;
964
- }
965
- return false;
966
970
  }
967
-
968
971
  /** @param {string} slug @param {string} [rootDir] @param {Function|null} [gitRunner] */
969
- function findMissionDocInBranches(/** @type {string} */ slug, /** @type {string} */ rootDir = process.cwd(), /** @type {Function|null} */ gitRunner = null) {
970
- if (!gitRunner) {gitRunner = require('./git').git;}
971
- /** @type {{branch: string, path: string}[]} */
972
- const candidates = [];
973
-
974
- const baseSlugMatch = slug.match(/^(task-\d+)/i);
975
- const baseSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : slug;
976
- const paths = [
977
- path.relative(rootDir, missionPathForSlug(rootDir, slug)).split(path.sep).join('/'),
978
- path.relative(rootDir, missionPathForSlug(rootDir, baseSlug)).split(path.sep).join('/')
979
- ];
980
- const uniquePaths = Array.from(new Set(paths));
981
-
982
- let branchResult;
983
- try {
984
- branchResult = gitRunner(['-C', rootDir, 'branch', '-a', '--format=%(refname:short)']);
985
- if (branchResult.status !== 0) {return candidates;}
986
- } catch (e) {
987
- return candidates;
988
- }
989
-
990
- /** @param {string} b */
991
- const branches = branchResult.stdout.trim().split('\n')
992
- .filter(Boolean)
993
- .filter((/** @type {string} */ b) => !b.includes('HEAD'))
994
- .filter((/** @type {string} */ b) => b.endsWith(baseSlug) || b.includes(`/${baseSlug}-`) || b.includes(`/${baseSlug}/`));
995
-
996
- for (const branch of branches) {
997
- for (const p of uniquePaths) {
998
- try {
999
- const lsResult = gitRunner(['-C', rootDir, 'ls-tree', '--name-only', branch, p]);
1000
- if (lsResult.status === 0 && lsResult.stdout.trim() === p) {
1001
- candidates.push({ branch, path: p });
1002
- break; // Next branch
972
+ function findMissionDocInBranches(slug, rootDir = process.cwd(), gitRunner) {
973
+ const runner = gitRunner || gitModule.git;
974
+ const candidates = [];
975
+ const baseSlugMatch = slug.match(/^(task-\d+)/i);
976
+ const baseSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : slug;
977
+ const files = [
978
+ node_path_1.default.relative(rootDir, missionPathForSlug(rootDir, slug)).split(node_path_1.default.sep).join('/'),
979
+ node_path_1.default.relative(rootDir, missionPathForSlug(rootDir, baseSlug)).split(node_path_1.default.sep).join('/')
980
+ ];
981
+ const uniquePaths = Array.from(new Set(files));
982
+ let branchResult;
983
+ try {
984
+ branchResult = runner(['-C', rootDir, 'branch', '-a', '--format=%(refname:short)']);
985
+ if (branchResult.status !== 0) {
986
+ return candidates;
987
+ }
988
+ }
989
+ catch (e) {
990
+ return candidates;
991
+ }
992
+ const branches = branchResult.stdout.trim().split('\n')
993
+ .filter(Boolean)
994
+ .filter((b) => !b.includes('HEAD'))
995
+ .filter((b) => b.endsWith(baseSlug) || b.includes(`/${baseSlug}-`) || b.includes(`/${baseSlug}/`));
996
+ for (const branch of branches) {
997
+ for (const f of uniquePaths) {
998
+ try {
999
+ const lsResult = runner(['-C', rootDir, 'ls-tree', '--name-only', branch, f]);
1000
+ if (lsResult.status === 0 && lsResult.stdout.trim() === f) {
1001
+ candidates.push({ branch, path: f });
1002
+ break;
1003
+ }
1004
+ }
1005
+ catch (err) {
1006
+ // ignore
1007
+ }
1003
1008
  }
1004
- } catch (err) {
1005
- // ignore
1006
- }
1007
1009
  }
1008
- }
1009
-
1010
- return candidates;
1010
+ return candidates;
1011
1011
  }
1012
-
1013
1012
  /**
1014
1013
  * Check if a file path is a mission artifact for a specific slug.
1015
1014
  * Mission artifacts include:
@@ -1024,77 +1023,36 @@ function findMissionDocInBranches(/** @type {string} */ slug, /** @type {string}
1024
1023
  */
1025
1024
  /** @param {string} file @param {string} slug @param {string} [rootDir] */
1026
1025
  function isMissionArtifact(file, slug, rootDir = process.cwd()) {
1027
- if (!file || !slug) {return false;}
1028
-
1029
- const missionDir = `${path.relative(rootDir, missionDirForSlug(rootDir, slug)).split(path.sep).join('/')}/`;
1030
- if (file.startsWith(missionDir)) {return true;}
1031
- const legacyMissionDir = `docs/missions/${getMissionYear(slug, rootDir)}/${slug}/`;
1032
- if (file.startsWith(legacyMissionDir)) {return true;}
1033
-
1034
- // Task files: task-NNN - title.md or task-NNN.md in the configured storage.
1035
- const escapedSlug = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1036
- const taskStorage = resolveTaskStorage(rootDir);
1037
- const taskDirs = [taskStorage.tasksDir, taskStorage.completedDir]
1038
- .map(dir => path.relative(rootDir, dir).split(path.sep).join('/'))
1039
- .map(dir => dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
1040
- const taskPattern = new RegExp(`^(?:${taskDirs.join('|')})/${escapedSlug}(?:\\s+-\\s+[^/]+\\.md|\\.md)$`, 'i');
1041
- if (taskPattern.test(file)) {return true;}
1042
-
1043
- return false;
1026
+ if (!file || !slug) {
1027
+ return false;
1028
+ }
1029
+ const missionDir = `${node_path_1.default.relative(rootDir, missionDirForSlug(rootDir, slug)).split(node_path_1.default.sep).join('/')}/`;
1030
+ if (file.startsWith(missionDir)) {
1031
+ return true;
1032
+ }
1033
+ const legacyMissionDir = `docs/missions/${getMissionYear(slug, rootDir)}/${slug}/`;
1034
+ if (file.startsWith(legacyMissionDir)) {
1035
+ return true;
1036
+ }
1037
+ const escapedSlug = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1038
+ const taskStorage = (0, product_config_js_1.resolveTaskStorage)(rootDir);
1039
+ const taskDirs = [taskStorage.tasksDir, taskStorage.completedDir]
1040
+ .map(dir => node_path_1.default.relative(rootDir, dir).split(node_path_1.default.sep).join('/'))
1041
+ .map(dir => dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
1042
+ const taskPattern = new RegExp(`^(?:${taskDirs.join('|')})/${escapedSlug}(?:\\s+-\\s+[^/]+\\.md|\\.md)$`, 'i');
1043
+ if (taskPattern.test(file)) {
1044
+ return true;
1045
+ }
1046
+ return false;
1044
1047
  }
1045
-
1046
1048
  /** @param {string} [file] */
1047
1049
  function isWorkflowGeneratedArtifact(file) {
1048
- if (!file) {return false;}
1049
- return file.startsWith('.workflow/')
1050
- || file.startsWith('.sessions/')
1051
- || file.startsWith('.forgejo-local/')
1052
- || file === 'graphify-out'
1053
- || file.startsWith('graphify-out/');
1050
+ if (!file) {
1051
+ return false;
1052
+ }
1053
+ return file.startsWith('.workflow/')
1054
+ || file.startsWith('.sessions/')
1055
+ || file.startsWith('.forgejo-local/')
1056
+ || file === 'graphify-out'
1057
+ || file.startsWith('graphify-out/');
1054
1058
  }
1055
-
1056
- module.exports = {
1057
- resolveMissionAdapter,
1058
- missionBaseDir,
1059
- missionUsesYearTier,
1060
- missionBranchPrefix,
1061
- missionBranchName,
1062
- missionBranchRef,
1063
- extractSlugFromBranch,
1064
- isMissionSlugCandidate,
1065
- getPrimaryBranch,
1066
- getPrimaryWorktree,
1067
- resolveMainRepo,
1068
- conventionalWorktreePath,
1069
- conventionalBaseWorktreePath,
1070
- detectLaunchBaseBranch,
1071
- parseBaseBranchLine,
1072
- readRecordedBaseBranch,
1073
- resolveMissionBaseBranch,
1074
- resolveBaseWorktree,
1075
- getMissionYear,
1076
- findMissionDir,
1077
- resolveWorktree,
1078
- findCheckpoints,
1079
- compareCheckpointFiles,
1080
- checkpointOrder,
1081
- getFirstLine,
1082
- findMissionArea,
1083
- graphifyAvailable,
1084
- probeGraphifyAvailability,
1085
- updateGraphifyKnowledgeGraph,
1086
- missionTitle,
1087
- normalizeVerifyArea,
1088
- detectMissionAreaFromContent,
1089
- missionPathForSlug,
1090
- missionDirForSlug,
1091
- inferSlug,
1092
- parseConflictFilesFromMergeOutput,
1093
- getConflictFiles,
1094
- findLastNonNoiseCommit,
1095
- squashTrailingBacklogNoiseIntoPreviousMission,
1096
- softResetTrailingBacklogNoise,
1097
- findMissionDocInBranches,
1098
- isMissionArtifact,
1099
- isWorkflowGeneratedArtifact
1100
- };