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