@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,854 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fmt = require('../core/fmt');
|
|
4
|
+
const { git, getWorktreeStatus } = require('../core/git');
|
|
5
|
+
const { startDraftAgent, selectAgent, readAgentConfigOrExit } = require('../agents/agents');
|
|
6
|
+
const { resolveTaskFile, enforceTaskAssignee, reportTaskResolution, checkBacklogIntegrity, transitionTask, getTaskClassification } = require('../tools/backlog');
|
|
7
|
+
const { findMissionArea, findMissionDir, inferSlug, getMissionYear, resolveMainRepo, conventionalWorktreePath, squashTrailingBacklogNoiseIntoPreviousMission, resolveWorktree, getPrimaryBranch, missionBranchName, missionDirForSlug, detectLaunchBaseBranch } = require('../core/mission-utils');
|
|
8
|
+
const { transitionVirtual } = require('../core/state-map');
|
|
9
|
+
const stats = require('./stats');
|
|
10
|
+
const { formatVerificationCommand } = require('../core/verification');
|
|
11
|
+
const { ensureStandaloneMissionBaseline } = require('../core/product-config');
|
|
12
|
+
const { ensureWorkflowGitignore } = require('../core/gitignore');
|
|
13
|
+
const { unquoteGitStatusPath } = require('./active');
|
|
14
|
+
|
|
15
|
+
const DRAFT_PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'draft.md');
|
|
16
|
+
const MISSION_SCAFFOLD_PATH = path.join(__dirname, '..', '..', 'templates', 'mission-scaffold.md');
|
|
17
|
+
|
|
18
|
+
async function runDraftCommand(args, {
|
|
19
|
+
inferSlugFn = inferSlug,
|
|
20
|
+
resolveMainRepoFn = resolveMainRepo,
|
|
21
|
+
conventionalWorktreePathFn = conventionalWorktreePath,
|
|
22
|
+
ensureMissionBranchFn = ensureMissionBranch,
|
|
23
|
+
ensureWorktreeFn = ensureWorktree,
|
|
24
|
+
ensureGraphifyWorkspaceFn = ensureGraphifyWorkspace,
|
|
25
|
+
ensureGraphifyIgnoreFn = ensureGraphifyIgnore,
|
|
26
|
+
ensureMissionFileFn = ensureMissionFile,
|
|
27
|
+
detectLaunchBaseBranchFn = detectLaunchBaseBranch,
|
|
28
|
+
ensureMissionBaseBranchRecordedFn = ensureMissionBaseBranchRecorded,
|
|
29
|
+
bootstrapBacklogTaskFn = bootstrapBacklogTask,
|
|
30
|
+
ensureStandaloneMissionBaselineFn = ensureStandaloneMissionBaseline,
|
|
31
|
+
ensureDraftRepoConfigCommittedFn = ensureDraftRepoConfigCommitted,
|
|
32
|
+
readAgentConfigOrExitFn = readAgentConfigOrExit,
|
|
33
|
+
selectAgentFn = selectAgent,
|
|
34
|
+
startDraftAgentFn = startDraftAgent,
|
|
35
|
+
resolveTaskFileFn = resolveTaskFile,
|
|
36
|
+
reportTaskResolutionFn = reportTaskResolution,
|
|
37
|
+
checkBacklogIntegrityFn = checkBacklogIntegrity,
|
|
38
|
+
ensureRepoExistsFn = ensureRepoExists,
|
|
39
|
+
transitionTaskFn = transitionTask,
|
|
40
|
+
transitionVirtualFn = transitionVirtual,
|
|
41
|
+
recordDraftImplementerFn = recordDraftImplementer,
|
|
42
|
+
enforceDraftCommitSafetyFn = enforceDraftCommitSafety,
|
|
43
|
+
validateDraftClassificationFn = validateDraftClassification,
|
|
44
|
+
normalizeDraftClassificationFn = normalizeDraftClassification,
|
|
45
|
+
restartDraftAgentFn = restartDraftAgent,
|
|
46
|
+
exitFn = process.exit,
|
|
47
|
+
logFn = fmt.log.plain,
|
|
48
|
+
errorFn = fmt.log.plainError
|
|
49
|
+
} = {}) {
|
|
50
|
+
const explicitSlug = args[0];
|
|
51
|
+
const slug = inferSlugFn(explicitSlug);
|
|
52
|
+
if (!slug) {
|
|
53
|
+
errorFn(fmt.status('FAIL', 'Usage: px draft <slug> [--agent <family>]'));
|
|
54
|
+
exitFn(1);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const normalizedSlug = slug.toLowerCase();
|
|
59
|
+
|
|
60
|
+
// Allow operators to pin the agent family via CLI flag instead of WORKFLOW_AGENT env var.
|
|
61
|
+
function flagValue(arr, flag, name) {
|
|
62
|
+
const i = arr.indexOf(flag);
|
|
63
|
+
if (i === -1) return null;
|
|
64
|
+
const v = arr[i + 1];
|
|
65
|
+
if (!v || v.startsWith('--')) {
|
|
66
|
+
errorFn(fmt.status('FAIL', `Missing value for --${name}. Usage: px draft <slug> --${name} <family>`));
|
|
67
|
+
exitFn(1);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return v;
|
|
71
|
+
}
|
|
72
|
+
const preselectedAgent = flagValue(args, '--agent', 'agent');
|
|
73
|
+
logFn(fmt.bold(`Starting mission draft automation for: ${fmt.slug(normalizedSlug)}`));
|
|
74
|
+
|
|
75
|
+
const mainRepo = resolveMainRepoFn();
|
|
76
|
+
if (ensureRepoExistsFn(mainRepo, exitFn, errorFn) === false) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const baselineResult = ensureStandaloneMissionBaselineFn(mainRepo);
|
|
81
|
+
if (baselineResult && baselineResult.failed) {
|
|
82
|
+
errorFn(fmt.status('FAIL', `Standalone mission baseline: ${baselineResult.message}`));
|
|
83
|
+
exitFn(1);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (baselineResult && baselineResult.committed) {
|
|
87
|
+
logFn(fmt.status('PASS', 'Standalone mission baseline committed in the primary checkout.'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!ensureDraftRepoConfigCommittedFn(mainRepo, { errorFn })) {
|
|
91
|
+
exitFn(1);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Preflight: Ensure Backlog task exists and is unambiguous before side effects
|
|
96
|
+
const mainResolution = resolveTaskFileFn(normalizedSlug, mainRepo);
|
|
97
|
+
if (!mainResolution.ok) {
|
|
98
|
+
reportTaskResolutionFn(mainResolution, normalizedSlug, errorFn);
|
|
99
|
+
exitFn(1);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Preflight: Backlog integrity check (filename vs frontmatter ID)
|
|
104
|
+
const relevantIssues = checkBacklogIntegrityFn(mainRepo, normalizedSlug);
|
|
105
|
+
if (relevantIssues.length > 0) {
|
|
106
|
+
errorFn(fmt.status('FAIL', `Backlog integrity issues detected for ${normalizedSlug}:`));
|
|
107
|
+
relevantIssues.forEach(issue => {
|
|
108
|
+
logFn(` - ${fmt.path(issue.file)}: filename ID (${fmt.bold(issue.filenameId)}) does not match frontmatter ID (${fmt.bold(issue.frontmatterId)})`);
|
|
109
|
+
});
|
|
110
|
+
logFn('Repair: Fix filename/id mismatch in backlog/ tasks before drafting.');
|
|
111
|
+
exitFn(1);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Detect the branch HEAD is on at draft time (where the human stands). This is
|
|
116
|
+
// the single source of truth for the mission's base. A non-primary feature
|
|
117
|
+
// branch is recorded as the mission base; the primary branch (or a detached
|
|
118
|
+
// HEAD) leaves no record and keeps the byte-identical legacy behaviour.
|
|
119
|
+
let launchBase = null;
|
|
120
|
+
try {
|
|
121
|
+
launchBase = detectLaunchBaseBranchFn(process.cwd());
|
|
122
|
+
} catch (error) {
|
|
123
|
+
errorFn(fmt.status('FAIL', error.message));
|
|
124
|
+
exitFn(1);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let recordedBase = null;
|
|
128
|
+
if (launchBase && launchBase !== getPrimaryBranch(mainRepo)) {
|
|
129
|
+
recordedBase = launchBase;
|
|
130
|
+
logFn(fmt.status('INFO', `Feature-branch mission: base branch detected as ${fmt.branch(recordedBase)}.`));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const branchName = missionBranchName(normalizedSlug, mainRepo);
|
|
134
|
+
logFn(fmt.bold(`Step 1: Setting up branch ${fmt.branch(branchName)}...`));
|
|
135
|
+
ensureMissionBranchFn(mainRepo, branchName, { logFn, baseBranch: recordedBase });
|
|
136
|
+
|
|
137
|
+
const targetWorktree = conventionalWorktreePathFn(normalizedSlug, mainRepo);
|
|
138
|
+
logFn(fmt.bold(`Step 2: Ensuring dedicated worktree at ${fmt.path(targetWorktree)}...`));
|
|
139
|
+
ensureWorktreeFn(mainRepo, targetWorktree, branchName, { logFn, errorFn });
|
|
140
|
+
ensureGraphifyWorkspaceFn(targetWorktree, { logFn });
|
|
141
|
+
ensureGraphifyIgnoreFn(targetWorktree, { logFn });
|
|
142
|
+
|
|
143
|
+
const gitignoreResult = ensureWorkflowGitignore(targetWorktree, { logFn });
|
|
144
|
+
if (gitignoreResult.created) {
|
|
145
|
+
logFn(fmt.status('PASS', `Created .gitignore with ${gitignoreResult.appended} workflow entries in ${fmt.path(targetWorktree)}`));
|
|
146
|
+
} else if (gitignoreResult.appended > 0) {
|
|
147
|
+
logFn(fmt.status('PASS', `Appended ${gitignoreResult.appended} workflow entries to .gitignore in ${fmt.path(targetWorktree)}`));
|
|
148
|
+
} else if (gitignoreResult.skipped) {
|
|
149
|
+
logFn(fmt.status('INFO', `.gitignore in ${fmt.path(targetWorktree)}: ${gitignoreResult.reason === 'symlink' ? 'symbolic link (skipped)' : 'not a git repo (skipped)'}`));
|
|
150
|
+
} else {
|
|
151
|
+
logFn(fmt.status('PASS', `.gitignore in ${fmt.path(targetWorktree)} already contains all workflow entries`));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
logFn(fmt.bold('Step 3: Scaffolding MISSION.md...'));
|
|
155
|
+
const missionFile = ensureMissionFileFn(targetWorktree, normalizedSlug, { logFn });
|
|
156
|
+
ensureMissionBaseBranchRecordedFn(missionFile, recordedBase, { logFn });
|
|
157
|
+
|
|
158
|
+
logFn(fmt.bold('Step 4: Ensuring Backlog task exists in worktree...'));
|
|
159
|
+
if (!bootstrapBacklogTaskFn(targetWorktree, mainRepo, normalizedSlug, { logFn, errorFn })) {
|
|
160
|
+
errorFn(fmt.status('FAIL', `Backlog task for ${normalizedSlug} is missing in ${getPrimaryBranch()} and could not be bootstrapped.`));
|
|
161
|
+
logFn(`Repair: Create the task file in backlog/tasks/ on ${getPrimaryBranch()} before drafting the mission.`);
|
|
162
|
+
logFn(`If Backlog.md is installed: ${fmt.command(`backlog_task_create --title \"Mission: ${normalizedSlug}\"`)}`);
|
|
163
|
+
logFn(`Without Backlog.md: create ${fmt.path(`backlog/tasks/${normalizedSlug} - <title>.md`)} with frontmatter id ${fmt.bold(normalizedSlug.toUpperCase())}.`);
|
|
164
|
+
exitFn(1);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const classificationCheck = validateDraftClassificationFn(normalizedSlug, targetWorktree, {
|
|
169
|
+
errorFn
|
|
170
|
+
});
|
|
171
|
+
if (!classificationCheck.ok) {
|
|
172
|
+
exitFn(1);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
logFn('\n' + fmt.status('PASS', 'Draft setup complete.'));
|
|
177
|
+
logFn(`Worktree: ${fmt.path(targetWorktree)}`);
|
|
178
|
+
logFn(`Mission doc: ${fmt.path(missionFile)}`);
|
|
179
|
+
|
|
180
|
+
if (!transitionTaskFn(normalizedSlug, 'backlog', { rootDir: targetWorktree, log: logFn })) {
|
|
181
|
+
errorFn(fmt.status('FAIL', `Could not transition task ${normalizedSlug} to backlog status.`));
|
|
182
|
+
exitFn(1);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Pre-select the initial family; bookkeeping commit is deferred until after the
|
|
187
|
+
// launch settles so a failed launch cannot leave a stale assignee commit behind.
|
|
188
|
+
const agentConfig = readAgentConfigOrExitFn();
|
|
189
|
+
const agent = preselectedAgent || selectAgentFn('draft', { config: agentConfig });
|
|
190
|
+
|
|
191
|
+
const prompt = buildDraftPrompt(normalizedSlug, { rootDir: mainRepo, worktree: targetWorktree });
|
|
192
|
+
logFn('Launching draft agent...');
|
|
193
|
+
const { agent: actualAgent, result } = await startDraftAgentFn({
|
|
194
|
+
prompt,
|
|
195
|
+
worktree: targetWorktree,
|
|
196
|
+
agent
|
|
197
|
+
});
|
|
198
|
+
logFn(`Draft agent family: ${fmt.agent(actualAgent)}`);
|
|
199
|
+
|
|
200
|
+
if (result.error) {
|
|
201
|
+
errorFn(fmt.status('FAIL', `Could not start draft agent (${fmt.agent(actualAgent)}): ${result.error.message}`));
|
|
202
|
+
exitFn(1);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (typeof result.status === 'number' && result.status !== 0) {
|
|
207
|
+
errorFn(fmt.status('FAIL', `Draft agent (${fmt.agent(actualAgent)}) exited with status ${result.status}.`));
|
|
208
|
+
exitFn(result.status || 1);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const taskResolutionAfter = resolveTaskFileFn(normalizedSlug, targetWorktree);
|
|
213
|
+
recordDraftImplementerFn({
|
|
214
|
+
selected: agent,
|
|
215
|
+
actual: actualAgent,
|
|
216
|
+
taskResolution: taskResolutionAfter,
|
|
217
|
+
slug: normalizedSlug,
|
|
218
|
+
worktree: targetWorktree
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
recordDraftStats({
|
|
222
|
+
slug: normalizedSlug,
|
|
223
|
+
rootDir: mainRepo,
|
|
224
|
+
agentFamily: actualAgent,
|
|
225
|
+
result,
|
|
226
|
+
log: logFn,
|
|
227
|
+
error: errorFn
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Post-draft mission type repair: if the agent did not produce valid labels,
|
|
231
|
+
// relaunch it once with a targeted fix prompt and validate again.
|
|
232
|
+
const normalizationResult = normalizeDraftClassificationFn(normalizedSlug, targetWorktree, {
|
|
233
|
+
errorFn
|
|
234
|
+
});
|
|
235
|
+
if (!normalizationResult.ok) {
|
|
236
|
+
logFn(fmt.status('WARN', `Post-draft mission type labels are not valid (${normalizationResult.reason}). Relaunching draft agent to repair them.`));
|
|
237
|
+
const restartOk = await restartDraftAgentFn(normalizedSlug, targetWorktree, {
|
|
238
|
+
logFn,
|
|
239
|
+
errorFn,
|
|
240
|
+
exitFn
|
|
241
|
+
});
|
|
242
|
+
if (!restartOk) {
|
|
243
|
+
exitFn(1);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const postRestartNorm = normalizeDraftClassificationFn(normalizedSlug, targetWorktree, {
|
|
247
|
+
errorFn
|
|
248
|
+
});
|
|
249
|
+
if (!postRestartNorm.ok) {
|
|
250
|
+
errorFn(fmt.status('FAIL', `Post-draft mission type labels are still invalid after restart (${postRestartNorm.reason}).`));
|
|
251
|
+
exitFn(1);
|
|
252
|
+
return;
|
|
253
|
+
} else {
|
|
254
|
+
logFn(fmt.status('PASS', `Post-draft mission type labels validated after restart: ${postRestartNorm.classification}`));
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
logFn(fmt.status('PASS', `Post-draft mission type labels validated: ${normalizationResult.classification}`));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Re-assert the Base-Branch record after the agent runs so a full MISSION.md
|
|
261
|
+
// rewrite cannot drop it; the safety-harness commit below captures the change.
|
|
262
|
+
ensureMissionBaseBranchRecordedFn(missionFile, recordedBase, { logFn });
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
enforceDraftCommitSafetyFn({ slug: normalizedSlug, worktree: targetWorktree, logFn, errorFn });
|
|
266
|
+
} catch (error) {
|
|
267
|
+
errorFn(fmt.status('FAIL', error.message));
|
|
268
|
+
exitFn(1);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!transitionVirtualFn(transitionTaskFn, normalizedSlug, 'ready', { rootDir: targetWorktree, log: logFn })) {
|
|
273
|
+
errorFn(fmt.status('FAIL', `Could not transition task ${normalizedSlug} to ready status.`));
|
|
274
|
+
exitFn(1);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
logFn('\n' + fmt.status('INFO', `Next: ${fmt.command(`cd ${targetWorktree}`)}`));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function draft(args, deps) {
|
|
282
|
+
return runDraftCommand(args, deps);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function recordDraftImplementer({
|
|
286
|
+
selected,
|
|
287
|
+
actual,
|
|
288
|
+
taskResolution,
|
|
289
|
+
log = fmt.log.plain,
|
|
290
|
+
enforceTaskAssigneeFn = enforceTaskAssignee,
|
|
291
|
+
gitFn = git,
|
|
292
|
+
slug,
|
|
293
|
+
worktree
|
|
294
|
+
}) {
|
|
295
|
+
if (!taskResolution || !taskResolution.ok || !actual) {
|
|
296
|
+
return actual || selected;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (selected && actual !== selected) {
|
|
300
|
+
log(fmt.status('INFO', `Draft agent fell back from ${fmt.agent(selected)} to ${fmt.agent(actual)}; enforcing backlog assignee.`));
|
|
301
|
+
} else {
|
|
302
|
+
log(fmt.status('INFO', `Enforcing draft agent ${fmt.agent(actual)} as assignee...`));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (enforceTaskAssigneeFn(taskResolution.taskFile, actual)) {
|
|
306
|
+
// Ensure we commit the fallback/recording to the mission branch so it is shared.
|
|
307
|
+
const effectiveWorktree = worktree || resolveWorktree(slug) || process.cwd();
|
|
308
|
+
const relativeTaskPath = path.relative(effectiveWorktree, taskResolution.taskFile);
|
|
309
|
+
gitFn(['-C', effectiveWorktree, 'add', relativeTaskPath]);
|
|
310
|
+
const commitResult = gitFn(['-C', effectiveWorktree, 'commit', '-m', `backlog(${slug}): enforce implementer=${actual}`]);
|
|
311
|
+
if (commitResult.status !== 0) {
|
|
312
|
+
log(fmt.status('WARN', `Failed to commit implementer recording: ${commitResult.stderr}`));
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
log(fmt.status('WARN', `Could not enforce draft agent ${fmt.agent(actual)} in backlog task.`));
|
|
316
|
+
}
|
|
317
|
+
return actual;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function ensureMissionBranch(mainRepo, branchName, {
|
|
321
|
+
gitFn = git,
|
|
322
|
+
logFn = fmt.log.plain,
|
|
323
|
+
squashTrailingBacklogNoiseIntoPreviousMissionFn = squashTrailingBacklogNoiseIntoPreviousMission,
|
|
324
|
+
baseBranch = null
|
|
325
|
+
} = {}) {
|
|
326
|
+
const branches = gitFn(['-C', mainRepo, 'branch', '--list', branchName]).stdout.trim();
|
|
327
|
+
if (branches) {
|
|
328
|
+
logFn(fmt.status('PASS', `Branch ${fmt.branch(branchName)} already exists.`));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
squashTrailingBacklogNoiseIntoPreviousMissionFn(mainRepo, gitFn);
|
|
333
|
+
|
|
334
|
+
// The base is whatever HEAD pointed at when draft ran (a feature branch); when
|
|
335
|
+
// none was recorded it falls back to the primary branch — byte-identical to
|
|
336
|
+
// the legacy single-branch behaviour.
|
|
337
|
+
const startPoint = baseBranch || getPrimaryBranch();
|
|
338
|
+
gitFn(['-C', mainRepo, 'branch', branchName, startPoint]);
|
|
339
|
+
logFn(fmt.status('PASS', `Created branch ${fmt.branch(branchName)} from ${fmt.branch(startPoint)}.`));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Persist the resolved mission base as a single machine-readable `Base-Branch:`
|
|
344
|
+
* line in MISSION.md. Idempotent: a no-op when `baseBranch` is falsy (primary or
|
|
345
|
+
* detached-HEAD launch) or when the correct line is already present. Replaces a
|
|
346
|
+
* stale line in place, otherwise inserts the line just under the title.
|
|
347
|
+
*/
|
|
348
|
+
function ensureMissionBaseBranchRecorded(missionFile, baseBranch, { logFn = fmt.log.plain } = {}) {
|
|
349
|
+
if (!baseBranch || !missionFile || !fs.existsSync(missionFile)) {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const content = fs.readFileSync(missionFile, 'utf8');
|
|
354
|
+
const line = `Base-Branch: ${baseBranch}`;
|
|
355
|
+
const existing = content.match(/^Base-Branch:\s*(\S+)\s*$/m);
|
|
356
|
+
if (existing && existing[1] === baseBranch) {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let updated;
|
|
361
|
+
if (existing) {
|
|
362
|
+
updated = content.replace(/^Base-Branch:\s*\S+\s*$/m, line);
|
|
363
|
+
} else {
|
|
364
|
+
const lines = content.split('\n');
|
|
365
|
+
const insertAt = lines.length > 0 ? 1 : 0;
|
|
366
|
+
lines.splice(insertAt, 0, '', line);
|
|
367
|
+
updated = lines.join('\n');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
fs.writeFileSync(missionFile, updated);
|
|
371
|
+
logFn(fmt.status('PASS', `Recorded ${line} in ${fmt.path(missionFile)}`));
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function ensureWorktree(mainRepo, targetWorktree, branchName, {
|
|
376
|
+
existsFn = fs.existsSync,
|
|
377
|
+
gitFn = git,
|
|
378
|
+
logFn = fmt.log.plain,
|
|
379
|
+
errorFn = fmt.log.plainError,
|
|
380
|
+
exitFn = process.exit
|
|
381
|
+
} = {}) {
|
|
382
|
+
if (existsFn(targetWorktree)) {
|
|
383
|
+
logFn(fmt.status('PASS', `Worktree directory ${fmt.path(targetWorktree)} already exists.`));
|
|
384
|
+
try {
|
|
385
|
+
gitFn(['-C', mainRepo, 'worktree', 'add', targetWorktree, branchName]);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
// Ignore "already exists" style failures; the directory is already usable.
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
gitFn(['-C', mainRepo, 'worktree', 'add', targetWorktree, branchName]);
|
|
394
|
+
logFn(fmt.status('PASS', `Created worktree at ${fmt.path(targetWorktree)}.`));
|
|
395
|
+
} catch (error) {
|
|
396
|
+
errorFn(fmt.status('FAIL', `Could not create worktree: ${error.message}`));
|
|
397
|
+
exitFn(1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function ensureGraphifyWorkspace(targetWorktree, { logFn = fmt.log.plain } = {}) {
|
|
402
|
+
const targetPath = path.join(targetWorktree, 'graphify-out');
|
|
403
|
+
|
|
404
|
+
if (fs.existsSync(targetPath)) {
|
|
405
|
+
try {
|
|
406
|
+
if (fs.lstatSync(targetPath).isDirectory()) {
|
|
407
|
+
logFn(fmt.status('PASS', `graphify-out directory already exists in the mission worktree at ${fmt.path(targetPath)}.`));
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
} catch (_) {
|
|
411
|
+
// Fall through to the generic warning below.
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
logFn(fmt.status('WARN', `${fmt.path(targetPath)} already exists and is not a directory. Leaving it unchanged.`));
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
419
|
+
logFn(fmt.status('PASS', `Created independent graphify-out directory in the mission worktree at ${fmt.path(targetPath)}.`));
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function ensureGraphifyIgnore(targetWorktree, { gitFn = git, logFn = fmt.log.plain } = {}) {
|
|
424
|
+
const targetPath = path.join(targetWorktree, '.graphifyignore');
|
|
425
|
+
|
|
426
|
+
if (fs.existsSync(targetPath) || fs.existsSync(path.join(targetWorktree, '.gitignore'))) {
|
|
427
|
+
logFn(fmt.status('PASS', '.graphifyignore or .gitignore already exists. Leaving unchanged.'));
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!fs.existsSync(targetWorktree)) {
|
|
432
|
+
logFn(fmt.status('PASS', `Worktree ${fmt.path(targetWorktree)} does not exist yet. .graphifyignore will be created by the draft agent.`));
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
437
|
+
fs.writeFileSync(targetPath,
|
|
438
|
+
'# Files owned by the workflow toolkit — not part of the project codebase.\n' +
|
|
439
|
+
'# Prevents graphify from extracting session logs, agent state, and tool caches.\n' +
|
|
440
|
+
'.workflow/\n',
|
|
441
|
+
{ encoding: 'utf-8' }
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const gitRoot = targetWorktree;
|
|
446
|
+
gitFn(['-C', gitRoot, 'add', '.graphifyignore']);
|
|
447
|
+
gitFn(['-C', gitRoot, 'commit', '-m', 'workflow: add .graphifyignore to exclude .workflow/ from graphify']);
|
|
448
|
+
logFn(fmt.status('PASS', `Created and committed .graphifyignore in ${fmt.path(gitRoot)}.`));
|
|
449
|
+
} catch (error) {
|
|
450
|
+
logFn(fmt.status('WARN', `Created .graphifyignore but could not commit: ${error.message}. It will be picked up by the draft safety harness.`));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function ensureMissionFile(targetWorktree, slug, { logFn = fmt.log.plain } = {}) {
|
|
457
|
+
const missionDir = missionDirForSlug(targetWorktree, slug);
|
|
458
|
+
if (!fs.existsSync(missionDir)) {
|
|
459
|
+
fs.mkdirSync(missionDir, { recursive: true });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const missionFile = path.join(missionDir, 'MISSION.md');
|
|
463
|
+
if (fs.existsSync(missionFile)) {
|
|
464
|
+
logFn(fmt.status('PASS', `MISSION.md already exists at ${fmt.path(missionFile)}`));
|
|
465
|
+
return missionFile;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const template = fs.readFileSync(MISSION_SCAFFOLD_PATH, 'utf8').replaceAll('{{slug}}', slug);
|
|
469
|
+
fs.writeFileSync(missionFile, template);
|
|
470
|
+
logFn(fmt.status('PASS', `Scaffolded MISSION.md at ${fmt.path(missionFile)}`));
|
|
471
|
+
logFn(fmt.status('INFO', 'Note: Draft mode involves AI refinement. Use the draft prompt to complete the contract.'));
|
|
472
|
+
return missionFile;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function ensureDraftRepoConfigCommitted(mainRepo, {
|
|
476
|
+
getWorktreeStatusFn = getWorktreeStatus,
|
|
477
|
+
errorFn = fmt.log.plainError
|
|
478
|
+
} = {}) {
|
|
479
|
+
const dirtyEntries = getWorktreeStatusFn(mainRepo);
|
|
480
|
+
if (!dirtyEntries || dirtyEntries.length === 0) {
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const configPaths = new Set([
|
|
485
|
+
'workflow.config.json',
|
|
486
|
+
'backlog/config.yml',
|
|
487
|
+
'config/state-map.json'
|
|
488
|
+
]);
|
|
489
|
+
const dirtyConfigEntries = dirtyEntries
|
|
490
|
+
.map(parseDirtyEntry)
|
|
491
|
+
.filter(entry => configPaths.has(entry.filePath));
|
|
492
|
+
|
|
493
|
+
if (dirtyConfigEntries.length === 0) {
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
errorFn(fmt.status('FAIL', `Draft preflight: repo-state config that affects mission layout is uncommitted in ${fmt.path(mainRepo)}.`));
|
|
498
|
+
for (const entry of dirtyConfigEntries) {
|
|
499
|
+
errorFn(` ${entry.status} ${entry.filePath}`);
|
|
500
|
+
}
|
|
501
|
+
errorFn('Commit these repo-state config changes before running draft. Mission worktrees are created from HEAD, so uncommitted config would produce a stale layout.');
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function bootstrapBacklogTask(targetWorktree, mainRepo, slug, {
|
|
506
|
+
resolveTaskFileFn = resolveTaskFile,
|
|
507
|
+
reportTaskResolutionFn = reportTaskResolution,
|
|
508
|
+
gitFn = git,
|
|
509
|
+
logFn = fmt.log.plain,
|
|
510
|
+
errorFn = fmt.log.plainError
|
|
511
|
+
} = {}) {
|
|
512
|
+
const taskResolution = resolveTaskFileFn(slug, targetWorktree);
|
|
513
|
+
if (taskResolution.ok) {
|
|
514
|
+
logFn(fmt.status('PASS', `Backlog task for ${fmt.slug(slug)} already exists in worktree.`));
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (taskResolution.reason === 'ambiguous') {
|
|
519
|
+
reportTaskResolutionFn(taskResolution, slug, errorFn);
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
logFn(fmt.status('INFO', `Backlog task for ${fmt.slug(slug)} not found in worktree. Attempting to bootstrap from ${fmt.path(mainRepo)}...`));
|
|
524
|
+
const mainResolution = resolveTaskFileFn(slug, mainRepo);
|
|
525
|
+
if (!mainResolution.ok) {
|
|
526
|
+
reportTaskResolutionFn(mainResolution, slug, errorFn);
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const relativePath = path.relative(mainRepo, mainResolution.taskFile);
|
|
531
|
+
const targetPath = path.join(targetWorktree, relativePath);
|
|
532
|
+
|
|
533
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
534
|
+
fs.copyFileSync(mainResolution.taskFile, targetPath);
|
|
535
|
+
|
|
536
|
+
logFn(fmt.status('PASS', `Bootstrapped ${fmt.path(relativePath)} from main repo.`));
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
gitFn(['-C', targetWorktree, 'add', relativePath]);
|
|
540
|
+
gitFn(['-C', targetWorktree, 'commit', '-m', `backlog(${slug}): bootstrap task from ${getPrimaryBranch()}`]);
|
|
541
|
+
logFn(fmt.status('PASS', 'Committed bootstrapped task in worktree.'));
|
|
542
|
+
} catch (error) {
|
|
543
|
+
errorFn(fmt.status('FAIL', `Could not commit bootstrapped task: ${error.message}`));
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function resolveVerifyCmd(rootDir) {
|
|
551
|
+
return formatVerificationCommand(null, rootDir);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function resolveTaskPath(slug, promptRoot) {
|
|
555
|
+
const resolution = resolveTaskFile(slug, promptRoot);
|
|
556
|
+
if (resolution && resolution.ok && resolution.taskFile) {
|
|
557
|
+
return resolution.taskFile;
|
|
558
|
+
}
|
|
559
|
+
return path.join(promptRoot, 'backlog', 'tasks', `<${slug}>.md`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function buildDraftPrompt(slug, { rootDir = process.cwd(), worktree = null } = {}) {
|
|
563
|
+
const template = fs.readFileSync(DRAFT_PROMPT_PATH, 'utf8');
|
|
564
|
+
const promptRoot = worktree || rootDir;
|
|
565
|
+
const year = getMissionYear(slug, promptRoot) || String(new Date().getFullYear());
|
|
566
|
+
const missionPath = path.join(missionDirForSlug(promptRoot, slug), 'MISSION.md');
|
|
567
|
+
const missionDir = path.dirname(missionPath);
|
|
568
|
+
const taskPath = resolveTaskPath(slug, promptRoot);
|
|
569
|
+
return template
|
|
570
|
+
.replaceAll('{{slug}}', slug)
|
|
571
|
+
.replaceAll('{{year}}', year)
|
|
572
|
+
.replaceAll('{{missionPath}}', missionPath)
|
|
573
|
+
.replaceAll('{{missionDir}}', missionDir)
|
|
574
|
+
.replaceAll('{{taskPath}}', taskPath)
|
|
575
|
+
.replaceAll('{{verifyCmd}}', resolveVerifyCmd(promptRoot));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function fallbackDraftCommitMessage(slug) {
|
|
579
|
+
return `draft(${slug}): capture agent output`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function validateDraftClassification(slug, worktree, {
|
|
583
|
+
resolveMissionClassificationFn = stats.resolveMissionClassification,
|
|
584
|
+
errorFn = fmt.log.plainError
|
|
585
|
+
} = {}) {
|
|
586
|
+
try {
|
|
587
|
+
const { classification } = resolveMissionClassificationFn(slug, worktree);
|
|
588
|
+
return { ok: true, classification };
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (error.message.includes('Missing or invalid classification')) {
|
|
591
|
+
return { ok: true, classification: null };
|
|
592
|
+
}
|
|
593
|
+
errorFn(fmt.status('FAIL', error.message));
|
|
594
|
+
return { ok: false, reason: 'invalid-classification' };
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function normalizeDraftClassification(slug, worktree, {
|
|
599
|
+
resolveMissionClassificationFn = stats.resolveMissionClassification,
|
|
600
|
+
errorFn = fmt.log.plainError
|
|
601
|
+
} = {}) {
|
|
602
|
+
try {
|
|
603
|
+
const { classification } = resolveMissionClassificationFn(slug, worktree);
|
|
604
|
+
return { ok: true, classification };
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (error.message.includes('Missing or invalid classification')) {
|
|
607
|
+
return { ok: false, reason: 'missing-classification' };
|
|
608
|
+
}
|
|
609
|
+
errorFn(fmt.status('FAIL', error.message));
|
|
610
|
+
return { ok: false, reason: 'invalid-classification' };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function buildRestartPrompt(slug, { rootDir = process.cwd(), worktree = null } = {}) {
|
|
615
|
+
return `${buildDraftPrompt(slug, { rootDir, worktree })}
|
|
616
|
+
|
|
617
|
+
Focused repair:
|
|
618
|
+
- update the backlog task so labels contain exactly one of \`ai_sdlc\` or \`user_value\`
|
|
619
|
+
- use \`ai_sdlc\` for workflow, prompt, or agent-fix work; use \`user_value\` for everything else, including standard code tech debt
|
|
620
|
+
- do not add a separate frontmatter field for mission type
|
|
621
|
+
- if both labels are present, keep only the correct one
|
|
622
|
+
`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function restartDraftAgent(slug, worktree, {
|
|
626
|
+
selectAgentFn = selectAgent,
|
|
627
|
+
startDraftAgentFn = startDraftAgent,
|
|
628
|
+
readAgentConfigOrExitFn = readAgentConfigOrExit,
|
|
629
|
+
logFn = fmt.log.plain,
|
|
630
|
+
errorFn = fmt.log.plainError,
|
|
631
|
+
exitFn = process.exit
|
|
632
|
+
} = {}) {
|
|
633
|
+
const agentConfig = readAgentConfigOrExitFn();
|
|
634
|
+
const agent = selectAgentFn('draft', { config: agentConfig });
|
|
635
|
+
|
|
636
|
+
const prompt = buildRestartPrompt(slug, { rootDir: worktree, worktree });
|
|
637
|
+
logFn('Relaunching draft agent to repair mission type labels...');
|
|
638
|
+
const { agent: actualAgent, result } = await startDraftAgentFn({ prompt, worktree, agent });
|
|
639
|
+
logFn(`Restart draft agent family: ${fmt.agent(actualAgent)}`);
|
|
640
|
+
|
|
641
|
+
if (result.error) {
|
|
642
|
+
errorFn(fmt.status('FAIL', `Could not restart draft agent (${fmt.agent(actualAgent)}): ${result.error.message}`));
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (typeof result.status === 'number' && result.status !== 0) {
|
|
647
|
+
errorFn(fmt.status('FAIL', `Restart draft agent (${fmt.agent(actualAgent)}) exited with status ${result.status}.`));
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function parseDirtyEntry(entry) {
|
|
655
|
+
const match = entry.match(/^(.{1,2})\s+(.*)$/);
|
|
656
|
+
const status = (match ? match[1] : entry.slice(0, 2)).padEnd(2, ' ');
|
|
657
|
+
const rawPath = (match ? match[2] : entry.slice(2)).trim();
|
|
658
|
+
// For renames git reports `<old> -> <new>`; each side is independently quoted
|
|
659
|
+
// and the ` -> ` separator is always literal, so split before unquoting. Both
|
|
660
|
+
// sides must be decoded because git C-escapes any path with a space or unusual
|
|
661
|
+
// byte under core.quotePath — otherwise `git add --` is handed a quote-wrapped
|
|
662
|
+
// pathspec that matches no file and the fallback commit aborts.
|
|
663
|
+
const renameParts = rawPath.includes(' -> ') ? rawPath.split(' -> ') : null;
|
|
664
|
+
const sourcePath = renameParts ? unquoteGitStatusPath(renameParts[0].trim()) : null;
|
|
665
|
+
const filePath = unquoteGitStatusPath(
|
|
666
|
+
renameParts ? renameParts[renameParts.length - 1].trim() : rawPath
|
|
667
|
+
);
|
|
668
|
+
return { status, filePath, sourcePath };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function isUnmergedStatus(status) {
|
|
672
|
+
return ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(status);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function isDeletedStatus(status) {
|
|
676
|
+
return status.includes('D') && !isUnmergedStatus(status);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function isMissionTaskPath(filePath, slug) {
|
|
680
|
+
if (!filePath) return false;
|
|
681
|
+
const taskPattern = new RegExp(`^backlog/(?:tasks|completed)/[^/]*${slug}(?:\\b|[^/]*$)`);
|
|
682
|
+
return taskPattern.test(filePath);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function isExpectedDraftPath(filePath, slug, worktree) {
|
|
686
|
+
const missionDir = findMissionDir(slug, worktree);
|
|
687
|
+
const missionPrefix = missionDir
|
|
688
|
+
? `${path.relative(worktree, missionDir)}/`
|
|
689
|
+
: path.relative(worktree, missionDirForSlug(worktree, slug)).split(path.sep).join('/') + '/';
|
|
690
|
+
return filePath.startsWith(missionPrefix) || isMissionTaskPath(filePath, slug);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function classifyDraftEntries(dirtyEntries, slug, worktree) {
|
|
694
|
+
const parsedEntries = dirtyEntries.map(parseDirtyEntry);
|
|
695
|
+
const conflictEntries = parsedEntries.filter(entry => isUnmergedStatus(entry.status));
|
|
696
|
+
const stagedEntries = parsedEntries.filter(entry => !isUnmergedStatus(entry.status));
|
|
697
|
+
const expectedEntries = stagedEntries.filter(entry => isExpectedDraftPath(entry.filePath, slug, worktree));
|
|
698
|
+
const unexpectedEntries = stagedEntries.filter(entry => !isExpectedDraftPath(entry.filePath, slug, worktree));
|
|
699
|
+
|
|
700
|
+
return { conflictEntries, expectedEntries, unexpectedEntries };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function resolveMissionSpecificDraftConflicts({ slug, worktree, conflictEntries, gitImpl = git, logFn = fmt.log.plain, errorFn = fmt.log.plainError }) {
|
|
704
|
+
const sharedConflicts = conflictEntries.filter(entry => !isExpectedDraftPath(entry.filePath, slug, worktree));
|
|
705
|
+
if (sharedConflicts.length > 0) {
|
|
706
|
+
const area = findMissionArea(findMissionDir(slug, worktree) || missionDirForSlug(worktree, slug));
|
|
707
|
+
const sharedFiles = sharedConflicts.map(entry => entry.filePath);
|
|
708
|
+
throw new Error(
|
|
709
|
+
`Draft safety harness found shared-file conflicts: ${sharedFiles.join(', ')}. ` +
|
|
710
|
+
`Run "px resolve-conflict ${slug}" from ${worktree}, then re-run ${formatVerificationCommand(area, worktree)}.`
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (conflictEntries.length === 0) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
logFn(fmt.status('WARN', 'Draft safety harness found mission-specific merge conflicts. Auto-resolving with --theirs:'));
|
|
719
|
+
for (const entry of conflictEntries) {
|
|
720
|
+
logFn(` ${entry.filePath}`);
|
|
721
|
+
gitImpl(['-C', worktree, 'checkout', '--theirs', '--', entry.filePath]);
|
|
722
|
+
gitImpl(['-C', worktree, 'add', '--', entry.filePath]);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function enforceDraftCommitSafety({ slug, worktree, dirtyEntries = getWorktreeStatus(worktree), gitImpl = git, logFn = fmt.log.plain, errorFn = fmt.log.plainError }) {
|
|
727
|
+
if (dirtyEntries.length === 0) {
|
|
728
|
+
logFn(fmt.status('PASS', 'Draft safety harness: no uncommitted changes left behind.'));
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
logFn(fmt.status('WARN', 'Draft safety harness: draft agent left uncommitted changes. Creating fallback commit.'));
|
|
733
|
+
for (const entry of dirtyEntries) {
|
|
734
|
+
logFn(` ${entry}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const { conflictEntries, expectedEntries, unexpectedEntries } = classifyDraftEntries(dirtyEntries, slug, worktree);
|
|
738
|
+
resolveMissionSpecificDraftConflicts({ slug, worktree, conflictEntries, gitImpl, logFn, errorFn });
|
|
739
|
+
|
|
740
|
+
const deletedTaskEntries = [...expectedEntries, ...unexpectedEntries].filter(entry =>
|
|
741
|
+
isMissionTaskPath(entry.filePath, slug) && isDeletedStatus(entry.status)
|
|
742
|
+
);
|
|
743
|
+
if (deletedTaskEntries.length > 0) {
|
|
744
|
+
throw new Error(
|
|
745
|
+
`Draft safety harness found deletion of the mission backlog task: ${deletedTaskEntries.map(entry => entry.filePath).join(', ')}. ` +
|
|
746
|
+
'Restore the task file and re-run the draft.'
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const renamedTaskEntries = [...expectedEntries, ...unexpectedEntries].filter(entry =>
|
|
751
|
+
entry.sourcePath &&
|
|
752
|
+
isMissionTaskPath(entry.sourcePath, slug) &&
|
|
753
|
+
entry.filePath !== entry.sourcePath
|
|
754
|
+
);
|
|
755
|
+
if (renamedTaskEntries.length > 0) {
|
|
756
|
+
throw new Error(
|
|
757
|
+
`Draft safety harness found rename/move of the mission backlog task: ${renamedTaskEntries.map(entry => `${entry.sourcePath} -> ${entry.filePath}`).join(', ')}. ` +
|
|
758
|
+
'Restore the task file path and re-run the draft.'
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const stagePaths = [...expectedEntries, ...unexpectedEntries].map(entry => entry.filePath);
|
|
763
|
+
if (unexpectedEntries.length > 0) {
|
|
764
|
+
logFn(fmt.status('WARN', 'Draft safety harness: capturing unexpected dirty files alongside mission artifacts:'));
|
|
765
|
+
for (const entry of unexpectedEntries) {
|
|
766
|
+
logFn(` ${entry.status} ${entry.filePath}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (stagePaths.length > 0) {
|
|
771
|
+
gitImpl(['-C', worktree, 'add', '--', ...stagePaths]);
|
|
772
|
+
}
|
|
773
|
+
const commitMessage = fallbackDraftCommitMessage(slug);
|
|
774
|
+
const commitResult = gitImpl([
|
|
775
|
+
'-C',
|
|
776
|
+
worktree,
|
|
777
|
+
'commit',
|
|
778
|
+
'-m',
|
|
779
|
+
commitMessage,
|
|
780
|
+
'-m',
|
|
781
|
+
'Safety harness: capture draft worktree changes left uncommitted by the agent.'
|
|
782
|
+
]);
|
|
783
|
+
|
|
784
|
+
if (commitResult.status !== 0) {
|
|
785
|
+
throw new Error('Draft safety harness could not create fallback commit.');
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
logFn(fmt.status('PASS', `Draft safety harness committed remaining changes with "${commitMessage}".`));
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function ensureRepoExists(mainRepo, exitFn = process.exit, errorFn = fmt.log.fail) {
|
|
793
|
+
if (!fs.existsSync(mainRepo)) {
|
|
794
|
+
errorFn(`Main repository not found at ${mainRepo}. Please ensure it exists or set PRIMARY_WORKTREE.`);
|
|
795
|
+
exitFn(1);
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Record a draft-stage stats row after the draft agent completes. Token/usage
|
|
803
|
+
* columns come from the agent result's telemetry (currently Codex only); other
|
|
804
|
+
* families record honest zeros with provider/model set to the family name.
|
|
805
|
+
* Best-effort: a failure here must never fail the draft.
|
|
806
|
+
*/
|
|
807
|
+
function recordDraftStats({ slug, rootDir, agentFamily, result, log = fmt.log.plain, error = fmt.log.plainError }) {
|
|
808
|
+
if (!result) return;
|
|
809
|
+
let durationMinutes = 0;
|
|
810
|
+
if (result.startedAt && result.endedAt) {
|
|
811
|
+
durationMinutes = (Date.parse(result.endedAt) - Date.parse(result.startedAt)) / 60000;
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
const { row } = stats.recordStageStats({
|
|
815
|
+
slug,
|
|
816
|
+
stage: 'draft',
|
|
817
|
+
rootDir,
|
|
818
|
+
implementer: agentFamily,
|
|
819
|
+
telemetry: result.telemetry || null,
|
|
820
|
+
durationMinutes,
|
|
821
|
+
});
|
|
822
|
+
log(fmt.status('INFO', `Draft stats recorded: ${slug} stage=draft provider=${row.provider} model=${row.model} input_tokens=${row.input_tokens} tool_calls=${row.tool_calls}`));
|
|
823
|
+
} catch (err) {
|
|
824
|
+
// Best-effort: never escalate to the fatal `error` channel.
|
|
825
|
+
log(fmt.status('WARN', `Could not record draft stats for ${slug}: ${err.message}`));
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
module.exports = draft;
|
|
830
|
+
module.exports.draft = draft;
|
|
831
|
+
module.exports.runDraftCommand = runDraftCommand;
|
|
832
|
+
module.exports.recordDraftStats = recordDraftStats;
|
|
833
|
+
module.exports.buildDraftPrompt = buildDraftPrompt;
|
|
834
|
+
module.exports.recordDraftImplementer = recordDraftImplementer;
|
|
835
|
+
module.exports.enforceDraftCommitSafety = enforceDraftCommitSafety;
|
|
836
|
+
module.exports.fallbackDraftCommitMessage = fallbackDraftCommitMessage;
|
|
837
|
+
module.exports.bootstrapBacklogTask = bootstrapBacklogTask;
|
|
838
|
+
module.exports.ensureGraphifyWorkspace = ensureGraphifyWorkspace;
|
|
839
|
+
module.exports.ensureGraphifyIgnore = ensureGraphifyIgnore;
|
|
840
|
+
module.exports.ensureMissionBranch = ensureMissionBranch;
|
|
841
|
+
module.exports.ensureMissionBaseBranchRecorded = ensureMissionBaseBranchRecorded;
|
|
842
|
+
module.exports.ensureWorktree = ensureWorktree;
|
|
843
|
+
module.exports.ensureMissionFile = ensureMissionFile;
|
|
844
|
+
module.exports.ensureDraftRepoConfigCommitted = ensureDraftRepoConfigCommitted;
|
|
845
|
+
module.exports.ensureRepoExists = ensureRepoExists;
|
|
846
|
+
module.exports.classifyDraftEntries = classifyDraftEntries;
|
|
847
|
+
module.exports.isUnmergedStatus = isUnmergedStatus;
|
|
848
|
+
module.exports.isDeletedStatus = isDeletedStatus;
|
|
849
|
+
module.exports.isMissionTaskPath = isMissionTaskPath;
|
|
850
|
+
module.exports.isExpectedDraftPath = isExpectedDraftPath;
|
|
851
|
+
module.exports.validateDraftClassification = validateDraftClassification;
|
|
852
|
+
module.exports.normalizeDraftClassification = normalizeDraftClassification;
|
|
853
|
+
module.exports.buildRestartPrompt = buildRestartPrompt;
|
|
854
|
+
module.exports.restartDraftAgent = restartDraftAgent;
|