@magnusekdahl/parallix 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/integration-pipelines.json +9 -0
- package/config/workflow.config.schema.json +12 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
- package/docs/adr/0041-integration-pipeline-gates.md +1 -1
- package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
- package/docs/adr/index.md +1 -0
- package/docs/use-cases.md +44 -0
- package/lib/agents/agents.js +149 -100
- package/lib/agents/claude-telemetry.js +14 -10
- package/lib/agents/claude.js +32 -11
- package/lib/agents/codex-telemetry.js +32 -19
- package/lib/agents/codex.js +36 -11
- package/lib/agents/limit-hit.js +42 -25
- package/lib/agents/mistral-telemetry.js +1 -1
- package/lib/agents/mistral.js +13 -3
- package/lib/agents/opencode-export.js +11 -5
- package/lib/agents/opencode-telemetry.js +75 -90
- package/lib/agents/opencode.js +86 -29
- package/lib/agents/stage-telemetry.js +3 -7
- package/lib/commands/active.js +139 -94
- package/lib/commands/checkpoint.js +3 -1
- package/lib/commands/config.js +5 -3
- package/lib/commands/coverage-gate.js +17 -10
- package/lib/commands/diff.js +11 -5
- package/lib/commands/draft.js +95 -55
- package/lib/commands/handoff.js +253 -65
- package/lib/commands/integrate.js +288 -188
- package/lib/commands/mission-start.js +39 -34
- package/lib/commands/rebase.js +32 -21
- package/lib/commands/repair-handoff.js +21 -18
- package/lib/commands/resolve-conflict.js +5 -2
- package/lib/commands/review.js +1 -0
- package/lib/commands/setup-review.js +1 -0
- package/lib/commands/stats-backfill.js +50 -41
- package/lib/commands/stats.js +653 -204
- package/lib/commands/status.js +33 -28
- package/lib/core/fmt.js +58 -15
- package/lib/core/git.js +7 -5
- package/lib/core/gitignore.js +6 -4
- package/lib/core/mission-utils.js +161 -88
- package/lib/core/nels.js +199 -0
- package/lib/core/persistent-data-migration.js +55 -23
- package/lib/core/product-config.js +44 -23
- package/lib/core/runtime-matrix.js +15 -2
- package/lib/core/spawn-tee.js +40 -13
- package/lib/core/state-map.js +32 -13
- package/lib/core/storage.js +19 -5
- package/lib/core/subagent-limit.js +28 -0
- package/lib/core/verification.js +29 -12
- package/lib/review/rebase.js +11 -3
- package/lib/review/review-adapter.js +93 -6
- package/lib/review/review-artifacts.js +89 -23
- package/lib/review/review-commands.js +139 -45
- package/lib/review/review-events.js +103 -52
- package/lib/review/review-loop.js +117 -87
- package/lib/review/review-polling.js +25 -5
- package/lib/review/review-prompts.js +36 -5
- package/lib/review/review-state.js +39 -15
- package/lib/tools/backlog.js +162 -71
- package/lib/tools/forgejo.js +290 -129
- package/lib/tools/gatekeeper.js +15 -0
- package/lib/tools/redgreen.js +220 -0
- package/lib/tools/sessions.js +12 -5
- package/lib/tools/setup-review.js +170 -70
- package/package.json +11 -3
- package/prompts/draft.md +6 -0
- package/templates/mission-scaffold.md +1 -1
package/lib/core/spawn-tee.js
CHANGED
|
@@ -11,13 +11,19 @@ const path = require('path');
|
|
|
11
11
|
// context land in the buffer even if the agent printed a lot before exiting.
|
|
12
12
|
const DEFAULT_MAX_TAIL_BYTES = 64 * 1024;
|
|
13
13
|
|
|
14
|
+
/** @typedef {Buffer|string} ChunkType */
|
|
14
15
|
class TailBuffer {
|
|
16
|
+
/** @param {number} maxBytes */
|
|
15
17
|
constructor(maxBytes) {
|
|
18
|
+
/** @type {number} */
|
|
16
19
|
this.maxBytes = maxBytes;
|
|
20
|
+
/** @type {ChunkType[]} */
|
|
17
21
|
this.chunks = [];
|
|
22
|
+
/** @type {number} */
|
|
18
23
|
this.size = 0;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/** @param {ChunkType} chunk */
|
|
21
27
|
push(chunk) {
|
|
22
28
|
this.chunks.push(chunk);
|
|
23
29
|
this.size += chunk.length;
|
|
@@ -28,15 +34,20 @@ class TailBuffer {
|
|
|
28
34
|
this.chunks.shift();
|
|
29
35
|
this.size -= head.length;
|
|
30
36
|
} else {
|
|
31
|
-
|
|
37
|
+
if (Buffer.isBuffer(head)) {
|
|
38
|
+
this.chunks[0] = head.subarray(overflow);
|
|
39
|
+
} else {
|
|
40
|
+
this.chunks.shift();
|
|
41
|
+
this.size -= head.length;
|
|
42
|
+
}
|
|
32
43
|
this.size -= overflow;
|
|
33
44
|
}
|
|
34
45
|
}
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
toString() {
|
|
38
|
-
if (this.chunks.length === 0) return '';
|
|
39
|
-
return Buffer.concat(this.chunks).toString('utf8');
|
|
49
|
+
if (this.chunks.length === 0) {return '';}
|
|
50
|
+
return Buffer.concat(/** @type {Buffer[]} */(this.chunks)).toString('utf8');
|
|
40
51
|
}
|
|
41
52
|
}
|
|
42
53
|
|
|
@@ -51,6 +62,11 @@ class TailBuffer {
|
|
|
51
62
|
*
|
|
52
63
|
* Pure I/O wrapper: no policy, no retry, no agent knowledge.
|
|
53
64
|
*/
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} command
|
|
67
|
+
* @param {string[]} args
|
|
68
|
+
* @param {{stdoutSink?: NodeJS.WriteStream, stderrSink?: NodeJS.WriteStream, maxTailBytes?: number, noOutputWatchdog?: {onNoOutput?: Function, initialDelayMs?: number, intervalMs?: number}, cwd?: string, env?: Record<string, string>, [key: string]: any}} [options]
|
|
69
|
+
*/
|
|
54
70
|
function spawnAndTee(command, args, options = {}) {
|
|
55
71
|
const {
|
|
56
72
|
stdoutSink = process.stdout,
|
|
@@ -63,8 +79,11 @@ function spawnAndTee(command, args, options = {}) {
|
|
|
63
79
|
return new Promise((resolve) => {
|
|
64
80
|
const stdoutTail = new TailBuffer(maxTailBytes);
|
|
65
81
|
const stderrTail = new TailBuffer(maxTailBytes);
|
|
82
|
+
/** @type {boolean} */
|
|
66
83
|
let settled = false;
|
|
84
|
+
/** @type {boolean} */
|
|
67
85
|
let sawOutput = false;
|
|
86
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
68
87
|
let watchdogTimer = null;
|
|
69
88
|
const startTime = Date.now();
|
|
70
89
|
const resolvedCwd = path.resolve(spawnOptions.cwd || process.cwd());
|
|
@@ -96,8 +115,9 @@ function spawnAndTee(command, args, options = {}) {
|
|
|
96
115
|
}
|
|
97
116
|
};
|
|
98
117
|
|
|
118
|
+
/** @param {{status: number|null, signal: string|null, stdout: string, stderr: string, error: any, startedAt: string, endedAt: string}} payload */
|
|
99
119
|
const finish = (payload) => {
|
|
100
|
-
if (settled) return;
|
|
120
|
+
if (settled) {return;}
|
|
101
121
|
settled = true;
|
|
102
122
|
clearWatchdog();
|
|
103
123
|
payload.startedAt = new Date(startTime).toISOString();
|
|
@@ -105,19 +125,22 @@ function spawnAndTee(command, args, options = {}) {
|
|
|
105
125
|
resolve(payload);
|
|
106
126
|
};
|
|
107
127
|
|
|
128
|
+
/** @param {number} delayMs */
|
|
108
129
|
const scheduleWatchdog = (delayMs) => {
|
|
109
|
-
if (!noOutputWatchdog || typeof noOutputWatchdog.onNoOutput !== 'function') return;
|
|
130
|
+
if (!noOutputWatchdog || typeof noOutputWatchdog.onNoOutput !== 'function') {return;}
|
|
110
131
|
const delay = Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 0;
|
|
111
132
|
watchdogTimer = setTimeout(() => {
|
|
112
133
|
watchdogTimer = null;
|
|
113
|
-
if (settled || sawOutput) return;
|
|
114
|
-
noOutputWatchdog.onNoOutput
|
|
134
|
+
if (settled || sawOutput) {return;}
|
|
135
|
+
if (typeof noOutputWatchdog.onNoOutput === 'function') {
|
|
136
|
+
noOutputWatchdog.onNoOutput({
|
|
115
137
|
command,
|
|
116
138
|
args,
|
|
117
139
|
pid: child.pid,
|
|
118
140
|
elapsedMs: Date.now() - startTime
|
|
119
141
|
});
|
|
120
|
-
|
|
142
|
+
}
|
|
143
|
+
scheduleWatchdog(noOutputWatchdog.intervalMs ?? 0);
|
|
121
144
|
}, delay);
|
|
122
145
|
if (typeof watchdogTimer.unref === 'function') {
|
|
123
146
|
watchdogTimer.unref();
|
|
@@ -130,7 +153,7 @@ function spawnAndTee(command, args, options = {}) {
|
|
|
130
153
|
};
|
|
131
154
|
|
|
132
155
|
if (noOutputWatchdog) {
|
|
133
|
-
scheduleWatchdog(noOutputWatchdog.initialDelayMs);
|
|
156
|
+
scheduleWatchdog(noOutputWatchdog.initialDelayMs ?? 0);
|
|
134
157
|
}
|
|
135
158
|
|
|
136
159
|
child.stdout.on('data', (chunk) => {
|
|
@@ -148,23 +171,27 @@ function spawnAndTee(command, args, options = {}) {
|
|
|
148
171
|
}
|
|
149
172
|
});
|
|
150
173
|
|
|
151
|
-
child.on('error', (err) => {
|
|
174
|
+
child.on('error', (/** @type {Error} */ err) => {
|
|
152
175
|
finish({
|
|
153
176
|
status: null,
|
|
154
177
|
signal: null,
|
|
155
178
|
stdout: stdoutTail.toString(),
|
|
156
179
|
stderr: stderrTail.toString(),
|
|
157
|
-
error: err
|
|
180
|
+
error: err,
|
|
181
|
+
startedAt: new Date(startTime).toISOString(),
|
|
182
|
+
endedAt: new Date().toISOString()
|
|
158
183
|
});
|
|
159
184
|
});
|
|
160
185
|
|
|
161
|
-
child.on('close', (code, signal) => {
|
|
186
|
+
child.on('close', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
|
|
162
187
|
finish({
|
|
163
188
|
status: code,
|
|
164
189
|
signal,
|
|
165
190
|
stdout: stdoutTail.toString(),
|
|
166
191
|
stderr: stderrTail.toString(),
|
|
167
|
-
error: null
|
|
192
|
+
error: null,
|
|
193
|
+
startedAt: new Date(startTime).toISOString(),
|
|
194
|
+
endedAt: new Date().toISOString()
|
|
168
195
|
});
|
|
169
196
|
});
|
|
170
197
|
});
|
package/lib/core/state-map.js
CHANGED
|
@@ -4,25 +4,29 @@ const { loadEffectiveConfig } = require('./product-config');
|
|
|
4
4
|
|
|
5
5
|
const SHIPPED_STATE_MAP_PATH = path.join(__dirname, '..', '..', 'config', 'state-map.json');
|
|
6
6
|
|
|
7
|
+
/** @param {string} rootDir @param {string} repoRelativePath */
|
|
7
8
|
function resolveRepoRelativePath(rootDir, repoRelativePath) {
|
|
8
|
-
if (!repoRelativePath || typeof repoRelativePath !== 'string') return null;
|
|
9
|
+
if (!repoRelativePath || typeof repoRelativePath !== 'string') {return null;}
|
|
9
10
|
return path.isAbsolute(repoRelativePath)
|
|
10
11
|
? repoRelativePath
|
|
11
12
|
: path.join(rootDir, repoRelativePath);
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/** @param {{rootDir?: string, config?: any, fallbackPath?: string}|string} [options] */
|
|
14
16
|
function resolveStateMapPath(options = {}) {
|
|
15
|
-
if (typeof options === 'string') return options;
|
|
17
|
+
if (typeof options === 'string') {return options;}
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
+
/** @type {{rootDir?: string, config?: any, fallbackPath?: string}} */
|
|
20
|
+
const opts = options;
|
|
21
|
+
const rootDir = opts.rootDir || process.cwd();
|
|
22
|
+
const config = opts.config || loadEffectiveConfig(rootDir);
|
|
19
23
|
const configuredPath = config.adapters?.tasks?.stateMap;
|
|
20
24
|
const repoPath = resolveRepoRelativePath(rootDir, configuredPath);
|
|
21
25
|
|
|
22
26
|
if (repoPath && fs.existsSync(repoPath)) {
|
|
23
27
|
return repoPath;
|
|
24
28
|
}
|
|
25
|
-
return
|
|
29
|
+
return opts.fallbackPath || SHIPPED_STATE_MAP_PATH;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
function loadStateMap(options = {}) {
|
|
@@ -34,14 +38,16 @@ function loadStateMap(options = {}) {
|
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
/** @param {unknown} value */
|
|
37
42
|
function normalizeState(value) {
|
|
38
|
-
if (typeof value !== 'string') return value;
|
|
43
|
+
if (typeof value !== 'string') {return value;}
|
|
39
44
|
return value.trim().toLowerCase();
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
// Virtual workflow state → actual backlog.md state name.
|
|
43
48
|
// Returns null if the virtual state explicitly maps to null (no backlog.md write).
|
|
44
49
|
// Returns the virtual name unchanged if it has no entry in the map (identity).
|
|
50
|
+
/** @param {Record<string, any> | any} mapOrOptions */
|
|
45
51
|
function resolveMap(mapOrOptions) {
|
|
46
52
|
if (mapOrOptions && typeof mapOrOptions === 'object' && !Array.isArray(mapOrOptions)) {
|
|
47
53
|
if (Object.prototype.hasOwnProperty.call(mapOrOptions, 'rootDir')
|
|
@@ -53,33 +59,46 @@ function resolveMap(mapOrOptions) {
|
|
|
53
59
|
return mapOrOptions || loadStateMap();
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
/** @param {string} virtualState @param {Record<string, any>} [map] */
|
|
56
63
|
function toActual(virtualState, map = loadStateMap()) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
/** @type {Record<string, any>} */
|
|
65
|
+
const m = resolveMap(map) || {};
|
|
66
|
+
if (Object.prototype.hasOwnProperty.call(m, virtualState)) {
|
|
67
|
+
return m[virtualState];
|
|
60
68
|
}
|
|
61
69
|
return virtualState;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
// Actual backlog.md state name → virtual workflow state name.
|
|
65
73
|
// Returns the actual name unchanged if no reverse mapping exists (identity).
|
|
74
|
+
/** @param {string} actualState @param {Record<string, any>} [map] */
|
|
66
75
|
function toVirtual(actualState, map = loadStateMap()) {
|
|
67
|
-
|
|
76
|
+
/** @type {Record<string, any>} */
|
|
77
|
+
const m = resolveMap(map) || {};
|
|
68
78
|
const normalizedActual = normalizeState(actualState);
|
|
69
|
-
for (const [virtual, actual] of Object.entries(
|
|
70
|
-
if (normalizeState(actual) === normalizedActual) return virtual;
|
|
79
|
+
for (const [virtual, actual] of Object.entries(m)) {
|
|
80
|
+
if (normalizeState(actual) === normalizedActual) {return virtual;}
|
|
71
81
|
}
|
|
72
82
|
return actualState;
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
// Wraps a transitionTask call: resolves virtual → actual, then delegates.
|
|
76
86
|
// If the virtual state maps to null, logs an info line and returns true (no-op).
|
|
87
|
+
/**
|
|
88
|
+
* @param {Function} transitionTaskFn
|
|
89
|
+
* @param {string} slug
|
|
90
|
+
* @param {string} virtualState
|
|
91
|
+
* @param {{log?: Function}} [options]
|
|
92
|
+
* @param {Record<string, any>} [map]
|
|
93
|
+
*/
|
|
77
94
|
function transitionVirtual(transitionTaskFn, slug, virtualState, options = {}, map = loadStateMap(options)) {
|
|
78
95
|
map = resolveMap(map);
|
|
79
96
|
const actual = toActual(virtualState, map);
|
|
80
97
|
if (actual === null) {
|
|
81
98
|
const fmt = require('./fmt');
|
|
82
|
-
|
|
99
|
+
/** @type {{log?: Function}} */
|
|
100
|
+
const opts = options;
|
|
101
|
+
const log = opts.log || fmt.log.plain;
|
|
83
102
|
log(fmt.status('INFO', `Virtual state '${virtualState}' has no backlog.md mapping for this board; skipping status write.`));
|
|
84
103
|
return true;
|
|
85
104
|
}
|
package/lib/core/storage.js
CHANGED
|
@@ -17,18 +17,23 @@ const path = require('path');
|
|
|
17
17
|
* call with `ensureDir: true`). Read-side callers may omit ensureDir so they
|
|
18
18
|
* fail gracefully when PARALLIX_HOME has never been initialized.
|
|
19
19
|
*/
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ensureDir?: boolean, platform?: string, env?: Record<string,string>, homedir?: Function} | boolean | string} [options]
|
|
22
|
+
*/
|
|
20
23
|
function resolveParallixHome(options = {}) {
|
|
21
24
|
if (typeof options === 'boolean' || typeof options === 'string') {
|
|
22
25
|
// Legacy shim: resolveParallixHome(true) === { ensureDir: true }
|
|
23
|
-
options = { ensureDir: options };
|
|
26
|
+
options = { ensureDir: Boolean(options) };
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
/** @type {{ensureDir?: boolean, platform?: string, env?: Record<string,string>, homedir?: Function}} */
|
|
30
|
+
const opts = /** @type {{ensureDir?: boolean, platform?: string, env?: Record<string,string>, homedir?: Function}} */(options);
|
|
26
31
|
const {
|
|
27
32
|
ensureDir = false,
|
|
28
33
|
platform = process.platform,
|
|
29
34
|
env = process.env,
|
|
30
35
|
homedir = os.homedir
|
|
31
|
-
} =
|
|
36
|
+
} = opts;
|
|
32
37
|
|
|
33
38
|
let home;
|
|
34
39
|
|
|
@@ -67,8 +72,11 @@ function resolveParallixHome(options = {}) {
|
|
|
67
72
|
* is true). Callers that pass an explicit `--csv-file` path bypass this
|
|
68
73
|
* resolver entirely — the caller supplies the path before calling here.
|
|
69
74
|
*/
|
|
75
|
+
/** @param {{ensureDir?: boolean, warn?: Function}} [options] */
|
|
70
76
|
function resolveStatsPath(options = {}) {
|
|
71
|
-
|
|
77
|
+
/** @type {{ensureDir?: boolean, warn?: Function}} */
|
|
78
|
+
const opts = options;
|
|
79
|
+
const home = resolveParallixHome({ ensureDir: opts.ensureDir !== false });
|
|
72
80
|
return path.join(home, 'stats.csv');
|
|
73
81
|
}
|
|
74
82
|
|
|
@@ -78,11 +86,14 @@ function resolveStatsPath(options = {}) {
|
|
|
78
86
|
* Returns `<PARALLIX_HOME>/agents.local.json`. Callers that pass an
|
|
79
87
|
* explicit `targetPath` bypass this resolver.
|
|
80
88
|
*/
|
|
89
|
+
/** @param {{ensureDir?: boolean} | string} [options] */
|
|
81
90
|
function resolveAgentsLocalPath(options = {}) {
|
|
82
91
|
if (typeof options === 'string') {
|
|
83
92
|
return path.resolve(options);
|
|
84
93
|
}
|
|
85
|
-
|
|
94
|
+
/** @type {{ensureDir?: boolean}} */
|
|
95
|
+
const opts = options;
|
|
96
|
+
const home = resolveParallixHome({ ensureDir: opts.ensureDir !== false });
|
|
86
97
|
return path.join(home, 'agents.local.json');
|
|
87
98
|
}
|
|
88
99
|
|
|
@@ -94,6 +105,7 @@ function resolveAgentsLocalPath(options = {}) {
|
|
|
94
105
|
* Malformed JSON returns `{ ok: false, error }` rather than throwing so
|
|
95
106
|
* callers can decide whether this is a hard failure.
|
|
96
107
|
*/
|
|
108
|
+
/** @param {string | (() => string)} pathOrResolution */
|
|
97
109
|
function readJson(pathOrResolution) {
|
|
98
110
|
let filePath;
|
|
99
111
|
if (typeof pathOrResolution === 'function') {
|
|
@@ -119,6 +131,7 @@ function readJson(pathOrResolution) {
|
|
|
119
131
|
* Write JSON to a path under PARALLIX_HOME (or an explicit path).
|
|
120
132
|
* Creates parent directories as needed.
|
|
121
133
|
*/
|
|
134
|
+
/** @param {string | (() => string)} filePath @param {any} data */
|
|
122
135
|
function writeJson(filePath, data) {
|
|
123
136
|
if (typeof filePath === 'function') {
|
|
124
137
|
filePath = filePath();
|
|
@@ -127,6 +140,7 @@ function writeJson(filePath, data) {
|
|
|
127
140
|
return filePath;
|
|
128
141
|
}
|
|
129
142
|
|
|
143
|
+
/** @param {string} filePath @param {string} content */
|
|
130
144
|
function writeFileAtomic(filePath, content) {
|
|
131
145
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
132
146
|
const tempPath = path.join(
|
|
@@ -137,7 +151,7 @@ function writeFileAtomic(filePath, content) {
|
|
|
137
151
|
fs.writeFileSync(tempPath, content, 'utf8');
|
|
138
152
|
fs.renameSync(tempPath, filePath);
|
|
139
153
|
} finally {
|
|
140
|
-
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
154
|
+
if (fs.existsSync(tempPath)) {fs.unlinkSync(tempPath);}
|
|
141
155
|
}
|
|
142
156
|
}
|
|
143
157
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { loadEffectiveConfig } = require('./product-config');
|
|
2
|
+
|
|
3
|
+
// Build a prompt-injection prefix that instructs the agent to limit
|
|
4
|
+
// concurrent subagent forks. Returns a non-empty advisory string when
|
|
5
|
+
// maxParallel >= 1, or an empty string when the setting is absent, null,
|
|
6
|
+
// zero, or negative (backward-compatible default).
|
|
7
|
+
//
|
|
8
|
+
// Prefix length target: under 200 characters.
|
|
9
|
+
//
|
|
10
|
+
// @param {number | null | undefined} [maxParallel] — explicit value override;
|
|
11
|
+
// when omitted the function reads from the effective workflow config.
|
|
12
|
+
/** @param {number | null | undefined} [maxParallel] */
|
|
13
|
+
function buildSubagentLimitPrefix(maxParallel) {
|
|
14
|
+
let value = maxParallel;
|
|
15
|
+
if (value === undefined || value === null) {
|
|
16
|
+
const cfg = loadEffectiveConfig();
|
|
17
|
+
const sub = cfg.adapters?.agents?.subagents;
|
|
18
|
+
value = sub && typeof sub.maxParallel === 'number' ? sub.maxParallel : null;
|
|
19
|
+
}
|
|
20
|
+
if (typeof value !== 'number' || value < 1) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
return `Do not spawn more than ${value} parallel subagents. If you need more, pause and wait.\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
buildSubagentLimitPrefix,
|
|
28
|
+
};
|
package/lib/core/verification.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const { git, run } = require('./git');
|
|
2
|
+
|
|
3
|
+
/** @typedef {(args: string[], options?: object) => import('child_process').SpawnSyncReturns<string>} GitFn */
|
|
2
4
|
const { loadAdapterConfig } = require('./product-config');
|
|
3
5
|
const fs = require('fs');
|
|
4
6
|
|
|
@@ -23,33 +25,40 @@ function resolveVerificationAdapter(rootDir = process.cwd()) {
|
|
|
23
25
|
return { command, defaultArea };
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/** @param {string} [area] @param {string} [rootDir] */
|
|
26
29
|
function formatVerificationCommand(area, rootDir = process.cwd()) {
|
|
27
30
|
const { command, defaultArea } = resolveVerificationAdapter(rootDir);
|
|
28
31
|
const effectiveArea = area || defaultArea;
|
|
29
32
|
if (!command) {
|
|
30
33
|
return NO_GATE_NOTICE;
|
|
31
34
|
}
|
|
32
|
-
return command.replaceAll('{{area}}', effectiveArea);
|
|
35
|
+
return command.replaceAll('{{area}}', effectiveArea || DEFAULT_AREA);
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
/** @param {string} [area] @param {{rootDir?: string, log?: Function, stdio?: string, runFn?: Function}} [options] */
|
|
35
39
|
function runVerificationGate(area, options = {}) {
|
|
36
|
-
|
|
40
|
+
/** @type {{rootDir?: string, log?: Function, stdio?: string, runFn?: Function}} */
|
|
41
|
+
const opts = options;
|
|
42
|
+
const rootDir = opts.rootDir || process.cwd();
|
|
37
43
|
const { command, defaultArea } = resolveVerificationAdapter(rootDir);
|
|
38
44
|
const effectiveArea = area || defaultArea;
|
|
39
45
|
|
|
40
46
|
if (!command) {
|
|
41
|
-
const info =
|
|
47
|
+
const info = opts.log || require('./fmt').log.info;
|
|
42
48
|
info(`No verification gate configured for area: ${effectiveArea}; default is no validation. `
|
|
43
49
|
+ 'Set adapters.verification.command in workflow.config.json to enforce one.');
|
|
44
50
|
return { status: 0 };
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
const stdio =
|
|
48
|
-
const runFn =
|
|
53
|
+
const stdio = opts.stdio || 'inherit';
|
|
54
|
+
const runFn = opts.runFn || run;
|
|
49
55
|
return runFn('bash', ['-lc', command.replaceAll('{{area}}', effectiveArea)], { cwd: rootDir, stdio });
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
|
|
58
|
+
/** @param {string} rootDir @param {{gitRunner?: GitFn}} [options] */
|
|
59
|
+
function readPublishedTreeState(rootDir, options = {}) {
|
|
60
|
+
/** @type {GitFn} */
|
|
61
|
+
const gitRunner = options.gitRunner || git;
|
|
53
62
|
const resolvedRoot = fs.realpathSync(rootDir);
|
|
54
63
|
const commitResult = gitRunner(['-C', resolvedRoot, 'rev-parse', 'HEAD']);
|
|
55
64
|
const treeResult = gitRunner(['-C', resolvedRoot, 'rev-parse', 'HEAD^{tree}']);
|
|
@@ -66,6 +75,7 @@ function readPublishedTreeState(rootDir = process.cwd(), { gitRunner = git } = {
|
|
|
66
75
|
return { ok: true, rootDir: resolvedRoot, commit, tree };
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
/** @param {string} [area] @param {string} [rootDir] @param {{gitRunner?: GitFn, runFn?: Function, stdio?: string}} [options] */
|
|
69
79
|
function captureVerifiedTreeProof(area, rootDir = process.cwd(), options = {}) {
|
|
70
80
|
const {
|
|
71
81
|
gitRunner = git,
|
|
@@ -74,7 +84,7 @@ function captureVerifiedTreeProof(area, rootDir = process.cwd(), options = {}) {
|
|
|
74
84
|
} = options;
|
|
75
85
|
|
|
76
86
|
const before = readPublishedTreeState(rootDir, { gitRunner });
|
|
77
|
-
if (!before.ok) return before;
|
|
87
|
+
if (!before.ok) {return before;}
|
|
78
88
|
|
|
79
89
|
const verification = runVerificationGate(area, {
|
|
80
90
|
rootDir: before.rootDir,
|
|
@@ -88,8 +98,8 @@ function captureVerifiedTreeProof(area, rootDir = process.cwd(), options = {}) {
|
|
|
88
98
|
};
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
const after = readPublishedTreeState(before.rootDir, { gitRunner });
|
|
92
|
-
if (!after.ok) return after;
|
|
101
|
+
const after = readPublishedTreeState(/** @type {string} */(before.rootDir), { gitRunner });
|
|
102
|
+
if (!after.ok) {return after;}
|
|
93
103
|
if (after.commit !== before.commit || after.tree !== before.tree) {
|
|
94
104
|
return {
|
|
95
105
|
ok: false,
|
|
@@ -113,13 +123,17 @@ function captureVerifiedTreeProof(area, rootDir = process.cwd(), options = {}) {
|
|
|
113
123
|
};
|
|
114
124
|
}
|
|
115
125
|
|
|
116
|
-
|
|
126
|
+
/** @param {{rootDir?: string, commit?: string, tree?: string}} proof @param {string} [rootDir] @param {{gitRunner?: GitFn}} [opts] */
|
|
127
|
+
function assertVerifiedTreeProof(proof, rootDir = process.cwd(), opts = {}) {
|
|
117
128
|
if (!proof || typeof proof !== 'object') {
|
|
118
129
|
return { ok: false, error: 'missing verification proof' };
|
|
119
130
|
}
|
|
120
131
|
|
|
132
|
+
/** @type {{gitRunner?: GitFn}} */
|
|
133
|
+
const o = opts;
|
|
134
|
+
const gitRunner = o.gitRunner || git;
|
|
121
135
|
const current = readPublishedTreeState(rootDir, { gitRunner });
|
|
122
|
-
if (!current.ok) return current;
|
|
136
|
+
if (!current.ok) {return current;}
|
|
123
137
|
|
|
124
138
|
if (proof.rootDir !== current.rootDir) {
|
|
125
139
|
return { ok: false, error: `verification proof was captured from a different checkout: ${proof.rootDir}` };
|
|
@@ -131,8 +145,11 @@ function assertVerifiedTreeProof(proof, rootDir = process.cwd(), { gitRunner = g
|
|
|
131
145
|
return { ok: true, proof: current };
|
|
132
146
|
}
|
|
133
147
|
|
|
148
|
+
/** @param {string[]} args @param {{log?: Function}} [options] */
|
|
134
149
|
function runWorkflow(args, options = {}) {
|
|
135
|
-
|
|
150
|
+
/** @type {{log?: Function}} */
|
|
151
|
+
const opts = options;
|
|
152
|
+
const log = opts.log || require('./fmt').log.plain;
|
|
136
153
|
const area = args[0] || process.env.VERIFY_AREA || DEFAULT_AREA;
|
|
137
154
|
log(`Running verification gate for area: ${area}...`);
|
|
138
155
|
return runVerificationGate(area, { stdio: 'inherit' });
|
package/lib/review/rebase.js
CHANGED
|
@@ -18,6 +18,10 @@ const SHARED_FILE_REBASE_CONFLICT_RE = /shared file(?:\(s\))? require agent-assi
|
|
|
18
18
|
/**
|
|
19
19
|
* Auto-commit safe mission artifacts before rebase.
|
|
20
20
|
* Only touches mission-generated files, task files, and stats CSV.
|
|
21
|
+
* @param {string} slug
|
|
22
|
+
* @param {string} worktree
|
|
23
|
+
* @param {{taskFile?: string|null, gitFn?: Function, log?: Function, error?: Function, isMissionArtifactFn?: Function, isWorkflowGeneratedArtifactFn?: Function, resolveStatsRelPathFn?: Function}} [options]
|
|
24
|
+
* @returns {Promise<{ok: boolean, dirty: boolean, unsafe?: boolean}>}
|
|
21
25
|
*/
|
|
22
26
|
async function commitSafeMissionArtifacts(slug, worktree, {
|
|
23
27
|
taskFile = null,
|
|
@@ -34,13 +38,14 @@ async function commitSafeMissionArtifacts(slug, worktree, {
|
|
|
34
38
|
return { ok: true, dirty: false };
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
const parsePorcelainZ = (stdout) => {
|
|
41
|
+
const parsePorcelainZ = (/** @type {string|Buffer} */ stdout) => {
|
|
38
42
|
const entries = String(stdout).split('\0').filter(Boolean);
|
|
39
43
|
const dirty = [];
|
|
40
44
|
for (let i = 0; i < entries.length; i += 1) {
|
|
41
45
|
const entry = entries[i];
|
|
42
46
|
const xy = entry.slice(0, 2);
|
|
43
47
|
const file = entry.slice(3);
|
|
48
|
+
/** @type {{xy: string, file: string, paths: string[], source?: string}} */
|
|
44
49
|
const record = { xy, file, paths: [file] };
|
|
45
50
|
if ((xy[0] === 'R' || xy[0] === 'C') && i + 1 < entries.length) {
|
|
46
51
|
const source = entries[i + 1];
|
|
@@ -63,7 +68,7 @@ async function commitSafeMissionArtifacts(slug, worktree, {
|
|
|
63
68
|
|
|
64
69
|
const resolvedTaskFile = taskFile ? path.relative(rootDir, taskFile) : null;
|
|
65
70
|
const statsRelPath = resolveStatsRelPathFn(rootDir);
|
|
66
|
-
const isSafeToCommit = (file) =>
|
|
71
|
+
const isSafeToCommit = (/** @type {string} */ file) =>
|
|
67
72
|
isWorkflowGeneratedArtifactFn(file)
|
|
68
73
|
|| isMissionArtifactFn(file, slug, rootDir)
|
|
69
74
|
|| (resolvedTaskFile && file === resolvedTaskFile)
|
|
@@ -71,7 +76,7 @@ async function commitSafeMissionArtifacts(slug, worktree, {
|
|
|
71
76
|
|
|
72
77
|
const unsafeFiles = dirtyFiles
|
|
73
78
|
.flatMap(f => f.paths)
|
|
74
|
-
.filter(file => !isSafeToCommit(file));
|
|
79
|
+
.filter((/** @type {string} */ file) => !isSafeToCommit(file));
|
|
75
80
|
if (unsafeFiles.length > 0) {
|
|
76
81
|
error(fmt.status('FAIL', `Cannot auto-commit: dirty files include non-mission paths:`));
|
|
77
82
|
unsafeFiles.forEach(file => error(` - ${file}`));
|
|
@@ -100,6 +105,9 @@ async function commitSafeMissionArtifacts(slug, worktree, {
|
|
|
100
105
|
*
|
|
101
106
|
* Always commits safe worktree state first, then rebases via the workflow CLI.
|
|
102
107
|
* In standalone (provider-disabled) mode, skips the rebase after committing.
|
|
108
|
+
* @param {string} slug
|
|
109
|
+
* @param {{runFn?: Function, gitFn?: Function, taskFile?: string|null, worktree?: string, log?: Function, error?: Function, isReviewProviderEnabledFn?: Function|null, legacyIsForgejoReviewEnabledFn?: Function|null, isForgejoReviewEnabledFn?: Function|null}} [options]
|
|
110
|
+
* @returns {Promise<{ok: boolean, sharedFileConflicts: boolean}>}
|
|
103
111
|
*/
|
|
104
112
|
async function rebaseBeforeReviewRound(slug, {
|
|
105
113
|
runFn = require('child_process').spawnSync,
|