@magnusekdahl/parallix 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- package/tools/setup-forgejo-docker.sh +84 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { eligibleAgentsForStep, workflowLauncherStatus } = require('../agents/agents');
|
|
4
|
+
|
|
5
|
+
// Agent eligibility and selection are config-driven: parallix/config/agents.json
|
|
6
|
+
// declares which agents are eligible per step, and each agent's launcher is
|
|
7
|
+
// discovered by the RESOLVERS in agents.js (which resolve launchers as bare
|
|
8
|
+
// executable names on PATH). This module no longer ships hardcoded
|
|
9
|
+
// binary paths.
|
|
10
|
+
const CONFIG_PATH = path.join(__dirname, '..', '..', 'config', 'agents.json');
|
|
11
|
+
|
|
12
|
+
// Thin diagnostic wrapper over agents.js launcher discovery. Kept so the runtime
|
|
13
|
+
// matrix reports exactly the discovery the launcher actually uses (RESOLVERS),
|
|
14
|
+
// instead of a divergent default-path mechanism.
|
|
15
|
+
function launcherStatus(agent, options = {}) {
|
|
16
|
+
const { workflowLauncherStatusFn = workflowLauncherStatus } = options;
|
|
17
|
+
return workflowLauncherStatusFn(agent);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildAutonomousReviewMatrix(options = {}) {
|
|
21
|
+
const {
|
|
22
|
+
step = 'review',
|
|
23
|
+
eligibleAgentsForStepFn = eligibleAgentsForStep,
|
|
24
|
+
workflowLauncherStatusFn = workflowLauncherStatus,
|
|
25
|
+
configPath = CONFIG_PATH,
|
|
26
|
+
existsSyncFn = fs.existsSync
|
|
27
|
+
} = options;
|
|
28
|
+
|
|
29
|
+
const agents = eligibleAgentsForStepFn(step);
|
|
30
|
+
const launchers = Object.fromEntries(
|
|
31
|
+
agents.map(agent => [agent, workflowLauncherStatusFn(agent)])
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
step,
|
|
36
|
+
agents,
|
|
37
|
+
configPath,
|
|
38
|
+
configPresent: existsSyncFn(configPath),
|
|
39
|
+
launchers
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatMatrixSummary(matrix) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
|
|
46
|
+
lines.push(`Agent eligibility config: ${matrix.configPresent ? 'present' : 'missing'} (${matrix.configPath})`);
|
|
47
|
+
lines.push(`Launcher support matrix (step: ${matrix.step}):`);
|
|
48
|
+
for (const agent of matrix.agents) {
|
|
49
|
+
const launcher = matrix.launchers[agent];
|
|
50
|
+
const status = launcher.supported ? 'supported' : 'blocked';
|
|
51
|
+
const health = launcher.health ? `, ${launcher.health}` : '';
|
|
52
|
+
const reason = launcher.reason ? `; ${launcher.reason}` : '';
|
|
53
|
+
lines.push(` - ${agent}: ${status} (${launcher.detail}${health}${reason})`);
|
|
54
|
+
}
|
|
55
|
+
lines.push(
|
|
56
|
+
'Reviewer is chosen at runtime from the eligible-and-supported pool, ' +
|
|
57
|
+
'excluding the implementer (config-driven via agents.json; no hardcoded routing).'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns true if any eligible agent other than `implementer` has a supported launcher.
|
|
65
|
+
* Used by the review loop to decide whether single-family fallback is authorized.
|
|
66
|
+
*/
|
|
67
|
+
function runnableDifferentFamilyExists(implementer, options = {}) {
|
|
68
|
+
const {
|
|
69
|
+
step = 'review',
|
|
70
|
+
eligibleAgentsForStepFn = eligibleAgentsForStep,
|
|
71
|
+
workflowLauncherStatusFn = workflowLauncherStatus
|
|
72
|
+
} = options;
|
|
73
|
+
const agents = eligibleAgentsForStepFn(step);
|
|
74
|
+
return agents.some(a => a !== implementer && workflowLauncherStatusFn(a).supported);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
launcherStatus,
|
|
79
|
+
buildAutonomousReviewMatrix,
|
|
80
|
+
formatMatrixSummary,
|
|
81
|
+
runnableDifferentFamilyExists
|
|
82
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const childProcess = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Bound the in-memory transcript so a noisy or long-running agent cannot
|
|
7
|
+
// turn the harness into an O(total-output) memory hog. Detection only needs
|
|
8
|
+
// a recent window to find limit-hit phrases (limit-hit.js clips ~200 chars
|
|
9
|
+
// around the match), so a tail buffer is sufficient. The cap is generous
|
|
10
|
+
// enough that a real limit-hit message and its surrounding reset-time
|
|
11
|
+
// context land in the buffer even if the agent printed a lot before exiting.
|
|
12
|
+
const DEFAULT_MAX_TAIL_BYTES = 64 * 1024;
|
|
13
|
+
|
|
14
|
+
class TailBuffer {
|
|
15
|
+
constructor(maxBytes) {
|
|
16
|
+
this.maxBytes = maxBytes;
|
|
17
|
+
this.chunks = [];
|
|
18
|
+
this.size = 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
push(chunk) {
|
|
22
|
+
this.chunks.push(chunk);
|
|
23
|
+
this.size += chunk.length;
|
|
24
|
+
while (this.size > this.maxBytes && this.chunks.length > 0) {
|
|
25
|
+
const head = this.chunks[0];
|
|
26
|
+
const overflow = this.size - this.maxBytes;
|
|
27
|
+
if (head.length <= overflow) {
|
|
28
|
+
this.chunks.shift();
|
|
29
|
+
this.size -= head.length;
|
|
30
|
+
} else {
|
|
31
|
+
this.chunks[0] = head.subarray(overflow);
|
|
32
|
+
this.size -= overflow;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
toString() {
|
|
38
|
+
if (this.chunks.length === 0) return '';
|
|
39
|
+
return Buffer.concat(this.chunks).toString('utf8');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Spawn a child process and tee its stdout/stderr to the parent's streams
|
|
45
|
+
* while keeping a bounded tail of the output in memory. Returns a Promise
|
|
46
|
+
* resolving with a result shaped like `spawnSync` (status, signal, stdout,
|
|
47
|
+
* stderr, error) but with stdout/stderr as utf-8 strings populated even
|
|
48
|
+
* when stdio:'inherit' is requested. Forces stdio to ['inherit', 'pipe',
|
|
49
|
+
* 'pipe'] internally so the parent's stdin is preserved while child output
|
|
50
|
+
* remains capturable.
|
|
51
|
+
*
|
|
52
|
+
* Pure I/O wrapper: no policy, no retry, no agent knowledge.
|
|
53
|
+
*/
|
|
54
|
+
function spawnAndTee(command, args, options = {}) {
|
|
55
|
+
const {
|
|
56
|
+
stdoutSink = process.stdout,
|
|
57
|
+
stderrSink = process.stderr,
|
|
58
|
+
maxTailBytes = DEFAULT_MAX_TAIL_BYTES,
|
|
59
|
+
noOutputWatchdog = null,
|
|
60
|
+
...spawnOptions
|
|
61
|
+
} = options;
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const stdoutTail = new TailBuffer(maxTailBytes);
|
|
65
|
+
const stderrTail = new TailBuffer(maxTailBytes);
|
|
66
|
+
let settled = false;
|
|
67
|
+
let sawOutput = false;
|
|
68
|
+
let watchdogTimer = null;
|
|
69
|
+
const startTime = Date.now();
|
|
70
|
+
const resolvedCwd = path.resolve(spawnOptions.cwd || process.cwd());
|
|
71
|
+
const env = {
|
|
72
|
+
...process.env,
|
|
73
|
+
...(spawnOptions.env || {}),
|
|
74
|
+
PWD: resolvedCwd
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let child;
|
|
78
|
+
try {
|
|
79
|
+
child = childProcess.spawn(command, args, {
|
|
80
|
+
...spawnOptions,
|
|
81
|
+
env,
|
|
82
|
+
stdio: ['inherit', 'pipe', 'pipe']
|
|
83
|
+
});
|
|
84
|
+
} catch (err) {
|
|
85
|
+
resolve({
|
|
86
|
+
status: null, signal: null, stdout: '', stderr: '', error: err,
|
|
87
|
+
startedAt: new Date(startTime).toISOString(), endedAt: new Date().toISOString()
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const clearWatchdog = () => {
|
|
93
|
+
if (watchdogTimer) {
|
|
94
|
+
clearTimeout(watchdogTimer);
|
|
95
|
+
watchdogTimer = null;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const finish = (payload) => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearWatchdog();
|
|
103
|
+
payload.startedAt = new Date(startTime).toISOString();
|
|
104
|
+
payload.endedAt = new Date().toISOString();
|
|
105
|
+
resolve(payload);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const scheduleWatchdog = (delayMs) => {
|
|
109
|
+
if (!noOutputWatchdog || typeof noOutputWatchdog.onNoOutput !== 'function') return;
|
|
110
|
+
const delay = Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : 0;
|
|
111
|
+
watchdogTimer = setTimeout(() => {
|
|
112
|
+
watchdogTimer = null;
|
|
113
|
+
if (settled || sawOutput) return;
|
|
114
|
+
noOutputWatchdog.onNoOutput({
|
|
115
|
+
command,
|
|
116
|
+
args,
|
|
117
|
+
pid: child.pid,
|
|
118
|
+
elapsedMs: Date.now() - startTime
|
|
119
|
+
});
|
|
120
|
+
scheduleWatchdog(noOutputWatchdog.intervalMs);
|
|
121
|
+
}, delay);
|
|
122
|
+
if (typeof watchdogTimer.unref === 'function') {
|
|
123
|
+
watchdogTimer.unref();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const noteOutput = () => {
|
|
128
|
+
sawOutput = true;
|
|
129
|
+
clearWatchdog();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (noOutputWatchdog) {
|
|
133
|
+
scheduleWatchdog(noOutputWatchdog.initialDelayMs);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
child.stdout.on('data', (chunk) => {
|
|
137
|
+
noteOutput();
|
|
138
|
+
stdoutTail.push(chunk);
|
|
139
|
+
if (stdoutSink && typeof stdoutSink.write === 'function') {
|
|
140
|
+
stdoutSink.write(chunk);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
child.stderr.on('data', (chunk) => {
|
|
144
|
+
noteOutput();
|
|
145
|
+
stderrTail.push(chunk);
|
|
146
|
+
if (stderrSink && typeof stderrSink.write === 'function') {
|
|
147
|
+
stderrSink.write(chunk);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
child.on('error', (err) => {
|
|
152
|
+
finish({
|
|
153
|
+
status: null,
|
|
154
|
+
signal: null,
|
|
155
|
+
stdout: stdoutTail.toString(),
|
|
156
|
+
stderr: stderrTail.toString(),
|
|
157
|
+
error: err
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
child.on('close', (code, signal) => {
|
|
162
|
+
finish({
|
|
163
|
+
status: code,
|
|
164
|
+
signal,
|
|
165
|
+
stdout: stdoutTail.toString(),
|
|
166
|
+
stderr: stderrTail.toString(),
|
|
167
|
+
error: null
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { spawnAndTee, DEFAULT_MAX_TAIL_BYTES };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { loadEffectiveConfig } = require('./product-config');
|
|
4
|
+
|
|
5
|
+
const SHIPPED_STATE_MAP_PATH = path.join(__dirname, '..', '..', 'config', 'state-map.json');
|
|
6
|
+
|
|
7
|
+
function resolveRepoRelativePath(rootDir, repoRelativePath) {
|
|
8
|
+
if (!repoRelativePath || typeof repoRelativePath !== 'string') return null;
|
|
9
|
+
return path.isAbsolute(repoRelativePath)
|
|
10
|
+
? repoRelativePath
|
|
11
|
+
: path.join(rootDir, repoRelativePath);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveStateMapPath(options = {}) {
|
|
15
|
+
if (typeof options === 'string') return options;
|
|
16
|
+
|
|
17
|
+
const rootDir = options.rootDir || process.cwd();
|
|
18
|
+
const config = options.config || loadEffectiveConfig(rootDir);
|
|
19
|
+
const configuredPath = config.adapters?.tasks?.stateMap;
|
|
20
|
+
const repoPath = resolveRepoRelativePath(rootDir, configuredPath);
|
|
21
|
+
|
|
22
|
+
if (repoPath && fs.existsSync(repoPath)) {
|
|
23
|
+
return repoPath;
|
|
24
|
+
}
|
|
25
|
+
return options.fallbackPath || SHIPPED_STATE_MAP_PATH;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadStateMap(options = {}) {
|
|
29
|
+
const stateMapPath = resolveStateMapPath(options);
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(stateMapPath, 'utf8'));
|
|
32
|
+
} catch (_) {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeState(value) {
|
|
38
|
+
if (typeof value !== 'string') return value;
|
|
39
|
+
return value.trim().toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Virtual workflow state → actual backlog.md state name.
|
|
43
|
+
// Returns null if the virtual state explicitly maps to null (no backlog.md write).
|
|
44
|
+
// Returns the virtual name unchanged if it has no entry in the map (identity).
|
|
45
|
+
function resolveMap(mapOrOptions) {
|
|
46
|
+
if (mapOrOptions && typeof mapOrOptions === 'object' && !Array.isArray(mapOrOptions)) {
|
|
47
|
+
if (Object.prototype.hasOwnProperty.call(mapOrOptions, 'rootDir')
|
|
48
|
+
|| Object.prototype.hasOwnProperty.call(mapOrOptions, 'config')
|
|
49
|
+
|| Object.prototype.hasOwnProperty.call(mapOrOptions, 'fallbackPath')) {
|
|
50
|
+
return loadStateMap(mapOrOptions);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return mapOrOptions || loadStateMap();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toActual(virtualState, map = loadStateMap()) {
|
|
57
|
+
map = resolveMap(map);
|
|
58
|
+
if (Object.prototype.hasOwnProperty.call(map, virtualState)) {
|
|
59
|
+
return map[virtualState];
|
|
60
|
+
}
|
|
61
|
+
return virtualState;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Actual backlog.md state name → virtual workflow state name.
|
|
65
|
+
// Returns the actual name unchanged if no reverse mapping exists (identity).
|
|
66
|
+
function toVirtual(actualState, map = loadStateMap()) {
|
|
67
|
+
map = resolveMap(map);
|
|
68
|
+
const normalizedActual = normalizeState(actualState);
|
|
69
|
+
for (const [virtual, actual] of Object.entries(map)) {
|
|
70
|
+
if (normalizeState(actual) === normalizedActual) return virtual;
|
|
71
|
+
}
|
|
72
|
+
return actualState;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Wraps a transitionTask call: resolves virtual → actual, then delegates.
|
|
76
|
+
// If the virtual state maps to null, logs an info line and returns true (no-op).
|
|
77
|
+
function transitionVirtual(transitionTaskFn, slug, virtualState, options = {}, map = loadStateMap(options)) {
|
|
78
|
+
map = resolveMap(map);
|
|
79
|
+
const actual = toActual(virtualState, map);
|
|
80
|
+
if (actual === null) {
|
|
81
|
+
const fmt = require('./fmt');
|
|
82
|
+
const log = options.log || fmt.log.plain;
|
|
83
|
+
log(fmt.status('INFO', `Virtual state '${virtualState}' has no backlog.md mapping for this board; skipping status write.`));
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return transitionTaskFn(slug, actual, options);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { SHIPPED_STATE_MAP_PATH, loadStateMap, normalizeState, resolveStateMapPath, toActual, toVirtual, transitionVirtual };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the parallix-owned persistent-data root.
|
|
7
|
+
*
|
|
8
|
+
* Precedence:
|
|
9
|
+
* 1. PARALLIX_HOME env var (highest priority)
|
|
10
|
+
* 2. Platform-specific base:
|
|
11
|
+
* - Linux: $HOME/.local/state/parallix
|
|
12
|
+
* - macOS: ~/Library/Application Support/parallix
|
|
13
|
+
* - Windows: %LOCALAPPDATA%/parallix
|
|
14
|
+
* - Fallback: $HOME/.parallix
|
|
15
|
+
*
|
|
16
|
+
* This function guarantees the directory exists (creates it + parents on first
|
|
17
|
+
* call with `ensureDir: true`). Read-side callers may omit ensureDir so they
|
|
18
|
+
* fail gracefully when PARALLIX_HOME has never been initialized.
|
|
19
|
+
*/
|
|
20
|
+
function resolveParallixHome(options = {}) {
|
|
21
|
+
if (typeof options === 'boolean' || typeof options === 'string') {
|
|
22
|
+
// Legacy shim: resolveParallixHome(true) === { ensureDir: true }
|
|
23
|
+
options = { ensureDir: options };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
ensureDir = false,
|
|
28
|
+
platform = process.platform,
|
|
29
|
+
env = process.env,
|
|
30
|
+
homedir = os.homedir
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
let home;
|
|
34
|
+
|
|
35
|
+
// --- env override (highest precedence) ---
|
|
36
|
+
if (env.PARALLIX_HOME && typeof env.PARALLIX_HOME === 'string' && env.PARALLIX_HOME.trim().length > 0) {
|
|
37
|
+
home = path.resolve(env.PARALLIX_HOME);
|
|
38
|
+
} else if (platform === 'linux') {
|
|
39
|
+
home = path.join(homedir(), '.local', 'state', 'parallix');
|
|
40
|
+
} else if (platform === 'darwin') {
|
|
41
|
+
home = path.join(homedir(), 'Library', 'Application Support', 'parallix');
|
|
42
|
+
} else if (platform === 'win32') {
|
|
43
|
+
const localAppData = env.LOCALAPPDATA;
|
|
44
|
+
if (localAppData && typeof localAppData === 'string' && localAppData.trim().length > 0) {
|
|
45
|
+
home = path.join(localAppData.trim(), 'parallix');
|
|
46
|
+
} else {
|
|
47
|
+
home = path.join(homedir(), '.parallix');
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
// WSL, CI, other UNIX variants
|
|
51
|
+
home = path.join(homedir(), '.parallix');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
home = path.resolve(home);
|
|
55
|
+
|
|
56
|
+
if (ensureDir) {
|
|
57
|
+
fs.mkdirSync(home, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return home;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the effective stats CSV path.
|
|
65
|
+
*
|
|
66
|
+
* Returns `<PARALLIX_HOME>/stats.csv` (creating PARALLIX_HOME if `ensureDir`
|
|
67
|
+
* is true). Callers that pass an explicit `--csv-file` path bypass this
|
|
68
|
+
* resolver entirely — the caller supplies the path before calling here.
|
|
69
|
+
*/
|
|
70
|
+
function resolveStatsPath(options = {}) {
|
|
71
|
+
const home = resolveParallixHome({ ensureDir: options.ensureDir !== false, warn: options.warn });
|
|
72
|
+
return path.join(home, 'stats.csv');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the effective agent blocklist path.
|
|
77
|
+
*
|
|
78
|
+
* Returns `<PARALLIX_HOME>/agents.local.json`. Callers that pass an
|
|
79
|
+
* explicit `targetPath` bypass this resolver.
|
|
80
|
+
*/
|
|
81
|
+
function resolveAgentsLocalPath(options = {}) {
|
|
82
|
+
if (typeof options === 'string') {
|
|
83
|
+
return path.resolve(options);
|
|
84
|
+
}
|
|
85
|
+
const home = resolveParallixHome({ ensureDir: options.ensureDir !== false });
|
|
86
|
+
return path.join(home, 'agents.local.json');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read a JSON file that lives under PARALLIX_HOME.
|
|
91
|
+
*
|
|
92
|
+
* Returns `{ ok: false, error }` when the file does not exist or is not
|
|
93
|
+
* valid JSON — callers should treat absence as "no local overrides".
|
|
94
|
+
* Malformed JSON returns `{ ok: false, error }` rather than throwing so
|
|
95
|
+
* callers can decide whether this is a hard failure.
|
|
96
|
+
*/
|
|
97
|
+
function readJson(pathOrResolution) {
|
|
98
|
+
let filePath;
|
|
99
|
+
if (typeof pathOrResolution === 'function') {
|
|
100
|
+
filePath = pathOrResolution();
|
|
101
|
+
} else {
|
|
102
|
+
filePath = pathOrResolution;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
106
|
+
return { ok: false, error: null, data: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
111
|
+
const data = JSON.parse(raw);
|
|
112
|
+
return { ok: true, error: null, data };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return { ok: false, error: err, data: null };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Write JSON to a path under PARALLIX_HOME (or an explicit path).
|
|
120
|
+
* Creates parent directories as needed.
|
|
121
|
+
*/
|
|
122
|
+
function writeJson(filePath, data) {
|
|
123
|
+
if (typeof filePath === 'function') {
|
|
124
|
+
filePath = filePath();
|
|
125
|
+
}
|
|
126
|
+
writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
127
|
+
return filePath;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function writeFileAtomic(filePath, content) {
|
|
131
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
132
|
+
const tempPath = path.join(
|
|
133
|
+
path.dirname(filePath),
|
|
134
|
+
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`
|
|
135
|
+
);
|
|
136
|
+
try {
|
|
137
|
+
fs.writeFileSync(tempPath, content, 'utf8');
|
|
138
|
+
fs.renameSync(tempPath, filePath);
|
|
139
|
+
} finally {
|
|
140
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check whether PARALLIX_HOME has been initialized (directory exists).
|
|
146
|
+
* Does NOT create the directory.
|
|
147
|
+
*/
|
|
148
|
+
function isInitialized() {
|
|
149
|
+
const home = resolveParallixHome({ ensureDir: false });
|
|
150
|
+
try {
|
|
151
|
+
return fs.statSync(home).isDirectory();
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
resolveParallixHome,
|
|
159
|
+
resolveStatsPath,
|
|
160
|
+
resolveAgentsLocalPath,
|
|
161
|
+
readJson,
|
|
162
|
+
writeJson,
|
|
163
|
+
writeFileAtomic,
|
|
164
|
+
isInitialized,
|
|
165
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const { run } = require('./git');
|
|
2
|
+
const { loadAdapterConfig } = require('./product-config');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
// parallix targets arbitrary repositories, so there is no universal gate
|
|
6
|
+
// command. When adapters.verification.command is not configured, verification
|
|
7
|
+
// is a no-op pass ("no validation"). A repository opts into a real gate by
|
|
8
|
+
// declaring the command in workflow.config.json.
|
|
9
|
+
const DEFAULT_AREA = 'docs';
|
|
10
|
+
// Shell-safe no-op so this is harmless if pasted into a command sequence: `:` is
|
|
11
|
+
// the bash null command and `#` comments the explanation.
|
|
12
|
+
const NO_GATE_NOTICE = ': # no verification gate configured (set adapters.verification.command)';
|
|
13
|
+
|
|
14
|
+
function resolveVerificationAdapter(rootDir = process.cwd()) {
|
|
15
|
+
const verification = loadAdapterConfig(rootDir).verification || {};
|
|
16
|
+
const command = typeof verification.command === 'string' && verification.command.trim()
|
|
17
|
+
? verification.command.trim()
|
|
18
|
+
: null;
|
|
19
|
+
const defaultArea = typeof verification.defaultArea === 'string' && verification.defaultArea.trim()
|
|
20
|
+
? verification.defaultArea.trim()
|
|
21
|
+
: DEFAULT_AREA;
|
|
22
|
+
|
|
23
|
+
return { command, defaultArea };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatVerificationCommand(area, rootDir = process.cwd()) {
|
|
27
|
+
const { command, defaultArea } = resolveVerificationAdapter(rootDir);
|
|
28
|
+
const effectiveArea = area || defaultArea;
|
|
29
|
+
if (!command) {
|
|
30
|
+
return NO_GATE_NOTICE;
|
|
31
|
+
}
|
|
32
|
+
return command.replaceAll('{{area}}', effectiveArea);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runVerificationGate(area, options = {}) {
|
|
36
|
+
const rootDir = options.rootDir || process.cwd();
|
|
37
|
+
const { command, defaultArea } = resolveVerificationAdapter(rootDir);
|
|
38
|
+
const effectiveArea = area || defaultArea;
|
|
39
|
+
|
|
40
|
+
if (!command) {
|
|
41
|
+
const info = options.log || require('./fmt').log.info;
|
|
42
|
+
info(`No verification gate configured for area: ${effectiveArea}; default is no validation. `
|
|
43
|
+
+ 'Set adapters.verification.command in workflow.config.json to enforce one.');
|
|
44
|
+
return { status: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const stdio = options.stdio || 'inherit';
|
|
48
|
+
const runFn = options.runFn || run;
|
|
49
|
+
return runFn('bash', ['-lc', command.replaceAll('{{area}}', effectiveArea)], { cwd: rootDir, stdio });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readPublishedTreeState(rootDir = process.cwd(), { gitRunner = run } = {}) {
|
|
53
|
+
const resolvedRoot = fs.realpathSync(rootDir);
|
|
54
|
+
const commitResult = gitRunner(['-C', resolvedRoot, 'rev-parse', 'HEAD']);
|
|
55
|
+
const treeResult = gitRunner(['-C', resolvedRoot, 'rev-parse', 'HEAD^{tree}']);
|
|
56
|
+
|
|
57
|
+
const commit = commitResult.stdout ? commitResult.stdout.trim() : '';
|
|
58
|
+
const tree = treeResult.stdout ? treeResult.stdout.trim() : '';
|
|
59
|
+
if (commitResult.status !== 0 || treeResult.status !== 0 || !commit || !tree) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: `could not resolve current published tree for ${resolvedRoot}`
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { ok: true, rootDir: resolvedRoot, commit, tree };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function captureVerifiedTreeProof(area, rootDir = process.cwd(), options = {}) {
|
|
70
|
+
const {
|
|
71
|
+
gitRunner = run,
|
|
72
|
+
runFn = run,
|
|
73
|
+
stdio = 'inherit'
|
|
74
|
+
} = options;
|
|
75
|
+
|
|
76
|
+
const before = readPublishedTreeState(rootDir, { gitRunner });
|
|
77
|
+
if (!before.ok) return before;
|
|
78
|
+
|
|
79
|
+
const verification = runVerificationGate(area, {
|
|
80
|
+
rootDir: before.rootDir,
|
|
81
|
+
runFn,
|
|
82
|
+
stdio
|
|
83
|
+
});
|
|
84
|
+
if (verification.status !== 0) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: `verification gate failed for ${before.rootDir} with exit code ${verification.status}`
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const after = readPublishedTreeState(before.rootDir, { gitRunner });
|
|
92
|
+
if (!after.ok) return after;
|
|
93
|
+
if (after.commit !== before.commit || after.tree !== before.tree) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `verification proof became stale while publishing ${before.rootDir}`
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { command, defaultArea } = resolveVerificationAdapter(before.rootDir);
|
|
101
|
+
const effectiveArea = area || defaultArea;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
proof: {
|
|
106
|
+
rootDir: before.rootDir,
|
|
107
|
+
area: effectiveArea,
|
|
108
|
+
command: command || null,
|
|
109
|
+
commit: after.commit,
|
|
110
|
+
tree: after.tree,
|
|
111
|
+
verifiedAt: new Date().toISOString()
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function assertVerifiedTreeProof(proof, rootDir = process.cwd(), { gitRunner = run } = {}) {
|
|
117
|
+
if (!proof || typeof proof !== 'object') {
|
|
118
|
+
return { ok: false, error: 'missing verification proof' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const current = readPublishedTreeState(rootDir, { gitRunner });
|
|
122
|
+
if (!current.ok) return current;
|
|
123
|
+
|
|
124
|
+
if (proof.rootDir !== current.rootDir) {
|
|
125
|
+
return { ok: false, error: `verification proof was captured from a different checkout: ${proof.rootDir}` };
|
|
126
|
+
}
|
|
127
|
+
if (proof.commit !== current.commit || proof.tree !== current.tree) {
|
|
128
|
+
return { ok: false, error: 'verification proof does not match the tree being published' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { ok: true, proof: current };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function runWorkflow(args, options = {}) {
|
|
135
|
+
const log = options.log || require('./fmt').log.plain;
|
|
136
|
+
const area = args[0] || process.env.VERIFY_AREA || DEFAULT_AREA;
|
|
137
|
+
log(`Running verification gate for area: ${area}...`);
|
|
138
|
+
return runVerificationGate(area, { stdio: 'inherit' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = runWorkflow;
|
|
142
|
+
module.exports.DEFAULT_AREA = DEFAULT_AREA;
|
|
143
|
+
module.exports.NO_GATE_NOTICE = NO_GATE_NOTICE;
|
|
144
|
+
module.exports.formatVerificationCommand = formatVerificationCommand;
|
|
145
|
+
module.exports.resolveVerificationAdapter = resolveVerificationAdapter;
|
|
146
|
+
module.exports.runVerificationGate = runVerificationGate;
|
|
147
|
+
module.exports.readPublishedTreeState = readPublishedTreeState;
|
|
148
|
+
module.exports.captureVerifiedTreeProof = captureVerifiedTreeProof;
|
|
149
|
+
module.exports.assertVerifiedTreeProof = assertVerifiedTreeProof;
|