@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.
- package/index.js +190 -185
- package/lib/agents/agents.js +737 -838
- package/lib/agents/agents.ts +930 -0
- package/lib/agents/claude-telemetry.js +149 -154
- package/lib/agents/claude-telemetry.ts +233 -0
- package/lib/agents/claude.js +121 -131
- package/lib/agents/claude.ts +156 -0
- package/lib/agents/codex-telemetry.js +176 -164
- package/lib/agents/codex-telemetry.ts +205 -0
- package/lib/agents/codex.js +168 -201
- package/lib/agents/codex.ts +236 -0
- package/lib/agents/limit-hit.js +216 -226
- package/lib/agents/limit-hit.ts +262 -0
- package/lib/agents/mistral-telemetry.js +7 -10
- package/lib/agents/mistral-telemetry.ts +44 -0
- package/lib/agents/mistral.js +40 -58
- package/lib/agents/mistral.ts +84 -0
- package/lib/agents/opencode-export.js +126 -127
- package/lib/agents/opencode-export.ts +160 -0
- package/lib/agents/opencode-telemetry.js +282 -261
- package/lib/agents/opencode-telemetry.ts +350 -0
- package/lib/agents/opencode.js +263 -305
- package/lib/agents/opencode.ts +370 -0
- package/lib/agents/stage-telemetry.js +23 -24
- package/lib/agents/stage-telemetry.ts +47 -0
- package/lib/commands/active.js +502 -579
- package/lib/commands/active.ts +665 -0
- package/lib/commands/checkpoint.js +98 -74
- package/lib/commands/checkpoint.ts +79 -0
- package/lib/commands/config.js +61 -31
- package/lib/commands/config.ts +47 -0
- package/lib/commands/coverage-gate.js +271 -246
- package/lib/commands/coverage-gate.ts +362 -0
- package/lib/commands/diff.js +140 -119
- package/lib/commands/diff.ts +125 -0
- package/lib/commands/draft.js +770 -923
- package/lib/commands/draft.ts +1021 -0
- package/lib/commands/handoff.js +588 -608
- package/lib/commands/handoff.ts +694 -0
- package/lib/commands/integrate.js +1332 -1463
- package/lib/commands/integrate.ts +1688 -0
- package/lib/commands/mission-start.js +279 -237
- package/lib/commands/mission-start.ts +263 -0
- package/lib/commands/rebase.js +606 -582
- package/lib/commands/rebase.ts +635 -0
- package/lib/commands/repair-handoff.js +181 -179
- package/lib/commands/repair-handoff.ts +228 -0
- package/lib/commands/resolve-conflict.js +121 -102
- package/lib/commands/resolve-conflict.ts +112 -0
- package/lib/commands/review.js +3 -4
- package/lib/commands/review.ts +14 -0
- package/lib/commands/setup-review.js +4 -5
- package/lib/commands/setup-review.ts +14 -0
- package/lib/commands/setup.js +3 -3
- package/lib/commands/setup.ts +2 -0
- package/lib/commands/stats-backfill.js +394 -359
- package/lib/commands/stats-backfill.ts +418 -0
- package/lib/commands/stats.js +1424 -1665
- package/lib/commands/stats.ts +2186 -0
- package/lib/commands/status.js +219 -200
- package/lib/commands/status.ts +219 -0
- package/lib/commands/verify.js +3 -1
- package/lib/commands/verify.ts +2 -0
- package/lib/core/fmt.js +140 -211
- package/lib/core/fmt.ts +189 -0
- package/lib/core/git.js +121 -102
- package/lib/core/git.ts +146 -0
- package/lib/core/gitignore.js +59 -106
- package/lib/core/gitignore.ts +103 -0
- package/lib/core/mission-utils.js +833 -875
- package/lib/core/mission-utils.ts +998 -0
- package/lib/core/nels.js +112 -127
- package/lib/core/nels.ts +189 -0
- package/lib/core/persistent-data-migration.js +247 -205
- package/lib/core/persistent-data-migration.ts +255 -0
- package/lib/core/product-config.js +400 -476
- package/lib/core/product-config.ts +518 -0
- package/lib/core/runtime-matrix.js +41 -87
- package/lib/core/runtime-matrix.ts +84 -0
- package/lib/core/spawn-tee.js +152 -192
- package/lib/core/spawn-tee.ts +202 -0
- package/lib/core/state-map.js +92 -92
- package/lib/core/state-map.ts +115 -0
- package/lib/core/storage.js +101 -129
- package/lib/core/storage.ts +182 -0
- package/lib/core/subagent-limit.js +15 -16
- package/lib/core/subagent-limit.ts +25 -0
- package/lib/core/verification.js +148 -138
- package/lib/core/verification.ts +200 -0
- package/lib/index.js +142 -59
- package/lib/index.ts +134 -0
- package/lib/review/rebase.js +142 -145
- package/lib/review/rebase.ts +188 -0
- package/lib/review/review-adapter.js +99 -87
- package/lib/review/review-adapter.ts +198 -0
- package/lib/review/review-artifacts.js +486 -629
- package/lib/review/review-artifacts.ts +622 -0
- package/lib/review/review-commands.js +1220 -1373
- package/lib/review/review-commands.ts +1579 -0
- package/lib/review/review-events.js +544 -886
- package/lib/review/review-events.ts +954 -0
- package/lib/review/review-loop.js +945 -1020
- package/lib/review/review-loop.ts +1174 -0
- package/lib/review/review-polling.js +86 -115
- package/lib/review/review-polling.ts +194 -0
- package/lib/review/review-prompts.js +154 -189
- package/lib/review/review-prompts.ts +182 -0
- package/lib/review/review-state.js +246 -225
- package/lib/review/review-state.ts +353 -0
- package/lib/review/review.js +63 -84
- package/lib/review/review.ts +138 -0
- package/lib/tools/backlog.js +694 -680
- package/lib/tools/backlog.ts +835 -0
- package/lib/tools/forgejo.js +1270 -1405
- package/lib/tools/forgejo.ts +1793 -0
- package/lib/tools/gatekeeper.js +106 -96
- package/lib/tools/gatekeeper.ts +124 -0
- package/lib/tools/redgreen.js +200 -181
- package/lib/tools/redgreen.ts +221 -0
- package/lib/tools/sessions.js +58 -52
- package/lib/tools/sessions.ts +81 -0
- package/lib/tools/setup-review.js +902 -975
- package/lib/tools/setup-review.ts +1152 -0
- package/package.json +7 -5
- package/prompts/draft.md +1 -0
- package/prompts/portfolio.md +1 -1
- 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
|
+
}
|