@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
@@ -0,0 +1,998 @@
1
+ import * as fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import * as fmt from './fmt.js';
5
+ import { loadAdapterConfig, resolveTaskStorage, isStandaloneWorkflowLayout } from './product-config.js';
6
+ import * as gitModule from './git.js';
7
+
8
+ /** @param {string} prefix */
9
+ function normalizeBranchPrefix(prefix: string): string {
10
+ if (typeof prefix !== 'string' || !prefix.trim()) {
11
+ return 'mission/';
12
+ }
13
+ return prefix.endsWith('/') ? prefix : prefix + '/';
14
+ }
15
+
16
+ function missionAdapterDefaults() {
17
+ return {
18
+ baseDir: 'missions',
19
+ branchPrefix: 'mission/',
20
+ worktreePattern: '../<repo>-<slug>',
21
+ };
22
+ }
23
+
24
+ function resolveMissionAdapter(rootDir: string = process.cwd()) {
25
+ const adapters = loadAdapterConfig(rootDir);
26
+ const missions = (adapters.missions as Record<string, unknown>) || {};
27
+ const defaults = missionAdapterDefaults();
28
+ return {
29
+ baseDir: typeof missions.baseDir === 'string' && missions.baseDir.trim()
30
+ ? missions.baseDir
31
+ : defaults.baseDir,
32
+ branchPrefix: normalizeBranchPrefix(missions.branchPrefix as string || defaults.branchPrefix),
33
+ worktreePattern: typeof missions.worktreePattern === 'string' && missions.worktreePattern.trim()
34
+ ? missions.worktreePattern
35
+ : defaults.worktreePattern,
36
+ };
37
+ }
38
+
39
+ export function missionBaseDir(rootDir: string = process.cwd()): string {
40
+ return path.resolve(rootDir, resolveMissionAdapter(rootDir).baseDir);
41
+ }
42
+
43
+ export function missionUsesYearTier(rootDir: string = process.cwd()): boolean {
44
+ return resolveMissionAdapter(rootDir).baseDir !== missionAdapterDefaults().baseDir;
45
+ }
46
+
47
+ export function missionBranchPrefix(rootDir: string = process.cwd()): string {
48
+ return resolveMissionAdapter(rootDir).branchPrefix;
49
+ }
50
+
51
+ /** @param {string} slug @param {string} [rootDir] */
52
+ export function missionBranchName(slug: string, rootDir: string = process.cwd()): string {
53
+ return missionBranchPrefix(rootDir) + slug;
54
+ }
55
+
56
+ /** @param {string} slug @param {string} [rootDir] */
57
+ export function missionBranchRef(slug: string, rootDir: string = process.cwd()): string {
58
+ return 'refs/heads/' + missionBranchName(slug, rootDir);
59
+ }
60
+
61
+ /** @param {unknown} value */
62
+ export function isMissionSlugCandidate(value: unknown): boolean {
63
+ return typeof value === 'string' && /^(task|adhoc)-[a-z0-9][a-z0-9-]*$/i.test(value.trim());
64
+ }
65
+
66
+ /** @param {string} branch @param {string} [rootDir] */
67
+ export function extractSlugFromBranch(branch: string, rootDir: string = process.cwd()): string | null {
68
+ const prefix = missionBranchPrefix(rootDir);
69
+ if (!branch || !branch.startsWith(prefix)) {return null;}
70
+ return branch.slice(prefix.length).toLowerCase();
71
+ }
72
+
73
+ /** @param {string|Function} rootDirOrGitFn @param {Function|null} [maybeGitFn] */
74
+ export function getPrimaryBranch(rootDirOrGitFn: string | Function = process.cwd(), maybeGitFn: Function | null = null): string {
75
+ const rootDir = typeof rootDirOrGitFn === 'function' ? process.cwd() : rootDirOrGitFn;
76
+ /** @type {Function} */
77
+ const runner = typeof rootDirOrGitFn === 'function' ? rootDirOrGitFn : (maybeGitFn || gitModule.git);
78
+ const listLocalBranches = () => {
79
+ const result = runner(['-C', rootDir, 'branch', '--list', '--format=%(refname:short)', 'main', 'master']);
80
+ return (result.stdout || '').split('\n').map((b: string) => b.trim()).filter(Boolean);
81
+ };
82
+
83
+ try {
84
+ const config = loadAdapterConfig(rootDir);
85
+ const missions = (config.missions as Record<string, unknown>) || {};
86
+ if (typeof missions.primaryBranch === 'string' && missions.primaryBranch.trim()) {
87
+ const configured = missions.primaryBranch.trim();
88
+ const branches = listLocalBranches();
89
+ if (branches.includes(configured)) {
90
+ return configured;
91
+ }
92
+ if (configured !== 'main' && configured !== 'master') {
93
+ return configured;
94
+ }
95
+ }
96
+ } catch (_) {
97
+ // fall through to git-based detection
98
+ }
99
+ try {
100
+ const branches = listLocalBranches();
101
+ if (branches.includes('main')) {return 'main';}
102
+ if (branches.includes('master')) {return 'master';}
103
+ } catch (_) {
104
+ // fall through
105
+ }
106
+ throw new Error(
107
+ "Could not detect primary branch. Neither 'main' nor 'master' exists as a local branch."
108
+ );
109
+ }
110
+
111
+ export function resolveMainRepo(): string {
112
+ if (process.env.PRIMARY_WORKTREE) {return process.env.PRIMARY_WORKTREE;}
113
+
114
+ const cwd = process.cwd();
115
+ const primaryBranch = getPrimaryBranch(process.cwd(), gitModule.git);
116
+ try {
117
+ const lines = gitModule.git(['worktree', 'list', '--porcelain']).stdout.split('\n');
118
+ const worktrees: Array<{path: string; branch: string|null; bare: boolean}> = [];
119
+ let current: {path: string; branch: string|null; bare: boolean} | null = null;
120
+
121
+ for (const line of lines) {
122
+ if (line.startsWith('worktree ')) {
123
+ if (current) {worktrees.push(current);}
124
+ current = { path: line.slice('worktree '.length).trim(), branch: null, bare: false };
125
+ continue;
126
+ }
127
+
128
+ if (!current) {continue;}
129
+
130
+ if (line.startsWith('branch ')) {
131
+ current.branch = line.slice('branch '.length).trim();
132
+ } else if (line === 'bare') {
133
+ current.bare = true;
134
+ } else if (line === '') {
135
+ worktrees.push(current);
136
+ current = null;
137
+ }
138
+ }
139
+
140
+ if (current) {worktrees.push(current);}
141
+
142
+ const primaryWorktree = worktrees.find(wt => !wt.bare && wt.branch === `refs/heads/${primaryBranch}`);
143
+ if (primaryWorktree) {return primaryWorktree.path;}
144
+ } catch (_) {
145
+ // ignore git errors, fall through to error
146
+ }
147
+
148
+ try {
149
+ const currentBranch = gitModule.getCurrentBranch(cwd);
150
+ const toplevel = gitModule.git(['-C', cwd, 'rev-parse', '--show-toplevel']);
151
+ const repoRoot = (toplevel.stdout || '').trim();
152
+ if (toplevel.status === 0 && repoRoot) {
153
+ if (currentBranch === primaryBranch) {
154
+ return repoRoot;
155
+ }
156
+
157
+ if (
158
+ path.resolve(repoRoot) === path.resolve(cwd) &&
159
+ isStandaloneWorkflowLayout(repoRoot) &&
160
+ !currentBranch
161
+ ) {
162
+ return repoRoot;
163
+ }
164
+ }
165
+ } catch (_) {
166
+ // ignore git errors, fall through to error
167
+ }
168
+
169
+ throw new Error(
170
+ `Could not resolve primary repository. No worktree on '${primaryBranch}' branch found and PRIMARY_WORKTREE is not set. ` +
171
+ "Verify your worktree setup or set the PRIMARY_WORKTREE environment variable."
172
+ );
173
+ }
174
+
175
+ export function getPrimaryWorktree(): string {
176
+ return resolveMainRepo();
177
+ }
178
+
179
+ /** @param {string} slug @param {string} [mainRepo] */
180
+ export function conventionalWorktreePath(slug: string, mainRepo: string = getPrimaryWorktree()): string {
181
+ const projectName = path.basename(mainRepo);
182
+ const pattern = resolveMissionAdapter(mainRepo).worktreePattern;
183
+ const rendered = pattern
184
+ .replaceAll('<repo>', projectName)
185
+ .replaceAll('<slug>', slug);
186
+ return path.resolve(mainRepo, rendered);
187
+ }
188
+
189
+ /**
190
+ * Conventional path for an auto-created *base* feature-branch worktree.
191
+ *
192
+ * Reuses the same mission `worktreePattern` so the path is discoverable and
193
+ * removable with the existing tooling. The base branch name is slug-sanitised
194
+ * (`/` → `-`) and prefixed with `base-` so it never collides with a mission
195
+ * worktree (`mission/<slug>` → `<repo>-<slug>`).
196
+ */
197
+ /** @param {string} baseBranch @param {string} [mainRepo] */
198
+ export function conventionalBaseWorktreePath(baseBranch: string, mainRepo: string = getPrimaryWorktree()): string {
199
+ const safeName = String(baseBranch).replace(/[\\/]+/g, '-');
200
+ return conventionalWorktreePath(`base-${safeName}`, mainRepo);
201
+ }
202
+
203
+ /**
204
+ * Detect the branch HEAD is on at draft time, to be used as the mission base.
205
+ *
206
+ * Returns the current branch name. Returns `null` when HEAD is detached so the
207
+ * caller falls back to `getPrimaryBranch()`. Throws when the current branch is
208
+ * itself a mission branch (the `mission/*` prefix is reserved; nesting a mission
209
+ * on a mission is refused).
210
+ *
211
+ * @param {string} [cwd]
212
+ * @param {{ gitFn?: Function }} [options]
213
+ * @returns {string|null}
214
+ */
215
+ /** @param {string} [cwd] @param {{gitFn?: Function}} [options] */
216
+ export function detectLaunchBaseBranch(cwd: string = process.cwd(), options: { gitFn?: Function } = {}): string | null {
217
+ /** @type {Function} */
218
+ const runner = options.gitFn || gitModule.git;
219
+ const result = runner(['-C', cwd, 'branch', '--show-current']);
220
+ const branch = ((result && result.stdout) || '').trim();
221
+ if (!branch) {
222
+ return null;
223
+ }
224
+ const prefix = missionBranchPrefix(cwd);
225
+ if (branch.startsWith(prefix)) {
226
+ throw new Error(
227
+ `Cannot launch a mission from mission branch '${branch}': the '${prefix}' prefix is reserved. ` +
228
+ 'Check out the feature branch or primary branch you want as the base before running draft.'
229
+ );
230
+ }
231
+ return branch;
232
+ }
233
+
234
+ /** @param {string} content */
235
+ export function parseBaseBranchLine(content: string): string | null {
236
+ if (!content) {return null;}
237
+ const match = content.match(/^Base-Branch:\s*(\S+)\s*$/m);
238
+ return match ? match[1].trim() : null;
239
+ }
240
+
241
+ /**
242
+ * Read the `Base-Branch:` line recorded in the mission's MISSION.md.
243
+ *
244
+ * Reads the on-disk MISSION.md first (present in the mission worktree); when it
245
+ * is not on disk, falls back to reading it from the mission branch via
246
+ * `git show <branch>:<path>`. Returns `null` when no `Base-Branch:` line exists
247
+ * (every pre-existing mission), so callers fall back to the primary branch.
248
+ */
249
+ /** @param {string} slug @param {string} [rootDir] @param {{gitFn?: Function}} [options] */
250
+ export function readRecordedBaseBranch(slug: string, rootDir: string = process.cwd(), options: { gitFn?: Function | null } = {}): string | null {
251
+ /** @type {Function | null} */
252
+ const gitFn = options.gitFn ?? null;
253
+ if (!slug) {return null;}
254
+
255
+ const missionDir = findMissionDir(slug, rootDir);
256
+ if (missionDir) {
257
+ const missionPath = path.join(missionDir, 'MISSION.md');
258
+ if (fs.existsSync(missionPath)) {
259
+ return parseBaseBranchLine(fs.readFileSync(missionPath, 'utf8'));
260
+ }
261
+ }
262
+
263
+ const runner = gitFn || gitModule.git;
264
+ const branch = missionBranchName(slug, rootDir);
265
+ const baseSlugMatch = slug.match(/^(task-\d+)/i);
266
+ const baseSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : slug;
267
+ const year = getMissionYear(slug, rootDir);
268
+ const adapterBaseDir = (path.relative(rootDir, missionBaseDir(rootDir)) || '.').split(path.sep).join('/');
269
+ const files = Array.from(new Set([
270
+ path.posix.join(adapterBaseDir, year, slug, 'MISSION.md'),
271
+ path.posix.join(adapterBaseDir, year, baseSlug, 'MISSION.md')
272
+ ]));
273
+
274
+ for (const f of files) {
275
+ try {
276
+ const res = runner(['-C', rootDir, 'show', `${branch}:${f}`]);
277
+ if (res && res.status === 0) {
278
+ const parsed = parseBaseBranchLine(res.stdout);
279
+ if (parsed) {return parsed;}
280
+ }
281
+ } catch (_) {
282
+ // ignore and try the next candidate
283
+ }
284
+ }
285
+
286
+ return null;
287
+ }
288
+
289
+ /**
290
+ * Resolve the base branch a mission was drafted from.
291
+ *
292
+ * Returns the recorded `Base-Branch:` when present, otherwise `getPrimaryBranch()`
293
+ * (the byte-identical legacy behaviour for every pre-existing mission).
294
+ */
295
+ /** @param {string} slug @param {string} [rootDir] @param {{gitFn?: Function}} [options] */
296
+ export function resolveMissionBaseBranch(slug: string, rootDir: string = process.cwd(), options: { gitFn?: Function | null } = {}): string {
297
+ /** @type {Function | null} */
298
+ const gitFn = options.gitFn ?? null;
299
+ const recorded = readRecordedBaseBranch(slug, rootDir, { gitFn: gitFn as Function | undefined });
300
+ if (recorded) {return recorded;}
301
+ return gitFn ? getPrimaryBranch(rootDir, gitFn as Function) : getPrimaryBranch(rootDir);
302
+ }
303
+
304
+ /** @param {string} branchRef @param {Function} runner @param {string} mainRepo */
305
+ function findWorktreeForBranch(branchRef: string, runner: Function, mainRepo: string): string | null {
306
+ const result = runner(['-C', mainRepo, 'worktree', 'list', '--porcelain']);
307
+ const lines = ((result && result.stdout) || '').split('\n');
308
+ let current: { path: string; branch: string | null } | null = null;
309
+ for (const line of lines) {
310
+ if (line.startsWith('worktree ')) {
311
+ current = { path: line.slice('worktree '.length).trim(), branch: null };
312
+ } else if (!current) {
313
+ continue;
314
+ } else if (line.startsWith('branch ')) {
315
+ current.branch = line.slice('branch '.length).trim();
316
+ } else if (line === '') {
317
+ if (current.branch === branchRef) {return current.path;}
318
+ current = null;
319
+ }
320
+ }
321
+ if (current && current.branch === branchRef) {return current.path;}
322
+ return null;
323
+ }
324
+
325
+ /**
326
+ * Resolve the worktree the mission integrates back into.
327
+ *
328
+ * When the resolved base equals the primary branch, delegates to
329
+ * `getPrimaryWorktree()` (untouched legacy behaviour). Otherwise returns the
330
+ * live worktree checked out on the base branch, auto-creating one at the
331
+ * conventional pattern path when none exists. Throws a `base branch`-bearing
332
+ * error when the recorded base does not exist locally.
333
+ */
334
+ /** @param {string} slug @param {{rootDir?: string, gitFn?: Function}} [options] */
335
+ export function resolveBaseWorktree(slug: string, options: { rootDir?: string; gitFn?: Function | null } = {}): string {
336
+ const rootDir = options.rootDir || process.cwd();
337
+ /** @type {Function | null} */
338
+ const gitFn = options.gitFn ?? null;
339
+ const runner = gitFn || gitModule.git;
340
+ const base = resolveMissionBaseBranch(slug, rootDir, { gitFn: gitFn as Function | undefined });
341
+ const primary = gitFn ? getPrimaryBranch(rootDir, gitFn as Function) : getPrimaryBranch(rootDir);
342
+ if (base === primary) {
343
+ return getPrimaryWorktree();
344
+ }
345
+
346
+ const mainRepo = getPrimaryWorktree();
347
+ const baseRef = `refs/heads/${base}`;
348
+
349
+ const existing = findWorktreeForBranch(baseRef, runner, mainRepo);
350
+ if (existing) {return existing;}
351
+
352
+ const branchExists = runner(['-C', mainRepo, 'show-ref', '--verify', '--quiet', baseRef]);
353
+ if (!branchExists || branchExists.status !== 0) {
354
+ throw new Error(
355
+ `Mission ${slug} records base branch '${base}' but it does not exist locally. ` +
356
+ `Create or fetch the '${base}' base branch before integrating.`
357
+ );
358
+ }
359
+
360
+ const worktreePath = conventionalBaseWorktreePath(base, mainRepo);
361
+ const addResult = runner(['-C', mainRepo, 'worktree', 'add', worktreePath, base]);
362
+ if (!addResult || addResult.status !== 0) {
363
+ const detail = addResult ? [addResult.stdout, addResult.stderr].filter(Boolean).join('\n').trim() : '';
364
+ throw new Error(
365
+ `Could not create base worktree for base branch '${base}' at ${worktreePath}${detail ? ': ' + detail : '.'}`
366
+ );
367
+ }
368
+ return worktreePath;
369
+ }
370
+
371
+ /** @param {string|undefined} [slug] @param {string} [rootDir] */
372
+ export function getMissionYear(slug: string | undefined = undefined, rootDir: string = process.cwd()): string {
373
+ if (process.env.MISSION_YEAR_OVERRIDE) {
374
+ return process.env.MISSION_YEAR_OVERRIDE;
375
+ }
376
+
377
+ const hasExplicitConfig = fs.existsSync(path.join(rootDir, 'workflow.config.json'));
378
+ if (slug && (missionUsesYearTier(rootDir) || (!hasExplicitConfig && fs.existsSync(path.join(rootDir, 'docs', 'missions'))))) {
379
+ const baseDir = missionUsesYearTier(rootDir)
380
+ ? missionBaseDir(rootDir)
381
+ : path.join(rootDir, 'docs', 'missions');
382
+ if (fs.existsSync(baseDir)) {
383
+ try {
384
+ const stat = fs.statSync(baseDir);
385
+ if (!stat.isDirectory()) {
386
+ return new Date().getFullYear().toString();
387
+ }
388
+ } catch (_) {
389
+ return new Date().getFullYear().toString();
390
+ }
391
+ const years = fs.readdirSync(baseDir)
392
+ .filter(d => /^\d{4}$/.test(d))
393
+ .sort((a: string, b: string) => b.localeCompare(a));
394
+
395
+ const slugStr = slug;
396
+ const candidateSlugs = [slugStr.toLowerCase()];
397
+ const baseTaskMatch = slugStr.match(/^(task-\d+)/i);
398
+ if (baseTaskMatch) {
399
+ candidateSlugs.push(baseTaskMatch[1].toLowerCase());
400
+ }
401
+
402
+ for (const year of years) {
403
+ for (const s of candidateSlugs) {
404
+ const missionDir = path.join(baseDir, year, s);
405
+ if (fs.existsSync(missionDir)) {
406
+ return year;
407
+ }
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ return new Date().getFullYear().toString();
414
+ }
415
+
416
+ /** @param {string} slug @param {string} [rootDir] @param {{missionPath?: string}} options */
417
+ export function findMissionDir(slug: string, rootDir: string = process.cwd(), options: { missionPath?: string } = {}): string | null {
418
+ const opts = options;
419
+ if (opts.missionPath && fs.existsSync(opts.missionPath)) {
420
+ return fs.statSync(opts.missionPath).isDirectory() ? opts.missionPath : path.dirname(opts.missionPath);
421
+ }
422
+ if (!slug) {return null;}
423
+ const missionDir = missionDirForSlug(rootDir, slug);
424
+ if (fs.existsSync(missionDir)) {return missionDir;}
425
+
426
+ const baseTaskMatch = slug.match(/^(task-\d+)/i);
427
+ if (baseTaskMatch) {
428
+ const baseSlug = baseTaskMatch[1].toLowerCase();
429
+ const baseMissionDir = missionDirForSlug(rootDir, baseSlug);
430
+ if (fs.existsSync(baseMissionDir)) {return baseMissionDir;}
431
+ }
432
+
433
+ const legacyBaseDir = path.join(rootDir, 'docs', 'missions');
434
+ if (!missionUsesYearTier(rootDir) && !fs.existsSync(path.join(rootDir, 'workflow.config.json')) && fs.existsSync(legacyBaseDir)) {
435
+ const year = getMissionYear(slug, rootDir);
436
+ const legacyMissionDir = path.join(legacyBaseDir, year, slug);
437
+ if (fs.existsSync(legacyMissionDir)) {return legacyMissionDir;}
438
+ if (baseTaskMatch) {
439
+ const legacyBaseMissionDir = path.join(legacyBaseDir, year, baseTaskMatch[1].toLowerCase());
440
+ if (fs.existsSync(legacyBaseMissionDir)) {return legacyBaseMissionDir;}
441
+ }
442
+ }
443
+
444
+ return null;
445
+ }
446
+
447
+ /** @param {string} slug @param {{cwd?: string, gitFn?: Function}} [options] */
448
+ export function resolveWorktree(slug: string, options: { cwd?: string; gitFn?: Function | null } = {}): string | null {
449
+ const cwd = options.cwd || process.cwd();
450
+ /** @type {Function | null} */
451
+ const gitFn = options.gitFn ?? null;
452
+ const runGit = gitFn || gitModule.git;
453
+ const branchRef = missionBranchRef(slug, cwd);
454
+
455
+ try {
456
+ const lines = runGit(['worktree', 'list', '--porcelain']).stdout.split('\n');
457
+ const matches: Array<{ path: string; branch: string | null; prunable: boolean }> = [];
458
+ let current: { path: string; branch: string | null; prunable: boolean } | null = null;
459
+
460
+ for (const line of lines) {
461
+ if (line.startsWith('worktree ')) {
462
+ current = { path: line.slice('worktree '.length).trim(), branch: null, prunable: false };
463
+ continue;
464
+ }
465
+ if (!current) {continue;}
466
+ if (line.startsWith('branch ')) {
467
+ current.branch = line.slice('branch '.length).trim();
468
+ } else if (line.startsWith('prunable ')) {
469
+ current.prunable = true;
470
+ } else if (line === '') {
471
+ if (current.branch === branchRef) {matches.push(current);}
472
+ current = null;
473
+ }
474
+ }
475
+
476
+ if (current && current.branch === branchRef) {matches.push(current);}
477
+
478
+ const liveMatches = matches.filter(m => !m.prunable);
479
+ if (liveMatches.length > 0) {
480
+ const cwdMatch = liveMatches.find(m => cwd === m.path || cwd.startsWith(m.path + '/'));
481
+ if (cwdMatch) {return cwdMatch.path;}
482
+ return liveMatches[0].path;
483
+ }
484
+ } catch (_) {
485
+ // fall through to branch-based cwd fallback
486
+ }
487
+
488
+ try {
489
+ if (gitModule.getCurrentBranch(cwd) === missionBranchName(slug, cwd)) {
490
+ return cwd;
491
+ }
492
+ } catch (_) {
493
+ // fall through to null
494
+ }
495
+
496
+ return null;
497
+ }
498
+
499
+ /** @param {string} missionDir */
500
+ export function findCheckpoints(missionDir: string): string[] {
501
+ const files = fs.readdirSync(missionDir);
502
+ return files
503
+ .filter(f => /^(CHECKPOINT_|CP-\d+).*\.md$/i.test(f))
504
+ .sort(compareCheckpointFiles)
505
+ .map(f => path.join(missionDir, f));
506
+ }
507
+
508
+ /** @param {string} a @param {string} b */
509
+ export function compareCheckpointFiles(a: string, b: string): number {
510
+ const aOrder = checkpointOrder(a);
511
+ const bOrder = checkpointOrder(b);
512
+
513
+ if (aOrder !== bOrder) {
514
+ return aOrder - bOrder;
515
+ }
516
+
517
+ return a.localeCompare(b);
518
+ }
519
+
520
+ /** @param {string} filename */
521
+ export function checkpointOrder(filename: string): number {
522
+ const numericMatch = filename.match(/(?:CP-|CHECKPOINT_)(\d+)/i);
523
+ if (numericMatch) {
524
+ return Number(numericMatch[1]);
525
+ }
526
+
527
+ return Number.MAX_SAFE_INTEGER;
528
+ }
529
+
530
+ /** @param {string} filePath */
531
+ export function getFirstLine(filePath: string): string {
532
+ const content = fs.readFileSync(filePath, 'utf8');
533
+ return content.split('\n')[0].replace(/^#+\s*/, '').trim();
534
+ }
535
+
536
+ /** @param {string} [slug] */
537
+ export function missionTitle(slug: string | undefined): string | null {
538
+ if (!slug) {return null;}
539
+ const missionDir = findMissionDir(slug);
540
+ if (!missionDir) {return null;}
541
+
542
+ const missionPath = path.join(missionDir, 'MISSION.md');
543
+ if (!fs.existsSync(missionPath)) {return null;}
544
+
545
+ const firstLine = fs.readFileSync(missionPath, 'utf8').split('\n')[0] || '';
546
+ return firstLine.replace(/^#\s*Mission:\s*/i, '').trim() || null;
547
+ }
548
+
549
+ export const SUPPORTED_VERIFY_AREAS = new Set(['docs', 'workflow', 'web', 'server', 'auth', 'android', 'k8s', 'deps', 'all']);
550
+
551
+ /** @param {string} [area] */
552
+ export function normalizeVerifyArea(area: string | undefined): string {
553
+ if (!area) {return 'docs';}
554
+ if (area === 'auth-server') {return 'auth';}
555
+ return SUPPORTED_VERIFY_AREAS.has(area) ? area : area;
556
+ }
557
+
558
+ /** @param {string} content */
559
+ export function detectMissionAreaFromContent(content: string): string {
560
+ const gateMatch = content.match(
561
+ /(?:^|\s)(?:\.{1,2}\/[\w.\/-]+\.(?:sh|bash|py|rb)|\.{1,2}\/[a-z][\w-]*)\s+([a-zA-Z0-9_-]+)\s*(?:$|\n)/m
562
+ );
563
+ return normalizeVerifyArea(gateMatch ? gateMatch[1] : 'docs');
564
+ }
565
+
566
+ export function findMissionArea(missionDir: string): string {
567
+ const missionPath = path.join(missionDir, 'MISSION.md');
568
+ if (!fs.existsSync(missionPath)) {return 'docs';}
569
+
570
+ const content = fs.readFileSync(missionPath, 'utf8');
571
+ return detectMissionAreaFromContent(content);
572
+ }
573
+
574
+ /** @param {{commandRunner?: Function}} [options] */
575
+ export function graphifyAvailable(options: { commandRunner?: Function | null } = {}): boolean {
576
+ const commandRunner = options.commandRunner ?? null;
577
+ const cmdRunner = commandRunner || gitModule.run;
578
+ return probeGraphifyAvailability({ commandRunner: cmdRunner }).available;
579
+ }
580
+
581
+ /** @returns {string[]} */
582
+ export function graphifyCommandCandidates(): string[] {
583
+ const candidates: string[] = [];
584
+ const seen = new Set<string>();
585
+
586
+ const pushCandidate = (candidate: string) => {
587
+ if (!candidate || seen.has(candidate)) {return;}
588
+ seen.add(candidate);
589
+ candidates.push(candidate);
590
+ };
591
+
592
+ if (process.env.GRAPHIFY_BIN) {pushCandidate(process.env.GRAPHIFY_BIN);}
593
+ pushCandidate('graphify');
594
+ pushCandidate(path.join(os.homedir(), '.local', 'bin', 'graphify'));
595
+
596
+ return candidates;
597
+ }
598
+
599
+ /** @param {{commandRunner?: Function}} [options] */
600
+ export function probeGraphifyAvailability(options: { commandRunner?: Function | null } = {}): { available: boolean; command?: string; status?: number | null; reason?: string; error?: unknown } {
601
+ const commandRunner = options.commandRunner ?? null;
602
+ const cmdRunner = commandRunner || gitModule.run;
603
+ for (const command of graphifyCommandCandidates()) {
604
+ try {
605
+ const result = cmdRunner(command, ['--help']);
606
+ return {
607
+ available: result.status !== null && result.status !== undefined,
608
+ command,
609
+ status: result.status ?? null
610
+ };
611
+ } catch (error: unknown) {
612
+ const e = error as Error & { code?: string };
613
+ if (e && e.code === 'ENOENT') {
614
+ continue;
615
+ }
616
+ return {
617
+ available: false,
618
+ reason: 'probe-failed',
619
+ command,
620
+ error
621
+ };
622
+ }
623
+ }
624
+
625
+ return { available: false, reason: 'missing-command' };
626
+ }
627
+
628
+ /**
629
+ * @param {{rootDir?: string, commandRunner?: Function, log?: Function, startMessage?: string, failureHint?: string}} [options]
630
+ */
631
+ /** @param {{rootDir?: string, commandRunner?: Function, log?: Function, startMessage?: string, failureHint?: string}} [options] */
632
+ export function updateGraphifyKnowledgeGraph(options: { rootDir?: string; commandRunner?: Function; log?: Function; startMessage?: string; failureHint?: string } = {}): { updated: boolean; skipped: boolean; reason?: string; status?: number } {
633
+ const rootDir = options.rootDir || process.cwd();
634
+ const commandRunner = options.commandRunner;
635
+ const logFn = options.log || fmt.log.plain;
636
+ const startMessage = options.startMessage || 'Updating graphify knowledge graph...';
637
+ const failureHint = options.failureHint || 'Continuing without blocking workflow.';
638
+ const cmdRunner = commandRunner || gitModule.run;
639
+ const probe = probeGraphifyAvailability({ commandRunner: cmdRunner });
640
+ if (!probe.available) {
641
+ if (probe.reason === 'missing-command') {
642
+ logFn(fmt.status('WARN', 'graphify not found in PATH. Skipping knowledge graph update.'));
643
+ return { updated: false, skipped: true, reason: 'missing-command' };
644
+ }
645
+
646
+ const probeError = probe.error && typeof probe.error === 'object' && 'message' in probe.error ? (probe.error as {message:string}).message : 'unknown probe failure';
647
+ logFn(fmt.status('WARN', `graphify probe failed (${probeError}). Skipping knowledge graph update.`));
648
+ return { updated: false, skipped: true, reason: 'probe-failed' };
649
+ }
650
+
651
+ logFn(fmt.status('INFO', startMessage));
652
+ const result = cmdRunner(probe.command!, ['update', '.'], {
653
+ cwd: rootDir,
654
+ stdio: 'inherit'
655
+ });
656
+
657
+ if (result.status !== 0) {
658
+ logFn(fmt.status('WARN', `graphify update failed with status ${result.status}. ${failureHint}`));
659
+ return { updated: false, skipped: true, reason: 'update-failed', status: result.status };
660
+ }
661
+
662
+ return { updated: true, skipped: false };
663
+ }
664
+
665
+ /** @param {string} rootDir @param {string} slug */
666
+ export function missionPathForSlug(rootDir: string, slug: string): string {
667
+ return path.join(missionDirForSlug(rootDir, slug), 'MISSION.md');
668
+ }
669
+
670
+ /** @param {string} rootDir @param {string} slug */
671
+ export function missionDirForSlug(rootDir: string, slug: string): string {
672
+ const parts: string[] = [missionBaseDir(rootDir)];
673
+ if (missionUsesYearTier(rootDir)) {
674
+ parts.push(getMissionYear(slug, rootDir));
675
+ }
676
+ parts.push(slug);
677
+ return path.join(...parts);
678
+ }
679
+
680
+ /**
681
+ * Infer mission slug from current context:
682
+ * 1. Explicit slugCandidate (task-NNN)
683
+ * 2. Current branch (mission/slug)
684
+ * 3. Current directory name (mission-task-slug)
685
+ * 4. Registered worktree branch for current directory
686
+ *
687
+ * @param {string} [slugCandidate]
688
+ * @returns {string|null}
689
+ */
690
+ /** @param {string} [slugCandidate] */
691
+ export function inferSlug(slugCandidate: string | undefined): string | null {
692
+ if (slugCandidate && isMissionSlugCandidate(slugCandidate)) {
693
+ return slugCandidate.toLowerCase();
694
+ }
695
+
696
+ // 1. Check branch
697
+ try {
698
+ const branch = gitModule.getCurrentBranch();
699
+ const fromBranch = extractSlugFromBranch(branch);
700
+ if (fromBranch) {return fromBranch;}
701
+ } catch (_) {
702
+ // ignore
703
+ }
704
+
705
+ // 3. Check directory name
706
+ const cwd = process.cwd();
707
+ const dirName = path.basename(cwd);
708
+ const dirSlugMatch = dirName.match(/((?:task|adhoc)-[a-z0-9][a-z0-9-]*)$/i);
709
+ if (dirSlugMatch) {
710
+ return dirSlugMatch[1].toLowerCase();
711
+ }
712
+
713
+
714
+ // 3. Check worktree registry
715
+ try {
716
+ const lines = gitModule.git(['worktree', 'list', '--porcelain']).stdout.split('\n');
717
+ let currentPath: string | null = null;
718
+ for (const line of lines) {
719
+ if (line.startsWith('worktree ')) {
720
+ currentPath = line.slice('worktree '.length).trim();
721
+ } else if (line.startsWith('branch ') && currentPath === cwd) {
722
+ const branch = line.slice('branch '.length).trim();
723
+ const shortBranch = branch.replace(/^refs\/heads\//, '');
724
+ const fromBranch = extractSlugFromBranch(shortBranch, cwd);
725
+ if (fromBranch) {return fromBranch;}
726
+ } else if (line === '') {
727
+ currentPath = null;
728
+ }
729
+ }
730
+ } catch (_) {
731
+ // ignore
732
+ }
733
+
734
+ return null;
735
+ }
736
+
737
+ /**
738
+ * Parse files with merge conflicts from the output of `git merge --no-commit --no-ff`.
739
+ *
740
+ * Git emits lines of the form:
741
+ * CONFLICT (content): Merge conflict in path/to/file
742
+ * CONFLICT (modify/delete): path/to/file deleted in HEAD.
743
+ * CONFLICT (add/add): Merge conflict in path/to/file
744
+ *
745
+ * Returns an array of relative file paths (deduped).
746
+ */
747
+ /** @param {string} output */
748
+ export function parseConflictFilesFromMergeOutput(output: string): string[] {
749
+ const seen = new Set<string>();
750
+ const files: string[] = [];
751
+ for (const line of output.split('\n')) {
752
+ if (!line.startsWith('CONFLICT')) {continue;}
753
+ const inMatch = line.match(/Merge conflict in (.+)$/);
754
+ if (inMatch) {
755
+ const f = inMatch[1].trim();
756
+ if (f && !seen.has(f)) { seen.add(f); files.push(f); }
757
+ continue;
758
+ }
759
+ const colonIdx = line.indexOf(':');
760
+ if (colonIdx !== -1) {
761
+ const rest = line.slice(colonIdx + 1).trim();
762
+ const f = rest.split(/\s+/)[0];
763
+ if (f && !seen.has(f)) { seen.add(f); files.push(f); }
764
+ }
765
+ }
766
+ return files;
767
+ }
768
+
769
+ /**
770
+ * Identify files that would have merge conflicts when merging `branch` into `rootDir`.
771
+ *
772
+ * Performs a dry `git merge --no-commit --no-ff` in `rootDir`, collects conflict
773
+ * file paths from the output, aborts the in-progress merge, and returns the list.
774
+ *
775
+ * @param {string} rootDir - Absolute path to the git worktree to test in.
776
+ * @param {string} branch - Branch (or ref) to attempt merging.
777
+ * @param {{ gitRunner?: Function }} [options]
778
+ * @returns {string[]} Relative paths of conflicting files (empty if no conflicts).
779
+ */
780
+ /** @param {string} rootDir @param {string} branch @param {{gitRunner?: Function}} [options] */
781
+ export function getConflictFiles(rootDir: string, branch: string, options: { gitRunner?: Function } = {}): string[] {
782
+ const runner = options.gitRunner || gitModule.git;
783
+
784
+ const merge = runner(['-C', rootDir, 'merge', '--no-commit', '--no-ff', branch]);
785
+ runner(['-C', rootDir, 'merge', '--abort']);
786
+
787
+ if (merge.status === 0) {return [];}
788
+
789
+ const output = [merge.stdout, merge.stderr].filter(Boolean).join('\n');
790
+ const conflictFiles = parseConflictFilesFromMergeOutput(output);
791
+
792
+ if (conflictFiles.length === 0) {
793
+ const summary = output.slice(0, 500) || '(no output)';
794
+ throw new Error(`git merge exited ${merge.status} with no CONFLICT lines — raw output:\n${summary}`);
795
+ }
796
+
797
+ return conflictFiles;
798
+ }
799
+
800
+ /** @param {string} rootDir @param {Function} [gitRunner] */
801
+ export function findLastNonNoiseCommit(rootDir: string, gitRunner?: Function): string | null {
802
+ const runner = gitRunner || gitModule.git;
803
+
804
+ const currentFullRef = runner(['-C', rootDir, 'rev-parse', '--symbolic-full-name', 'HEAD'], { stdio: 'pipe' }).stdout.trim();
805
+
806
+ let commit = 'HEAD';
807
+ for (let i = 0; i < 100; i++) {
808
+ const commitSha = runner(['-C', rootDir, 'rev-parse', commit], { stdio: 'pipe' }).stdout.trim();
809
+
810
+ const branchesContaining = runner(['-C', rootDir, 'branch', '-a', '--contains', commitSha, '--format=%(refname)'], { stdio: 'pipe' })
811
+ .stdout.trim().split('\n').filter(Boolean);
812
+
813
+ const isShared = branchesContaining.some((b: string) => b.startsWith('refs/remotes/'));
814
+ if (isShared) {
815
+ return null;
816
+ }
817
+
818
+ const otherLocalBranches = branchesContaining.filter((b: string) => b !== currentFullRef && b.startsWith('refs/heads/'));
819
+ if (otherLocalBranches.length > 0) {
820
+ return null;
821
+ }
822
+
823
+ const logResult = runner(['-C', rootDir, 'log', '-1', '--format=%s', commit], { stdio: 'pipe' });
824
+ if (logResult.status !== 0) {return null;}
825
+ const msg = logResult.stdout.trim();
826
+
827
+ const diffResult = runner(['-C', rootDir, 'diff-tree', '--no-commit-id', '--name-only', '-r', commit], { stdio: 'pipe' });
828
+ if (diffResult.status !== 0) {return null;}
829
+
830
+ const files = diffResult.stdout.trim().split('\n').filter(Boolean);
831
+ if (files.length > 0) {
832
+ let isNoiseFiles = true;
833
+ for (const file of files) {
834
+ if (!file.startsWith('backlog/') && !file.endsWith('agents.local.json')) {
835
+ isNoiseFiles = false;
836
+ break;
837
+ }
838
+ }
839
+
840
+ 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);
841
+
842
+ if (!isNoiseFiles || !isNoiseMsg) {
843
+ return commit;
844
+ }
845
+ } else {
846
+ return commit;
847
+ }
848
+ commit = `${commit}^`;
849
+ }
850
+ return null;
851
+ }
852
+
853
+ /** @param {string} rootDir @param {Function} [gitRunner] */
854
+ export function squashTrailingBacklogNoiseIntoPreviousMission(rootDir: string, gitRunner?: Function): boolean {
855
+ const runner = gitRunner || gitModule.git;
856
+
857
+ const status = runner(['-C', rootDir, 'status', '--porcelain']).stdout.trim();
858
+ if (status) {
859
+ fmt.log.warn(`Skipping noise squash in ${rootDir}: worktree is not clean.`);
860
+ return false;
861
+ }
862
+
863
+ const nonNoiseCommit = findLastNonNoiseCommit(rootDir, runner);
864
+ if (!nonNoiseCommit) {return false;}
865
+
866
+ const headSha = runner(['-C', rootDir, 'rev-parse', 'HEAD']).stdout.trim();
867
+ const baseSha = runner(['-C', rootDir, 'rev-parse', nonNoiseCommit]).stdout.trim();
868
+
869
+ if (headSha !== baseSha) {
870
+ fmt.log.info(`Squashing trailing backlog noise into ${baseSha.substring(0, 7)}...`);
871
+ const date = runner(['-C', rootDir, 'log', '-1', '--format=%aD', baseSha]).stdout.trim();
872
+ const resetResult = runner(['-C', rootDir, 'reset', '--soft', baseSha]);
873
+ if (resetResult.status !== 0) {
874
+ fmt.log.fail(`Failed to reset to ${baseSha}: ${resetResult.stderr}`);
875
+ return false;
876
+ }
877
+ const commitResult = runner(['-C', rootDir, 'commit', '--amend', '--no-edit', '--date', date]);
878
+ if (commitResult.status !== 0) {
879
+ fmt.log.fail(`Failed to amend commit: ${commitResult.stderr}`);
880
+ return false;
881
+ }
882
+ return true;
883
+ }
884
+ return false;
885
+ }
886
+
887
+ /** @param {string} rootDir @param {Function} [gitRunner] */
888
+ export function softResetTrailingBacklogNoise(rootDir: string, gitRunner?: Function): boolean {
889
+ const runner = gitRunner || gitModule.git;
890
+
891
+ const status = runner(['-C', rootDir, 'status', '--porcelain']).stdout.trim();
892
+ if (status) {
893
+ fmt.log.warn(`Skipping noise reset in ${rootDir}: worktree is not clean.`);
894
+ return false;
895
+ }
896
+
897
+ const nonNoiseCommit = findLastNonNoiseCommit(rootDir, runner);
898
+ if (!nonNoiseCommit) {return false;}
899
+
900
+ const headSha = runner(['-C', rootDir, 'rev-parse', 'HEAD']).stdout.trim();
901
+ const baseSha = runner(['-C', rootDir, 'rev-parse', nonNoiseCommit]).stdout.trim();
902
+
903
+ if (headSha !== baseSha) {
904
+ fmt.log.info(`Resetting trailing backlog noise back to ${baseSha.substring(0, 7)} to include in the integration...`);
905
+ const resetResult = runner(['-C', rootDir, 'reset', '--soft', baseSha]);
906
+ if (resetResult.status !== 0) {
907
+ fmt.log.fail(`Failed to reset to ${baseSha}: ${resetResult.stderr}`);
908
+ return false;
909
+ }
910
+ return true;
911
+ }
912
+ return false;
913
+ }
914
+
915
+ /** @param {string} slug @param {string} [rootDir] @param {Function|null} [gitRunner] */
916
+ export function findMissionDocInBranches(slug: string, rootDir: string = process.cwd(), gitRunner?: Function): Array<{ branch: string; path: string }> {
917
+ const runner = gitRunner || gitModule.git;
918
+ const candidates: Array<{ branch: string; path: string }> = [];
919
+
920
+ const baseSlugMatch = slug.match(/^(task-\d+)/i);
921
+ const baseSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : slug;
922
+ const files = [
923
+ path.relative(rootDir, missionPathForSlug(rootDir, slug)).split(path.sep).join('/'),
924
+ path.relative(rootDir, missionPathForSlug(rootDir, baseSlug)).split(path.sep).join('/')
925
+ ];
926
+ const uniquePaths = Array.from(new Set(files));
927
+
928
+ let branchResult;
929
+ try {
930
+ branchResult = runner(['-C', rootDir, 'branch', '-a', '--format=%(refname:short)']);
931
+ if (branchResult.status !== 0) {return candidates;}
932
+ } catch (e) {
933
+ return candidates;
934
+ }
935
+
936
+ const branches = branchResult.stdout.trim().split('\n')
937
+ .filter(Boolean)
938
+ .filter((b: string) => !b.includes('HEAD'))
939
+ .filter((b: string) => b.endsWith(baseSlug) || b.includes(`/${baseSlug}-`) || b.includes(`/${baseSlug}/`));
940
+
941
+ for (const branch of branches) {
942
+ for (const f of uniquePaths) {
943
+ try {
944
+ const lsResult = runner(['-C', rootDir, 'ls-tree', '--name-only', branch, f]);
945
+ if (lsResult.status === 0 && lsResult.stdout.trim() === f) {
946
+ candidates.push({ branch, path: f });
947
+ break;
948
+ }
949
+ } catch (err) {
950
+ // ignore
951
+ }
952
+ }
953
+ }
954
+
955
+ return candidates;
956
+ }
957
+
958
+ /**
959
+ * Check if a file path is a mission artifact for a specific slug.
960
+ * Mission artifacts include:
961
+ * - missions/<slug>/* by default, or the adapter-configured legacy path
962
+ * - the adapter-configured task storage path for active tasks
963
+ * - the adapter-configured task storage path for completed tasks
964
+ *
965
+ * @param {string} file - Relative file path
966
+ * @param {string} slug - Mission slug (e.g. task-1107)
967
+ * @param {string} rootDir - Workspace root
968
+ * @returns {boolean}
969
+ */
970
+ /** @param {string} file @param {string} slug @param {string} [rootDir] */
971
+ export function isMissionArtifact(file: string, slug: string, rootDir: string = process.cwd()): boolean {
972
+ if (!file || !slug) {return false;}
973
+
974
+ const missionDir = `${path.relative(rootDir, missionDirForSlug(rootDir, slug)).split(path.sep).join('/')}/`;
975
+ if (file.startsWith(missionDir)) {return true;}
976
+ const legacyMissionDir = `docs/missions/${getMissionYear(slug, rootDir)}/${slug}/`;
977
+ if (file.startsWith(legacyMissionDir)) {return true;}
978
+
979
+ const escapedSlug = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
980
+ const taskStorage = resolveTaskStorage(rootDir);
981
+ const taskDirs = [taskStorage.tasksDir, taskStorage.completedDir]
982
+ .map(dir => path.relative(rootDir, dir).split(path.sep).join('/'))
983
+ .map(dir => dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
984
+ const taskPattern = new RegExp(`^(?:${taskDirs.join('|')})/${escapedSlug}(?:\\s+-\\s+[^/]+\\.md|\\.md)$`, 'i');
985
+ if (taskPattern.test(file)) {return true;}
986
+
987
+ return false;
988
+ }
989
+
990
+ /** @param {string} [file] */
991
+ export function isWorkflowGeneratedArtifact(file: string | undefined): boolean {
992
+ if (!file) {return false;}
993
+ return file.startsWith('.workflow/')
994
+ || file.startsWith('.sessions/')
995
+ || file.startsWith('.forgejo-local/')
996
+ || file === 'graphify-out'
997
+ || file.startsWith('graphify-out/');
998
+ }