@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 +44 -24
- package/docs/authority-reference.md +29 -2
- package/lib/agents/opencode.js +117 -11
- package/lib/agents/stage-telemetry.js +10 -1
- package/lib/commands/active.js +1 -1
- package/lib/commands/draft.js +153 -15
- package/lib/commands/integrate.js +9 -4
- package/lib/commands/mission-start.js +15 -4
- package/lib/commands/stats-backfill.js +14 -16
- package/lib/commands/stats.js +198 -52
- package/lib/core/mission-utils.js +19 -9
- package/lib/core/product-config.js +8 -1
- package/lib/review/review-commands.js +3 -1
- package/lib/review/review-loop.js +90 -24
- package/lib/review/review.js +1 -1
- package/lib/tools/backlog.js +86 -23
- package/lib/tools/gatekeeper.js +6 -3
- package/lib/tools/setup-review.js +2 -2
- package/package.json +1 -1
- package/prompts/draft.md +1 -1
package/README.md
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
# Parallix
|
|
2
2
|
|
|
3
|
-
**Parallix is a
|
|
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
|
|
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:
|
|
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
|
-
**
|
|
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
|
|
15
|
-
px draft
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
61
|
+
Install from the public npm registry and run a complete mission:
|
|
63
62
|
|
|
64
63
|
```sh
|
|
65
|
-
|
|
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
|
-
|
|
92
|
+
Alternatively, install from a local tarball (useful for development or when offline):
|
|
80
93
|
|
|
81
94
|
```sh
|
|
82
|
-
npm
|
|
95
|
+
npm pack
|
|
96
|
+
npm install -g ./magnus-parallix-*.tgz
|
|
83
97
|
```
|
|
84
98
|
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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:**
|
|
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
|
|
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.
|
|
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
|
|
package/lib/agents/opencode.js
CHANGED
|
@@ -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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/lib/commands/active.js
CHANGED
|
@@ -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
|
|
package/lib/commands/draft.js
CHANGED
|
@@ -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
|
|
51
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
logFn(`
|
|
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')) {
|