@magnusekdahl/parallix 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -1,30 +1,29 @@
1
1
  # Parallix
2
2
 
3
- **Parallix is a CLI for a local-first, human-in-the-loop developer workflow that runs several supported AI coding agents against one repository as isolated, resumable, reviewable missions instead of one agent improvising in your working tree.**
3
+ **Parallix is a local-first Git workflow CLI for running AI coding agents in isolated, reviewable missions instead of letting one long-lived agent session mutate your main checkout.**
4
4
 
5
- It is built for solo maintainers and small-team leads who already drive AI coding agents such as Claude Code, Codex, OpenCode/Qwen, and Vibe/Mistral and have hit the real problems: two agents fighting over one checkout, a run dying when a provider hits its usage cap, no clean way to resume a long task where it stopped, and the agent that wrote the code also being the one that declares it done.
5
+ It is for engineers who already use Git and terminal-first coding agents such as Claude Code, Codex, OpenCode/Qwen, and Vibe/Mistral, and want branch isolation, resumable checkpoints, agent-family failover, and a forced review step without building that harness by hand.
6
6
 
7
- It wraps your AI coding workflow without replacing it: Parallix turns each piece of work into a *mission* with its own branch and its own git worktree, fails over to another agent family when one hits its usage limit, checkpoints long runs so they resume deterministically, and forces a second coding agent review step plus your own repo-configured verification gates before anything is integrated. A human still chooses the task, launches each phase, reads the review, and decides whether the work should land.
7
+ It wraps your existing AI coding workflow without replacing it: each mission gets its own branch and worktree, long runs checkpoint to markdown, review is a separate phase, and integration still goes through your repo's own verification command. A human still chooses the mission, launches each phase, reads the output, and decides what lands.
8
8
 
9
- **Why not just use Claude Code, Codex, or OpenCode directly?** Those are the agents Parallix is the harness around them. It does not replace your agent or your model. It coordinates several of them as one multi-agent coding workflow with isolation, automatic failover, deterministic checkpoints, and a forced review pass that a single agent session does not give you.
10
-
11
- **The first concrete thing you can do** is install the px CLI, create one Backlog.md-style task, and draft it:
9
+ **The first concrete thing you can do** is install the CLI and run one complete mission:
12
10
 
13
11
  ```sh
14
- npm pack && npm install -g ./magnusekdahl-parallix-*.tgz
15
- px draft task-001
12
+ npm install -g @magnusekdahl/parallix
13
+ px draft "create a hello world program"
14
+ px active
15
+ px review
16
+ px integrate
16
17
  ```
17
18
 
18
- `px draft` does not accept free-text slugs like `my-first-task`, and it does not create the task for you. It expects an existing Backlog.md-style task key such as `task-001`.
19
-
20
- Everything below is the longer version, with the proof and the caveats kept honest.
19
+ That path shows the whole value: isolate the work on its own branch and worktree, let an agent execute it with checkpoints, run a separate review phase, and only then integrate it back.
21
20
 
22
21
  ## Why Parallix?
23
22
 
24
23
  Running AI coding agents one session at a time hits a ceiling fast:
25
24
 
26
25
  - **One working tree, many agents.** Point two agents at the same checkout and they fight over the index, the branch, and uncommitted files. You either serialize them — one idle while the other runs — or hand-manage `git worktree` and branch names yourself.
27
- - **Runs die on usage caps.** An agent prints "usage limit reached", the run stops, and you babysit it: restart later, or hand-switch to a different model.
26
+ - **Runs die on usage caps.** An agent prints "usage limit reached", the run stops, and you babysit it: restart later or hand-switch to a different model.
28
27
  - **Long tasks lose their place.** A crashed or context-exhausted agent leaves you reconstructing what was already done by re-reading diffs.
29
28
  - **The author grades its own homework.** The agent that wrote the change also declares it done. Nobody independent looks before it lands.
30
29
 
@@ -59,32 +58,53 @@ In practice: a human drafts a mission, Parallix creates the branch and worktree,
59
58
 
60
59
  ## Quick start
61
60
 
62
- The current install path is a **local npm tarball** built from this repository. A **scoped npm package** (`@magnusekdahl/parallix`) will be available after publication (see [Current status](#current-status)).
61
+ Install from the public npm registry and run a complete mission:
63
62
 
64
63
  ```sh
65
- # Build and install from the local tarball
66
- npm pack
67
- npm install -g ./magnusekdahl-parallix-*.tgz
68
-
69
- # Confirm which px is on PATH
64
+ npm install -g @magnusekdahl/parallix
70
65
  px --version
66
+ px draft "hello world"
67
+ px active
68
+ px review
69
+ px integrate
70
+ ```
71
+
72
+ `px draft` creates the mission branch, sibling worktree, mission file, and task record. Then `cd` into the mission worktree and run `px active`, `px review`, and `px integrate` there with no slug; the CLI infers the mission from the current branch/worktree.
73
+
74
+ Other draft entry points are available when you need them:
75
+
76
+ ```sh
77
+ # Draft from the current repository directory name
78
+ px draft .
79
+
80
+ # Draft from an existing structured task file
81
+ px draft task-001
71
82
  ```
72
83
 
84
+ Optional but useful: run `px setup` if you want help creating `workflow.config.json`, appending workflow entries to `.gitignore`, or bootstrapping Forgejo review wiring.
85
+
73
86
  Optional but useful: add `px shell-init` to your shell rc so mission transitions can `cd` your terminal into the next worktree:
74
87
 
75
88
  ```sh
76
89
  echo 'eval "$(px shell-init bash)"' >> ~/.bashrc
77
90
  ```
78
91
 
79
- **Post-publish install:** Once published to the npm registry, Parallix can be installed with a single command:
92
+ Alternatively, install from a local tarball (useful for development or when offline):
80
93
 
81
94
  ```sh
82
- npm install -g @magnusekdahl/parallix
95
+ npm pack
96
+ npm install -g ./magnus-parallix-*.tgz
83
97
  ```
84
98
 
85
- `px setup` is an optional convenience wizard that writes `workflow.config.json` and appends workflow entries to `.gitignore`. Parallix runs on built-in defaults without it `px setup` becomes useful when you want to configure your verification command, customize the mission layout, or bootstrap the Forgejo review surface (repo, token files, and `review` remote). Forgejo is the PR viewer and publication surface here, not the authority for local branch ancestry or integration.
99
+ Parallix runs on built-in defaults without setup. `px setup` becomes useful when you want to configure your verification command, customize the mission layout, or bootstrap the Forgejo review surface (repo, token files, and `review` remote). Forgejo is the PR viewer and publication surface here, not the authority for local branch ancestry or integration.
100
+
101
+ `px draft` now works without a pre-existing task file. If the input is free text or a directory path, Parallix creates a synthetic markdown task with classification `unknown` so the later phases, stats, and preflight checks still have a consistent record to work from.
102
+
103
+ If you already use Backlog.md-style task files, that still works. `px draft task-001` remains valid, and Parallix will use the existing task metadata and classification when it is present.
104
+
105
+ ## Optional task files
86
106
 
87
- Before `px draft`, create a task first. If you already use Backlog.md, create it there. If you do not, create the markdown file yourself under `backlog/tasks/`:
107
+ Structured markdown task files are now optional. They are still useful when you want stable IDs, explicit labels, or an external task-management flow.
88
108
 
89
109
  ```md
90
110
  backlog/tasks/task-001 - my-first-task.md
@@ -98,7 +118,7 @@ dependencies: []
98
118
  ---
99
119
  ```
100
120
 
101
- Then draft and run it with the actual workflow command:
121
+ Then run the mission by slug:
102
122
 
103
123
  ```sh
104
124
  px draft task-001
@@ -154,7 +174,7 @@ The full evidence-backed inventory is in [`docs/use-cases.md`](docs/use-cases.md
154
174
 
155
175
  **Alpha, local-first, and best suited to operators comfortable with Git and CLI workflows.**
156
176
 
157
- - **Distribution:** Local `npm pack` + global install is the current install path. The scoped package `@magnusekdahl/parallix` will be available on the public npm registry after the operator publishes (ADR 0044, ADR 0046). No Homebrew, no Docker image, no standalone binary, and no CI/release automation today.
177
+ - **Distribution:** Published to the public npm registry as `@magnusekdahl/parallix`. Local tarball install (`npm pack`) is also supported. No Homebrew, no Docker image, no standalone binary, and no CI/release automation today.
158
178
  - **Review surface:** Forgejo is supported as the hosted PR viewer/publication surface, but the workflow remains local-first and can run without Forgejo when that provider is disabled.
159
179
  - **Versioning:** `CHANGELOG.md` is the versioning authority; PATCH bumps are the release discipline.
160
180
  - **Telemetry:** structured token/usage telemetry exists for the codex and claude families; the local-Qwen and mistral paths record honest zeros by design rather than fabricated numbers.
@@ -116,11 +116,38 @@ Each locked mission may add gates (staging validation, manual QA, ADR creation,
116
116
 
117
117
  External review by a different agent is mandatory before integration. Valid review: surface exists; reviewer inspects `<primary-branch>..HEAD`; findings cite file references; zero-finding reviews for non-trivial missions include explicit searched-and-found-none evidence.
118
118
 
119
- ### 4.4 Integration gate
119
+ ### 4.4 Backlog integrity gate (completed/archive-aware board mutations)
120
+
121
+ A task that has been integrated and moved to `backlog/completed/` (status `done`)
122
+ must not reappear in `backlog/tasks/` with `status: backlog`. Board mutations that
123
+ enumerate and rewrite tasks — notably the reorder / ordinal write path that
124
+ produces the recurring "Reorder tasks in backlog" commits — can otherwise
125
+ regenerate a `backlog/tasks/` copy for a task id whose canonical record now lives
126
+ in `completed/` (or `backlog/archive/`), so the board shows shipped work as
127
+ un-started and `task_list` surfaces the stale backlog copy (TASK-1343).
128
+
129
+ Parallix enforces the invariant **"never keep a `backlog/tasks/` file for a task
130
+ id that already exists in `backlog/completed/` or `backlog/archive/`"** in two
131
+ places, both in `lib/tools/backlog.js`:
132
+
133
+ - **Guard / gate:** `checkBacklogIntegrity()` emits a `duplicate-completed` issue
134
+ for any task id present in both `backlog/tasks/` and a canonical
135
+ (`completed/` or `archive/`) location. The gate is exercised by
136
+ `test/backlog_gate.test.js` (fails when a recurrence ships) and by the `px draft`
137
+ preflight (`lib/commands/draft.js`), which refuses to draft while the duplicate
138
+ exists. This is in addition to the existing filename-vs-frontmatter id check.
139
+ - **Mutation hygiene:** `pruneStaleBacklogDuplicates()` treats the completed/archive
140
+ copy as canonical and removes the stale `backlog/tasks/` copy, so a board
141
+ mutation does not leave a recreated `status: backlog` duplicate behind.
142
+
143
+ Regression coverage for the reorder-recreates-completed-task scenario lives in
144
+ `test/backlog_reorder_completed_duplicate.test.js`.
145
+
146
+ ### 4.5 Integration gate
120
147
 
121
148
  Complete when: mission reviewed, landing from the correct integration checkout, Backlog state updated, mission branch and worktree cleanup done.
122
149
 
123
- #### 4.4.1 Integration-time pipeline gates (ADR 0041)
150
+ #### 4.5.1 Integration-time pipeline gates (ADR 0041)
124
151
 
125
152
  `px integrate` runs integration-time gates before the squash-merge lands. These gates are configured via a repo-side config file and invoked per changed top-level area.
126
153
 
@@ -14,25 +14,104 @@ function __setSpawnAndTeeForTest(fn) { _spawnAndTee = fn || spawnAndTee; }
14
14
  function __setExportCaptureForTest(fn) { _captureExport = fn || captureOpencodeExport; }
15
15
  function __setSessionsForTest(mod) { _sessions = mod || sessions; }
16
16
 
17
- // Opencode outputs "Continue opencode -s ses_<id>" at the end.
18
- // Telemetry: after session completion, `opencode export` is invoked to extract
19
- // token-usage data. See opencode-telemetry.js for the parser.
20
- // Stats hooks in active.js and review-loop.js call recordStageStats which
21
- // defaults to '0' for tokens when telemetry is null.
17
+ // Test hook: override the cached feature-detect for `--format json` support.
18
+ // Set to true/false to force inclusion/exclusion of the flag regardless of
19
+ // the real binary. Pass null to reset to the live detect.
20
+ function __setJsonFormatSupportForTest(val) {
21
+ _jsonFormatSupported = val;
22
+ _jsonFormatDetectFn = null;
23
+ }
24
+
25
+ // Test hook: inject a feature-detect function for tests. This replaces the
26
+ // live shell-out with a stubbed function, making tests hermetic.
27
+ function __setJsonFormatDetectForTest(fn) {
28
+ _jsonFormatDetectFn = fn;
29
+ _jsonFormatSupported = null; // invalidate cache so the detect fn runs
30
+ }
31
+
32
+ // Session-id recovery for telemetry. After session completion `opencode export`
33
+ // is invoked to extract token-usage data (see opencode-telemetry.js), so the
34
+ // session id MUST be recoverable from the launcher's stdout. Stats hooks in
35
+ // active.js and review-loop.js call recordStageStats which defaults to '0' for
36
+ // tokens when telemetry is null.
37
+ //
38
+ // Two shapes are recognized:
39
+ // 1. Legacy footer: "Continue opencode -s ses_<id>" (older opencode TUI).
40
+ // 2. JSON stream: `"sessionID":"ses_<id>"` (opencode v2.0.0
41
+ // `run --format json`). v2.0.0's plain `run` no longer prints the footer,
42
+ // so without the JSON field the id is lost and qwen telemetry drops to all
43
+ // zeros (task-1339). We request `--format json` in buildOpencodeInvocation
44
+ // so this field is always present.
22
45
  const OPENCODE_SESSION_ID_RE = /opencode\s+-s\s+(ses_\S+)/i;
46
+ const OPENCODE_JSON_SESSION_ID_RE = /"sessionID"\s*:\s*"(ses_[^"]+)"/;
23
47
 
24
48
  function extractOpencodeSessionId(stdout) {
25
49
  if (!stdout) return null;
26
- const m = OPENCODE_SESSION_ID_RE.exec(stdout);
27
- return m ? m[1] : null;
50
+ const footer = OPENCODE_SESSION_ID_RE.exec(stdout);
51
+ if (footer) return footer[1];
52
+ const json = OPENCODE_JSON_SESSION_ID_RE.exec(stdout);
53
+ return json ? json[1] : null;
28
54
  }
29
55
 
56
+ // Cached feature-detect for `--format json` support. Set to `false` only when
57
+ // a real invocation proves the flag is rejected (older opencode versions).
58
+ // Tests can inject a canned result via __setJsonFormatSupportForTest.
59
+ let _jsonFormatSupported = null;
60
+
61
+ // Injectable feature-detect function for tests. When set, checkJsonFormatSupport()
62
+ // calls this function instead of shelling out, making tests hermetic.
63
+ let _jsonFormatDetectFn = null;
64
+
30
65
  function resolveOpencodeCommand() {
31
66
  return 'opencode';
32
67
  }
33
68
 
34
- function buildOpencodeInvocation({ prompt, worktree, env, resume = false, sessionId = null, model = null }) {
69
+ // Lightweight feature-detect: try `opencode --format json --help`.
70
+ // Returns true if the flag is accepted (exit 0 or non-zero for unrelated reasons),
71
+ // false if the flag itself is rejected ("unrecognized" / "unknown option").
72
+ // Results are cached so the check runs at most once in production.
73
+ // Tests can inject a canned result via __setJsonFormatSupportForTest.
74
+ function checkJsonFormatSupport() {
75
+ if (_jsonFormatSupported !== null) return _jsonFormatSupported;
76
+ if (_jsonFormatDetectFn) {
77
+ _jsonFormatSupported = _jsonFormatDetectFn();
78
+ return _jsonFormatSupported;
79
+ }
80
+ try {
81
+ const { spawnSync } = require('node:child_process');
82
+ const result = spawnSync('opencode', ['--format', 'json', '--help'], {
83
+ timeout: 3000,
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ });
86
+ const stderr = (result.stderr && result.stderr.toString()) || '';
87
+ const combined = stderr;
88
+ // Flag rejected → fallback to legacy invocation.
89
+ if (/unrecognized|unknown option|no such option|invalid option/i.test(combined)) {
90
+ _jsonFormatSupported = false;
91
+ return false;
92
+ }
93
+ _jsonFormatSupported = true;
94
+ return true;
95
+ } catch (_) {
96
+ // ENOENT or other spawn errors → assume legacy (no JSON flag).
97
+ _jsonFormatSupported = false;
98
+ return false;
99
+ }
100
+ }
101
+
102
+ function buildOpencodeInvocation({ prompt, worktree, env, resume = false, sessionId = null, model = null, preferJson = true }) {
103
+ // `--format json` makes opencode stream NDJSON events that each carry a
104
+ // "sessionID":"ses_..." field. opencode v2.0.0's default `run` output no
105
+ // longer prints the "Continue opencode -s ses_..." footer, so JSON is the
106
+ // only reliable way to recover the session id needed for `opencode export`
107
+ // telemetry capture (task-1339).
108
+ //
109
+ // Compatibility guard: feature-detect the flag on first call; older opencode
110
+ // versions that reject `--format json` will silently fall back to the legacy
111
+ // invocation, preserving launch behaviour at the cost of telemetry.
112
+ const useJson = preferJson && checkJsonFormatSupport();
35
113
  const args = ['run', '--pure', '--dangerously-skip-permissions'];
114
+ if (useJson) args.push('--format', 'json');
36
115
  if (model) args.push('-m', model);
37
116
  // Only resume when the marker explicitly says so (resume=true).
38
117
  // sessionId from a stale cross-family marker must be ignored when resume=false.
@@ -164,6 +243,16 @@ function startOpencodeAgent({
164
243
  return result;
165
244
  }
166
245
 
246
+ // Detect whether `--format json` was rejected at runtime (e.g. older opencode
247
+ // that wasn't caught by the feature-detect). If so, retry the invocation
248
+ // without the flag so the prompt still runs (telemetry will be lost but the
249
+ // agent won't fail outright).
250
+ function isJsonFlagError(result) {
251
+ if (!result) return false;
252
+ const text = failureText(result);
253
+ return (/\bunrecognized option\b|\bunknown option\b|\bno such option\b|\binvalid option\b|\bunrecognized flag\b|\bunknown flag\b/i).test(text);
254
+ }
255
+
167
256
  async function processResult(result) {
168
257
  if (result && result.stdout) {
169
258
  result.sessionId = extractOpencodeSessionId(result.stdout);
@@ -186,14 +275,29 @@ function startOpencodeAgent({
186
275
  return result;
187
276
  }
188
277
 
189
- async function staleSessionHandler(invocation) {
278
+ async function runWithJsonFallback(invocation) {
190
279
  let result = await runInvocationWithRetry(invocation);
280
+ // Runtime fallback: if the first invocation used --format json but the
281
+ // binary rejected it, retry without the flag so the prompt still executes.
282
+ if (isJsonFlagError(result) && invocation.args.includes('--format')) {
283
+ const legacyInv = buildOpencodeInvocation({
284
+ prompt, worktree, env, resume, sessionId, model, preferJson: false,
285
+ });
286
+ result = await runInvocationWithRetry(legacyInv);
287
+ // Mark that we fell back so callers know telemetry may be absent.
288
+ if (result) result._jsonFallback = true;
289
+ }
290
+ return result;
291
+ }
292
+
293
+ async function staleSessionHandler(invocation) {
294
+ let result = await runWithJsonFallback(invocation);
191
295
  if (isStaleSessionResult(result) && worktree && resume) {
192
296
  try {
193
297
  _sessions.clearSession(worktree, slug, role);
194
298
  } catch (_) { /* best-effort */ }
195
299
  const freshInv = buildOpencodeInvocation({ prompt, worktree, env, resume: false, sessionId: null, model });
196
- result = await runInvocationWithRetry(freshInv);
300
+ result = await runWithJsonFallback(freshInv);
197
301
  }
198
302
  return processResult(result);
199
303
  }
@@ -214,5 +318,7 @@ module.exports = {
214
318
  shouldRetryOpencodeFailure,
215
319
  __setSpawnAndTeeForTest,
216
320
  __setExportCaptureForTest,
217
- __setSessionsForTest
321
+ __setSessionsForTest,
322
+ __setJsonFormatSupportForTest,
323
+ __setJsonFormatDetectForTest,
218
324
  };
@@ -27,7 +27,16 @@ const { codexHomeRoot, extractCodexTelemetry } = require('./codex');
27
27
  function resolveStageTelemetry({ worktree, result, sinceMs = 0 } = {}) {
28
28
  if (!result || !result.telemetry) return null;
29
29
  try {
30
- return extractCodexTelemetry(codexHomeRoot(worktree), { sinceMs: sinceMs || 0 }) || result.telemetry;
30
+ const codexT = extractCodexTelemetry(codexHomeRoot(worktree), { sinceMs: sinceMs || 0 });
31
+ // Guard against stale Codex rollouts bleeding into non-Codex stages:
32
+ // if the launcher attached telemetry with a provider that differs from
33
+ // what Codex would produce (e.g. anthropic vs openai), discard the Codex
34
+ // read and use the launcher-attached telemetry instead.
35
+ if (codexT && codexT.provider && result.telemetry.provider
36
+ && codexT.provider.toLowerCase() !== result.telemetry.provider.toLowerCase()) {
37
+ return result.telemetry;
38
+ }
39
+ return codexT || result.telemetry;
31
40
  } catch (_) {
32
41
  // Codex rollout unreadable: fall back to this stage's launcher telemetry.
33
42
  return result.telemetry;
@@ -461,7 +461,7 @@ async function runHandoffAndReview(slug, worktree, agent, {
461
461
  }
462
462
 
463
463
  log(`\nStarting autonomous review loop (implementer: ${fmt.agent(agent)})...`);
464
- _startReviewLoop(slug, { implementer: agent, worktree });
464
+ _startReviewLoop(slug, { implementer: agent, worktree, recordStageStatsSafeFn: review.recordStageStatsSafe });
465
465
  return true;
466
466
  }
467
467
 
@@ -1,9 +1,10 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const crypto = require('crypto');
3
4
  const fmt = require('../core/fmt');
4
5
  const { git, getWorktreeStatus } = require('../core/git');
5
6
  const { startDraftAgent, selectAgent, readAgentConfigOrExit } = require('../agents/agents');
6
- const { resolveTaskFile, enforceTaskAssignee, reportTaskResolution, checkBacklogIntegrity, transitionTask, getTaskClassification } = require('../tools/backlog');
7
+ const { resolveTaskFile, enforceTaskAssignee, reportTaskResolution, checkBacklogIntegrity, transitionTask, getTaskClassification, getTaskStorage } = require('../tools/backlog');
7
8
  const { findMissionArea, findMissionDir, inferSlug, getMissionYear, resolveMainRepo, conventionalWorktreePath, squashTrailingBacklogNoiseIntoPreviousMission, resolveWorktree, getPrimaryBranch, missionBranchName, missionDirForSlug, detectLaunchBaseBranch } = require('../core/mission-utils');
8
9
  const { transitionVirtual } = require('../core/state-map');
9
10
  const stats = require('./stats');
@@ -14,6 +15,80 @@ const { unquoteGitStatusPath } = require('./active');
14
15
 
15
16
  const DRAFT_PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'draft.md');
16
17
  const MISSION_SCAFFOLD_PATH = path.join(__dirname, '..', '..', 'templates', 'mission-scaffold.md');
18
+ const SYNTHETIC_SLUG_PREFIX = 'adhoc-';
19
+
20
+ function slugifyDraftIntent(value) {
21
+ return String(value || '')
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/^[./\\]+/, '')
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+ .replace(/-{2,}/g, '-')
28
+ .slice(0, 64);
29
+ }
30
+
31
+ function syntheticTaskId(slug, seed) {
32
+ const hash = crypto.createHash('sha1').update(String(seed || slug)).digest('hex').slice(0, 8).toUpperCase();
33
+ const prefix = slug.startsWith(SYNTHETIC_SLUG_PREFIX) ? 'ADHOC' : 'TASK';
34
+ const base = slug
35
+ .replace(/^(task|adhoc)-/i, '')
36
+ .replace(/[^a-z0-9]+/gi, '-')
37
+ .toUpperCase();
38
+ return `${prefix}-${base}-${hash}`;
39
+ }
40
+
41
+ function resolveDraftTarget(rawInput, cwd = process.cwd()) {
42
+ const explicit = String(rawInput || '').trim();
43
+ if (!explicit) return null;
44
+
45
+ if (explicit.toLowerCase().startsWith('task-')) {
46
+ return {
47
+ slug: explicit.toLowerCase(),
48
+ syntheticTask: null,
49
+ };
50
+ }
51
+
52
+ if (explicit.toLowerCase().startsWith(SYNTHETIC_SLUG_PREFIX)) {
53
+ return {
54
+ slug: explicit.toLowerCase(),
55
+ syntheticTask: {
56
+ title: explicit,
57
+ intent: explicit,
58
+ id: syntheticTaskId(explicit.toLowerCase(), explicit),
59
+ source: 'synthetic-explicit-slug',
60
+ },
61
+ };
62
+ }
63
+
64
+ const absoluteCandidate = path.resolve(cwd, explicit);
65
+ if (fs.existsSync(absoluteCandidate) && fs.statSync(absoluteCandidate).isDirectory()) {
66
+ const baseName = path.basename(absoluteCandidate);
67
+ const normalizedBase = slugifyDraftIntent(baseName) || 'project';
68
+ const slug = `${SYNTHETIC_SLUG_PREFIX}${normalizedBase}`;
69
+ return {
70
+ slug,
71
+ syntheticTask: {
72
+ title: `Draft mission for ${baseName}`,
73
+ intent: `Directory input: ${explicit}`,
74
+ id: syntheticTaskId(slug, absoluteCandidate),
75
+ source: 'synthetic-directory',
76
+ },
77
+ };
78
+ }
79
+
80
+ const normalized = slugifyDraftIntent(explicit) || 'mission';
81
+ const slug = normalized.startsWith(SYNTHETIC_SLUG_PREFIX) ? normalized : `${SYNTHETIC_SLUG_PREFIX}${normalized}`;
82
+ return {
83
+ slug,
84
+ syntheticTask: {
85
+ title: explicit,
86
+ intent: explicit,
87
+ id: syntheticTaskId(slug, explicit),
88
+ source: 'synthetic-free-text',
89
+ },
90
+ };
91
+ }
17
92
 
18
93
  async function runDraftCommand(args, {
19
94
  inferSlugFn = inferSlug,
@@ -47,8 +122,9 @@ async function runDraftCommand(args, {
47
122
  logFn = fmt.log.plain,
48
123
  errorFn = fmt.log.plainError
49
124
  } = {}) {
50
- const explicitSlug = args[0];
51
- const slug = inferSlugFn(explicitSlug);
125
+ const explicitInput = args[0];
126
+ const draftTarget = resolveDraftTarget(explicitInput) || { slug: inferSlugFn(explicitInput), syntheticTask: null };
127
+ const slug = draftTarget.slug;
52
128
  if (!slug) {
53
129
  errorFn(fmt.status('FAIL', 'Usage: px draft <slug> [--agent <family>]'));
54
130
  exitFn(1);
@@ -56,6 +132,7 @@ async function runDraftCommand(args, {
56
132
  }
57
133
 
58
134
  const normalizedSlug = slug.toLowerCase();
135
+ const syntheticTask = draftTarget.syntheticTask;
59
136
 
60
137
  // Allow operators to pin the agent family via CLI flag instead of WORKFLOW_AGENT env var.
61
138
  function flagValue(arr, flag, name) {
@@ -94,20 +171,24 @@ async function runDraftCommand(args, {
94
171
 
95
172
  // Preflight: Ensure Backlog task exists and is unambiguous before side effects
96
173
  const mainResolution = resolveTaskFileFn(normalizedSlug, mainRepo);
97
- if (!mainResolution.ok) {
174
+ if (!mainResolution.ok && !syntheticTask) {
98
175
  reportTaskResolutionFn(mainResolution, normalizedSlug, errorFn);
99
176
  exitFn(1);
100
177
  return;
101
178
  }
102
179
 
103
180
  // Preflight: Backlog integrity check (filename vs frontmatter ID)
104
- const relevantIssues = checkBacklogIntegrityFn(mainRepo, normalizedSlug);
181
+ const relevantIssues = syntheticTask ? [] : checkBacklogIntegrityFn(mainRepo, normalizedSlug);
105
182
  if (relevantIssues.length > 0) {
106
183
  errorFn(fmt.status('FAIL', `Backlog integrity issues detected for ${normalizedSlug}:`));
107
184
  relevantIssues.forEach(issue => {
108
- logFn(` - ${fmt.path(issue.file)}: filename ID (${fmt.bold(issue.filenameId)}) does not match frontmatter ID (${fmt.bold(issue.frontmatterId)})`);
185
+ if (issue.type === 'duplicate-completed') {
186
+ logFn(` - ${fmt.path(issue.file)}: task ${fmt.bold(issue.taskId)} already has a canonical copy in ${fmt.path(issue.canonicalFile)}; this backlog/tasks copy is stale.`);
187
+ } else {
188
+ logFn(` - ${fmt.path(issue.file)}: filename ID (${fmt.bold(issue.filenameId)}) does not match frontmatter ID (${fmt.bold(issue.frontmatterId)})`);
189
+ }
109
190
  });
110
- logFn('Repair: Fix filename/id mismatch in backlog/ tasks before drafting.');
191
+ logFn('Repair: Fix filename/id mismatch, or remove the stale backlog/tasks copy of a completed/archived task, before drafting.');
111
192
  exitFn(1);
112
193
  return;
113
194
  }
@@ -156,11 +237,11 @@ async function runDraftCommand(args, {
156
237
  ensureMissionBaseBranchRecordedFn(missionFile, recordedBase, { logFn });
157
238
 
158
239
  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())}.`);
240
+ if (!bootstrapBacklogTaskFn(targetWorktree, mainRepo, normalizedSlug, { logFn, errorFn, syntheticTask })) {
241
+ const { tasksDir } = getTaskStorage(targetWorktree);
242
+ const taskDirHint = path.relative(targetWorktree, tasksDir).split(path.sep).join('/');
243
+ errorFn(fmt.status('FAIL', `Backlog task for ${normalizedSlug} could not be prepared in the mission worktree.`));
244
+ logFn(`Repair: create the task with your task adapter, or add ${fmt.path(`${taskDirHint}/${normalizedSlug} - <title>.md`)} manually.`);
164
245
  exitFn(1);
165
246
  return;
166
247
  }
@@ -507,7 +588,8 @@ function bootstrapBacklogTask(targetWorktree, mainRepo, slug, {
507
588
  reportTaskResolutionFn = reportTaskResolution,
508
589
  gitFn = git,
509
590
  logFn = fmt.log.plain,
510
- errorFn = fmt.log.plainError
591
+ errorFn = fmt.log.plainError,
592
+ syntheticTask = null
511
593
  } = {}) {
512
594
  const taskResolution = resolveTaskFileFn(slug, targetWorktree);
513
595
  if (taskResolution.ok) {
@@ -520,6 +602,43 @@ function bootstrapBacklogTask(targetWorktree, mainRepo, slug, {
520
602
  return false;
521
603
  }
522
604
 
605
+ if (syntheticTask) {
606
+ const { tasksDir } = getTaskStorage(targetWorktree);
607
+ const taskPath = path.join(tasksDir, `${slug} - ${slugifyDraftIntent(syntheticTask.title || slug) || 'mission'}.md`);
608
+ const body = [
609
+ '---',
610
+ `id: ${syntheticTask.id || syntheticTaskId(slug, syntheticTask.intent || slug)}`,
611
+ `title: ${syntheticTask.title || slug}`,
612
+ 'status: backlog',
613
+ 'assignee: []',
614
+ "created_date: '" + new Date().toISOString().slice(0, 16).replace('T', ' ') + "'",
615
+ 'labels: [unknown]',
616
+ 'dependencies: []',
617
+ 'source: synthetic',
618
+ '---',
619
+ '',
620
+ '## Description',
621
+ '',
622
+ syntheticTask.intent || `Synthetic task created for ${slug}.`,
623
+ ''
624
+ ].join('\n');
625
+
626
+ fs.mkdirSync(path.dirname(taskPath), { recursive: true });
627
+ fs.writeFileSync(taskPath, body, 'utf8');
628
+ logFn(fmt.status('PASS', `Created synthetic backlog task at ${fmt.path(path.relative(targetWorktree, taskPath))}.`));
629
+
630
+ try {
631
+ const relativePath = path.relative(targetWorktree, taskPath);
632
+ gitFn(['-C', targetWorktree, 'add', relativePath]);
633
+ gitFn(['-C', targetWorktree, 'commit', '-m', `backlog(${slug}): create synthetic task`]);
634
+ logFn(fmt.status('PASS', 'Committed synthetic task in worktree.'));
635
+ } catch (error) {
636
+ errorFn(fmt.status('FAIL', `Could not commit synthetic task: ${error.message}`));
637
+ return false;
638
+ }
639
+ return true;
640
+ }
641
+
523
642
  logFn(fmt.status('INFO', `Backlog task for ${fmt.slug(slug)} not found in worktree. Attempting to bootstrap from ${fmt.path(mainRepo)}...`));
524
643
  const mainResolution = resolveTaskFileFn(slug, mainRepo);
525
644
  if (!mainResolution.ok) {
@@ -559,6 +678,16 @@ function resolveTaskPath(slug, promptRoot) {
559
678
  return path.join(promptRoot, 'backlog', 'tasks', `<${slug}>.md`);
560
679
  }
561
680
 
681
+ function resolveClassificationInstructions(taskPath) {
682
+ if (taskPath && fs.existsSync(taskPath)) {
683
+ const content = fs.readFileSync(taskPath, 'utf8');
684
+ if (/^source:\s*synthetic\s*$/mi.test(content)) {
685
+ 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.';
686
+ }
687
+ }
688
+ return 'set exactly one of `ai_sdlc` or `user_value` in the Backlog task labels — no other value. 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.';
689
+ }
690
+
562
691
  function buildDraftPrompt(slug, { rootDir = process.cwd(), worktree = null } = {}) {
563
692
  const template = fs.readFileSync(DRAFT_PROMPT_PATH, 'utf8');
564
693
  const promptRoot = worktree || rootDir;
@@ -572,6 +701,7 @@ function buildDraftPrompt(slug, { rootDir = process.cwd(), worktree = null } = {
572
701
  .replaceAll('{{missionPath}}', missionPath)
573
702
  .replaceAll('{{missionDir}}', missionDir)
574
703
  .replaceAll('{{taskPath}}', taskPath)
704
+ .replaceAll('{{classificationInstructions}}', resolveClassificationInstructions(taskPath))
575
705
  .replaceAll('{{verifyCmd}}', resolveVerifyCmd(promptRoot));
576
706
  }
577
707
 
@@ -584,7 +714,11 @@ function validateDraftClassification(slug, worktree, {
584
714
  errorFn = fmt.log.plainError
585
715
  } = {}) {
586
716
  try {
587
- const { classification } = resolveMissionClassificationFn(slug, worktree);
717
+ const { classification, error: classificationError } = resolveMissionClassificationFn(slug, worktree);
718
+ if (!classification) {
719
+ if (classificationError) errorFn(fmt.status('FAIL', classificationError));
720
+ return { ok: true, classification: null };
721
+ }
588
722
  return { ok: true, classification };
589
723
  } catch (error) {
590
724
  if (error.message.includes('Missing or invalid classification')) {
@@ -600,7 +734,11 @@ function normalizeDraftClassification(slug, worktree, {
600
734
  errorFn = fmt.log.plainError
601
735
  } = {}) {
602
736
  try {
603
- const { classification } = resolveMissionClassificationFn(slug, worktree);
737
+ const { classification, error: classificationError } = resolveMissionClassificationFn(slug, worktree);
738
+ if (!classification) {
739
+ if (classificationError) errorFn(fmt.status('FAIL', classificationError));
740
+ return { ok: false, reason: 'missing-classification' };
741
+ }
604
742
  return { ok: true, classification };
605
743
  } catch (error) {
606
744
  if (error.message.includes('Missing or invalid classification')) {