@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +661 -0
  3. package/README.md +196 -0
  4. package/config/agents.json +25 -0
  5. package/config/agents.local.json.template +8 -0
  6. package/config/state-map.json +4 -0
  7. package/config/state-map.json.template +31 -0
  8. package/config/workflow.config.schema.json +98 -0
  9. package/data/.gitkeep +0 -0
  10. package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
  11. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
  12. package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
  13. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
  14. package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
  15. package/docs/adr/0041-integration-pipeline-gates.md +165 -0
  16. package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
  17. package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
  18. package/docs/adr/0044-workflow-distribution-model.md +277 -0
  19. package/docs/adr/0045-parallax-branch-model.md +182 -0
  20. package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
  21. package/docs/adr/index.md +20 -0
  22. package/docs/agents.md +212 -0
  23. package/docs/authority-reference.md +298 -0
  24. package/docs/forgejo-setup.md +31 -0
  25. package/docs/migration/extraction.md +61 -0
  26. package/docs/migration/task-classification.md +36 -0
  27. package/docs/operator-setup.md +76 -0
  28. package/docs/readme-rewrite-benchmark.md +188 -0
  29. package/docs/use-cases.md +105 -0
  30. package/examples/README.md +62 -0
  31. package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
  32. package/examples/run-verify-env-smoke.sh +40 -0
  33. package/index.js +250 -0
  34. package/lib/README.md +13 -0
  35. package/lib/agents/agents.js +867 -0
  36. package/lib/agents/claude-telemetry.js +233 -0
  37. package/lib/agents/claude.js +139 -0
  38. package/lib/agents/codex-telemetry.js +202 -0
  39. package/lib/agents/codex.js +219 -0
  40. package/lib/agents/limit-hit.js +252 -0
  41. package/lib/agents/mistral-telemetry.js +44 -0
  42. package/lib/agents/mistral.js +68 -0
  43. package/lib/agents/opencode-export.js +110 -0
  44. package/lib/agents/opencode-telemetry.js +356 -0
  45. package/lib/agents/opencode.js +218 -0
  46. package/lib/agents/stage-telemetry.js +37 -0
  47. package/lib/commands/active.js +625 -0
  48. package/lib/commands/checkpoint.js +76 -0
  49. package/lib/commands/config.js +39 -0
  50. package/lib/commands/coverage-gate.js +358 -0
  51. package/lib/commands/diff.js +119 -0
  52. package/lib/commands/draft.js +854 -0
  53. package/lib/commands/handoff.js +501 -0
  54. package/lib/commands/integrate.js +1528 -0
  55. package/lib/commands/mission-start.js +246 -0
  56. package/lib/commands/rebase.js +597 -0
  57. package/lib/commands/repair-handoff.js +227 -0
  58. package/lib/commands/resolve-conflict.js +109 -0
  59. package/lib/commands/review.js +13 -0
  60. package/lib/commands/setup-review.js +13 -0
  61. package/lib/commands/setup.js +3 -0
  62. package/lib/commands/stats-backfill.js +395 -0
  63. package/lib/commands/stats.js +1601 -0
  64. package/lib/commands/status.js +183 -0
  65. package/lib/commands/verify.js +1 -0
  66. package/lib/core/fmt.js +202 -0
  67. package/lib/core/git.js +73 -0
  68. package/lib/core/gitignore.js +110 -0
  69. package/lib/core/mission-utils.js +1017 -0
  70. package/lib/core/persistent-data-migration.js +201 -0
  71. package/lib/core/product-config.js +508 -0
  72. package/lib/core/runtime-matrix.js +82 -0
  73. package/lib/core/spawn-tee.js +173 -0
  74. package/lib/core/state-map.js +89 -0
  75. package/lib/core/storage.js +165 -0
  76. package/lib/core/verification.js +149 -0
  77. package/lib/index.js +77 -0
  78. package/lib/review/rebase.js +163 -0
  79. package/lib/review/review-adapter.js +135 -0
  80. package/lib/review/review-artifacts.js +619 -0
  81. package/lib/review/review-commands.js +1375 -0
  82. package/lib/review/review-events.js +1007 -0
  83. package/lib/review/review-loop.js +1004 -0
  84. package/lib/review/review-polling.js +141 -0
  85. package/lib/review/review-prompts.js +212 -0
  86. package/lib/review/review-state.js +280 -0
  87. package/lib/review/review.js +96 -0
  88. package/lib/tools/backlog.js +680 -0
  89. package/lib/tools/forgejo.js +1585 -0
  90. package/lib/tools/gatekeeper.js +106 -0
  91. package/lib/tools/sessions.js +74 -0
  92. package/lib/tools/setup-review.js +1053 -0
  93. package/package.json +56 -0
  94. package/prompts/act-on-review-verbose.md +20 -0
  95. package/prompts/act-on-review.md +22 -0
  96. package/prompts/draft.md +20 -0
  97. package/prompts/execute.md +24 -0
  98. package/prompts/portfolio.md +30 -0
  99. package/prompts/review-verbose.md +20 -0
  100. package/prompts/review.md +17 -0
  101. package/px.js +236 -0
  102. package/templates/AGENTS-snippet.md +14 -0
  103. package/templates/AGENTS.md.template +34 -0
  104. package/templates/CLAUDE.md.template +27 -0
  105. package/templates/CODEX.md.template +38 -0
  106. package/templates/MISTRAL.md.template +24 -0
  107. package/templates/claude-commands/act-on-review.md +3 -0
  108. package/templates/claude-commands/area-review.md +3 -0
  109. package/templates/claude-commands/draft.md +6 -0
  110. package/templates/claude-commands/execute.md +6 -0
  111. package/templates/claude-commands/integrate.md +4 -0
  112. package/templates/claude-commands/portfolio.md +5 -0
  113. package/templates/claude-commands/review.md +4 -0
  114. package/templates/codex/config.toml +6 -0
  115. package/templates/mission-scaffold.md +39 -0
  116. package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
  117. package/templates/vibe/skills/area-review/SKILL.md +16 -0
  118. package/templates/vibe/skills/draft/SKILL.md +16 -0
  119. package/templates/vibe/skills/execute/SKILL.md +16 -0
  120. package/templates/vibe/skills/integrate/SKILL.md +16 -0
  121. package/templates/vibe/skills/portfolio/SKILL.md +21 -0
  122. package/templates/vibe/skills/review/SKILL.md +16 -0
  123. 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;