@magnusekdahl/parallix 1.2.0 → 1.3.0

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