@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,201 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const storage = require('./storage');
4
+
5
+ let _stats = null;
6
+ function getStats() {
7
+ if (!_stats) _stats = require('../commands/stats');
8
+ return _stats;
9
+ }
10
+
11
+ function getStatsHeaders() {
12
+ return getStats().STATS_HEADERS;
13
+ }
14
+
15
+ const ESSENTIAL_STATS_COLUMNS = ['date', 'mission', 'classification', 'implementer', 'pr_fix_rounds'];
16
+
17
+ function validateStatsRow(row, rowIndex, filePath) {
18
+ const missing = ESSENTIAL_STATS_COLUMNS.filter(col => !(col in row) || row[col] === '');
19
+ if (missing.length > 0) {
20
+ throw new Error(
21
+ `Malformed telemetry row ${rowIndex + 1} in ${path.resolve(filePath)}: ` +
22
+ `missing essential columns: ${missing.join(', ')}`
23
+ );
24
+ }
25
+ }
26
+
27
+ function parseCsvLine(line) {
28
+ const values = [];
29
+ let value = '';
30
+ let quoted = false;
31
+ for (let index = 0; index < line.length; index += 1) {
32
+ const char = line[index];
33
+ if (char === '"') {
34
+ if (quoted && line[index + 1] === '"') {
35
+ value += '"';
36
+ index += 1;
37
+ } else {
38
+ quoted = !quoted;
39
+ }
40
+ } else if (char === ',' && !quoted) {
41
+ values.push(value);
42
+ value = '';
43
+ } else {
44
+ value += char;
45
+ }
46
+ }
47
+ values.push(value);
48
+ return values;
49
+ }
50
+
51
+ function escapeCsvValue(value) {
52
+ const text = String(value ?? '');
53
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
54
+ }
55
+
56
+ /**
57
+ * Minimal CSV loader — avoids circular dependency with stats module (which
58
+ * imports this module at the top). Defined locally so `migrateStats()` can
59
+ * read any schema without triggering a require-cycle.
60
+ */
61
+ function loadCsv(filePath) {
62
+ if (!fs.existsSync(filePath)) {
63
+ return { headers: [], rows: [] };
64
+ }
65
+ const content = fs.readFileSync(filePath, 'utf8');
66
+ const lines = content.split('\n').filter(line => line.trim());
67
+ if (lines.length === 0) {
68
+ return { headers: [], rows: [] };
69
+ }
70
+ const headers = parseCsvLine(lines[0]);
71
+ const rows = [];
72
+ for (let i = 1; i < lines.length; i += 1) {
73
+ const values = parseCsvLine(lines[i]);
74
+ const row = {};
75
+ headers.forEach((header, idx) => {
76
+ row[header] = values[idx] || '';
77
+ });
78
+ rows.push(row);
79
+ }
80
+ return { headers, rows };
81
+ }
82
+
83
+ /**
84
+ * Read a stats CSV file with any schema (legacy 5-col or extended 21-col).
85
+ * Returns rows as arrays keyed by the 21-column STATS_HEADERS, with missing
86
+ * columns filled by normalizeStatsRow defaults.
87
+ */
88
+ function readStatsRows(filePath, options = {}) {
89
+ if (!filePath || !fs.existsSync(filePath)) return [];
90
+ const data = loadCsv(filePath);
91
+ if (data.headers.length === 0) return [];
92
+ const headers = getStatsHeaders();
93
+ const validated = data.rows.map((row, idx) => {
94
+ validateStatsRow(row, idx, filePath);
95
+ return row;
96
+ });
97
+ return validated
98
+ .map(row => getStats().normalizeStatsRow(row, { repo: options.defaultRepo }))
99
+ .map(row => headers.map(h => row[h] || ''));
100
+ }
101
+
102
+ function serializeStatsRows(rows) {
103
+ const headers = getStatsHeaders();
104
+ return `${[
105
+ headers.join(','),
106
+ ...rows.map(row => row.map(escapeCsvValue).join(','))
107
+ ].join('\n')}\n`;
108
+ }
109
+
110
+ function migrateStats(options = {}) {
111
+ const sourcePaths = options.sourcePaths || [options.sourcePath];
112
+ const destinationPath = options.destinationPath || storage.resolveStatsPath({ ensureDir: true });
113
+ const destinationRows = readStatsRows(destinationPath, options);
114
+ const sourceRows = sourcePaths.flatMap(sourcePath => readStatsRows(sourcePath, options));
115
+ const rows = [];
116
+ const seen = new Set();
117
+
118
+ for (const row of [...destinationRows, ...sourceRows]) {
119
+ const key = JSON.stringify(row);
120
+ if (!seen.has(key)) {
121
+ seen.add(key);
122
+ rows.push(row);
123
+ }
124
+ }
125
+
126
+ const content = serializeStatsRows(rows);
127
+ const current = fs.existsSync(destinationPath) ? fs.readFileSync(destinationPath, 'utf8') : null;
128
+ if (current !== content) storage.writeFileAtomic(destinationPath, content);
129
+ return { destinationPath, imported: rows.length - destinationRows.length, rows: rows.length };
130
+ }
131
+
132
+ function readBlocklistSource(filePath, warn, hardFailure = false) {
133
+ if (!filePath || !fs.existsSync(filePath)) return null;
134
+ try {
135
+ const payload = JSON.parse(fs.readFileSync(filePath, 'utf8'));
136
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
137
+ throw new Error('expected a JSON object at the file root');
138
+ }
139
+ if (
140
+ payload.blocklist !== undefined &&
141
+ (!payload.blocklist || typeof payload.blocklist !== 'object' || Array.isArray(payload.blocklist))
142
+ ) {
143
+ throw new Error('expected blocklist to be a JSON object');
144
+ }
145
+ return { filePath, payload, blocklist: payload.blocklist || {} };
146
+ } catch (error) {
147
+ if (hardFailure) throw error;
148
+ warn(`Skipping malformed legacy agent blocklist ${path.resolve(filePath)}: ${error.message}`);
149
+ return null;
150
+ }
151
+ }
152
+
153
+ function sameValue(left, right) {
154
+ return JSON.stringify(left) === JSON.stringify(right);
155
+ }
156
+
157
+ function migrateAgentBlocklists(options = {}) {
158
+ const warn = options.warn || (() => {});
159
+ const destinationPath = options.destinationPath || storage.resolveAgentsLocalPath({ ensureDir: true });
160
+ const sources = (options.sourcePaths || [])
161
+ .map(filePath => readBlocklistSource(filePath, warn))
162
+ .filter(Boolean);
163
+ const destination = readBlocklistSource(destinationPath, warn, true);
164
+ const selected = {};
165
+ const selectedFrom = {};
166
+ const conflicts = [];
167
+
168
+ for (const source of [...sources, ...(destination ? [destination] : [])]) {
169
+ for (const [agent, value] of Object.entries(source.blocklist)) {
170
+ if (Object.prototype.hasOwnProperty.call(selected, agent) && !sameValue(selected[agent], value)) {
171
+ const conflict = {
172
+ agent,
173
+ previousSource: selectedFrom[agent],
174
+ previousValue: selected[agent],
175
+ selectedSource: source.filePath,
176
+ selectedValue: value
177
+ };
178
+ conflicts.push(conflict);
179
+ warn(
180
+ `Agent blocklist conflict for "${agent}": ${path.resolve(source.filePath)} takes precedence over ` +
181
+ `${path.resolve(selectedFrom[agent])}; selected=${JSON.stringify(value)} previous=${JSON.stringify(selected[agent])}`
182
+ );
183
+ }
184
+ selected[agent] = value;
185
+ selectedFrom[agent] = source.filePath;
186
+ }
187
+ }
188
+
189
+ const payload = destination ? { ...destination.payload } : {};
190
+ payload.blocklist = selected;
191
+ const content = `${JSON.stringify(payload, null, 2)}\n`;
192
+ const current = fs.existsSync(destinationPath) ? fs.readFileSync(destinationPath, 'utf8') : null;
193
+ if (current !== content) storage.writeFileAtomic(destinationPath, content);
194
+ return { destinationPath, blocklist: selected, conflicts };
195
+ }
196
+
197
+ module.exports = {
198
+ migrateStats,
199
+ migrateAgentBlocklists,
200
+ _internals: { parseCsvLine, readStatsRows, serializeStatsRows }
201
+ };
@@ -0,0 +1,508 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawnSync } = require('child_process');
4
+
5
+ const REQUIRED_ADAPTER_KEYS = ['tasks', 'missions', 'verification', 'review', 'agents'];
6
+
7
+ // Code-owned defaults. An absent workflow.config.json yields a working tool;
8
+ // the optional override file overrides only the keys it sets. These defaults
9
+ // are the single source of truth — there is no shipped example config, so there
10
+ // is no second source to drift from (see task-1233 Scope Amendment).
11
+ const DEFAULT_CONFIG = Object.freeze({
12
+ product: {
13
+ name: 'Workflow',
14
+ targetUser: 'Engineering teams using git, task tracking, and code review',
15
+ },
16
+ adapters: {
17
+ tasks: { provider: 'backlog-md', storage: 'backlog', stateMap: 'state-map.json' },
18
+ missions: {
19
+ baseDir: 'missions',
20
+ branchPrefix: 'mission/',
21
+ worktreePattern: '../<repo>-<slug>',
22
+ },
23
+ // No universal cross-repo gate exists, so the default is no validation
24
+ // (no command). A repository opts into a gate by declaring
25
+ // adapters.verification.command in workflow.config.json.
26
+ verification: { defaultArea: 'docs' },
27
+ stats: { path: 'stats.csv' },
28
+ // Unset provider currently keeps Forgejo enabled (isForgejoReviewEnabled
29
+ // treats null as enabled) — a WrGroceries-ism. Flipping review off-by-default
30
+ // for external repos has a large review-subsystem blast radius and is deferred
31
+ // to TASK-1244; WrGroceries already declares review.provider in its own config.
32
+ review: {},
33
+ agents: {},
34
+ },
35
+ });
36
+
37
+ function isPlainObject(value) {
38
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
39
+ }
40
+
41
+ function deepMerge(base, override) {
42
+ if (!isPlainObject(override)) return base;
43
+ const out = { ...base };
44
+ for (const [key, value] of Object.entries(override)) {
45
+ out[key] = isPlainObject(value) && isPlainObject(out[key])
46
+ ? deepMerge(out[key], value)
47
+ : value;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // A fresh deep clone of the defaults so callers can never mutate the frozen
53
+ // source.
54
+ function defaultConfig() {
55
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
56
+ }
57
+
58
+ function configCandidates(rootDir = process.cwd()) {
59
+ return [path.join(rootDir, 'workflow.config.json')];
60
+ }
61
+
62
+ function findWorkflowConfig(rootDir = process.cwd()) {
63
+ return configCandidates(rootDir).find(candidate => fs.existsSync(candidate)) || null;
64
+ }
65
+
66
+ function loadWorkflowConfig(rootDir = process.cwd()) {
67
+ const configPath = findWorkflowConfig(rootDir);
68
+ if (!configPath) {
69
+ return { found: false, configPath: null, config: null };
70
+ }
71
+
72
+ let raw;
73
+ try {
74
+ raw = fs.readFileSync(configPath, 'utf8');
75
+ } catch (error) {
76
+ if (error && error.code === 'ENOENT') {
77
+ return { found: false, configPath: null, config: null };
78
+ }
79
+ throw error;
80
+ }
81
+ try {
82
+ return {
83
+ found: true,
84
+ configPath,
85
+ config: JSON.parse(raw),
86
+ parseError: null,
87
+ };
88
+ } catch (error) {
89
+ return {
90
+ found: true,
91
+ configPath,
92
+ config: null,
93
+ parseError: error,
94
+ };
95
+ }
96
+ }
97
+
98
+ // The effective config a command sees: code-owned defaults with any override
99
+ // file merged on top. A missing or malformed override falls back to defaults.
100
+ function loadEffectiveConfig(rootDir = process.cwd()) {
101
+ const loaded = loadWorkflowConfig(rootDir);
102
+ if (!loaded.found || loaded.parseError || !isPlainObject(loaded.config)) {
103
+ return defaultConfig();
104
+ }
105
+ // A structurally invalid override (e.g. adapters not an object) falls back to
106
+ // defaults rather than merging a bad shape into the effective config.
107
+ if (validateWorkflowConfig(loaded.config).length > 0) {
108
+ return defaultConfig();
109
+ }
110
+ return deepMerge(defaultConfig(), loaded.config);
111
+ }
112
+
113
+ // The override file is optional and partial: validate only the shape of what it
114
+ // provides. Missing sections are filled by code-owned defaults, so absent
115
+ // sections are not errors (and there are no placeholder sentinels to detect).
116
+ function validateWorkflowConfig(config) {
117
+ if (!isPlainObject(config)) {
118
+ return ['top-level JSON object is required'];
119
+ }
120
+
121
+ const issues = [];
122
+ if ('product' in config && !isPlainObject(config.product)) {
123
+ issues.push('product must be an object');
124
+ }
125
+ if ('adapters' in config) {
126
+ if (!isPlainObject(config.adapters)) {
127
+ issues.push('adapters must be an object');
128
+ } else {
129
+ for (const [key, value] of Object.entries(config.adapters)) {
130
+ if (!isPlainObject(value)) {
131
+ issues.push(`adapters.${key} must be an object`);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return issues;
137
+ }
138
+
139
+ function detectLegacyRepoLayout(rootDir = process.cwd()) {
140
+ const backlogDir = path.join(rootDir, 'backlog');
141
+ const missionDocsDir = path.join(rootDir, 'docs', 'missions');
142
+ const verifyScript = path.join(rootDir, 'scripts', 'verify-local.sh');
143
+
144
+ return fs.existsSync(backlogDir) && fs.existsSync(missionDocsDir) && fs.existsSync(verifyScript);
145
+ }
146
+
147
+ function isStandaloneWorkflowLayout(rootDir = process.cwd()) {
148
+ const workflowIndex = path.join(rootDir, 'workflow', 'index.js');
149
+ const workflowConfig = path.join(rootDir, 'workflow.config.json');
150
+
151
+ return fs.existsSync(workflowIndex) && fs.existsSync(workflowConfig);
152
+ }
153
+
154
+ function hasGitRepository(rootDir = process.cwd()) {
155
+ return fs.existsSync(path.join(rootDir, '.git'));
156
+ }
157
+
158
+ function initializeGitRepository(rootDir = process.cwd(), { spawnSyncFn = spawnSync } = {}) {
159
+ const initMain = spawnSyncFn('git', ['init', '-b', 'main'], {
160
+ cwd: rootDir,
161
+ encoding: 'utf8',
162
+ });
163
+ if (initMain.status === 0) {
164
+ return { ok: true, branch: 'main', mode: 'init-main' };
165
+ }
166
+
167
+ const initFallback = spawnSyncFn('git', ['init'], {
168
+ cwd: rootDir,
169
+ encoding: 'utf8',
170
+ });
171
+ if (initFallback.status !== 0) {
172
+ return {
173
+ ok: false,
174
+ message: (initFallback.stderr || initFallback.stdout || initMain.stderr || initMain.stdout || 'git init failed').trim(),
175
+ };
176
+ }
177
+
178
+ spawnSyncFn('git', ['symbolic-ref', 'HEAD', 'refs/heads/main'], {
179
+ cwd: rootDir,
180
+ encoding: 'utf8',
181
+ });
182
+ return { ok: true, branch: 'main', mode: 'init-fallback' };
183
+ }
184
+
185
+ // Commits the workflow files and any config that exists so the freshly
186
+ // initialized repo has a baseline commit. Without this, mission worktrees
187
+ // created via `git worktree add` would not have workflow/ or
188
+ // workflow.config.json checked out and could not run any workflow command.
189
+ function gitIdentityEnv() {
190
+ const env = { ...process.env };
191
+ if (!env.GIT_AUTHOR_NAME) env.GIT_AUTHOR_NAME = 'Workflow Setup';
192
+ if (!env.GIT_AUTHOR_EMAIL) env.GIT_AUTHOR_EMAIL = 'workflow@example.invalid';
193
+ if (!env.GIT_COMMITTER_NAME) env.GIT_COMMITTER_NAME = env.GIT_AUTHOR_NAME;
194
+ if (!env.GIT_COMMITTER_EMAIL) env.GIT_COMMITTER_EMAIL = env.GIT_AUTHOR_EMAIL;
195
+ return env;
196
+ }
197
+
198
+ function commitWorkflowBaseline(rootDir, { spawnSyncFn = spawnSync, existsSyncFn = fs.existsSync } = {}) {
199
+ const candidates = ['workflow', 'workflow.config.json'];
200
+ const present = candidates.filter(name => existsSyncFn(path.join(rootDir, name)));
201
+ if (present.length === 0) {
202
+ return { ok: true, committed: false, reason: 'no-workflow-files' };
203
+ }
204
+
205
+ const addResult = spawnSyncFn('git', ['add', '-A', '.'], {
206
+ cwd: rootDir,
207
+ encoding: 'utf8',
208
+ });
209
+ if (addResult.status !== 0) {
210
+ return {
211
+ ok: false,
212
+ committed: false,
213
+ message: (addResult.stderr || addResult.stdout || 'git add failed').trim(),
214
+ };
215
+ }
216
+
217
+ const commitResult = spawnSyncFn(
218
+ 'git',
219
+ ['commit', '-m', 'workflow: initial setup'],
220
+ { cwd: rootDir, encoding: 'utf8', env: gitIdentityEnv() },
221
+ );
222
+ if (commitResult.status !== 0) {
223
+ return {
224
+ ok: false,
225
+ committed: false,
226
+ message: (commitResult.stderr || commitResult.stdout || 'git commit failed').trim(),
227
+ };
228
+ }
229
+
230
+ return { ok: true, committed: true, files: present };
231
+ }
232
+
233
+ function ensureStandaloneMissionBaseline(rootDir = process.cwd(), { spawnSyncFn = spawnSync } = {}) {
234
+ if (!isStandaloneWorkflowLayout(rootDir) || !hasGitRepository(rootDir)) {
235
+ return { changed: false, committed: false, skipped: true };
236
+ }
237
+
238
+ const statusResult = spawnSyncFn('git', ['status', '--porcelain'], {
239
+ cwd: rootDir,
240
+ encoding: 'utf8',
241
+ });
242
+ if (statusResult.status !== 0) {
243
+ return {
244
+ changed: false,
245
+ committed: false,
246
+ failed: true,
247
+ message: (statusResult.stderr || statusResult.stdout || 'git status failed').trim(),
248
+ };
249
+ }
250
+
251
+ const dirtyEntries = (statusResult.stdout || '')
252
+ .split('\n')
253
+ .map(line => line.trimEnd())
254
+ .filter(Boolean);
255
+ const relevantEntries = dirtyEntries.filter(line => {
256
+ const filePath = line.slice(3).trim();
257
+ return !filePath.startsWith('.workflow/') && !filePath.startsWith('.sessions/');
258
+ });
259
+
260
+ if (relevantEntries.length === 0) {
261
+ return { changed: dirtyEntries.length > 0, committed: false, skipped: false };
262
+ }
263
+
264
+ const conflicted = relevantEntries.filter(line => {
265
+ const state = line.slice(0, 2).trim();
266
+ return ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(state);
267
+ });
268
+ if (conflicted.length > 0) {
269
+ return {
270
+ changed: true,
271
+ committed: false,
272
+ failed: true,
273
+ message: `conflicted files block mission baseline commit: ${conflicted.map(line => line.slice(3).trim()).join(', ')}`,
274
+ };
275
+ }
276
+
277
+ const addResult = spawnSyncFn('git', ['add', '-A', '.'], {
278
+ cwd: rootDir,
279
+ encoding: 'utf8',
280
+ });
281
+ if (addResult.status !== 0) {
282
+ return {
283
+ changed: true,
284
+ committed: false,
285
+ failed: true,
286
+ message: (addResult.stderr || addResult.stdout || 'git add failed').trim(),
287
+ };
288
+ }
289
+
290
+ const commitResult = spawnSyncFn('git', ['commit', '-m', 'workflow: prepare standalone mission baseline'], {
291
+ cwd: rootDir,
292
+ encoding: 'utf8',
293
+ env: gitIdentityEnv(),
294
+ });
295
+ if (commitResult.status !== 0) {
296
+ return {
297
+ changed: true,
298
+ committed: false,
299
+ failed: true,
300
+ message: (commitResult.stderr || commitResult.stdout || 'git commit failed').trim(),
301
+ };
302
+ }
303
+
304
+ return {
305
+ changed: true,
306
+ committed: true,
307
+ entries: relevantEntries.map(line => line.slice(3).trim()),
308
+ };
309
+ }
310
+
311
+ function ensureStandaloneGitRepo(rootDir = process.cwd(), options = {}) {
312
+ const isStandaloneWorkflowLayoutFn = options.isStandaloneWorkflowLayoutFn || isStandaloneWorkflowLayout;
313
+ const hasGitRepositoryFn = options.hasGitRepositoryFn || hasGitRepository;
314
+ const initializeGitRepositoryFn = options.initializeGitRepositoryFn || initializeGitRepository;
315
+ const commitWorkflowBaselineFn = options.commitWorkflowBaselineFn || commitWorkflowBaseline;
316
+
317
+ if (!isStandaloneWorkflowLayoutFn(rootDir) || hasGitRepositoryFn(rootDir)) {
318
+ return { changed: false, initialized: false };
319
+ }
320
+
321
+ const result = initializeGitRepositoryFn(rootDir, options);
322
+ if (!result.ok) {
323
+ return {
324
+ changed: false,
325
+ initialized: false,
326
+ failed: true,
327
+ message: result.message || 'git init failed',
328
+ };
329
+ }
330
+
331
+ const commit = commitWorkflowBaselineFn(rootDir, options);
332
+
333
+ return {
334
+ changed: true,
335
+ initialized: true,
336
+ branch: result.branch || 'main',
337
+ mode: result.mode || 'init-main',
338
+ baselineCommit: commit,
339
+ };
340
+ }
341
+
342
+ function adapterChecklist() {
343
+ return [
344
+ 'Workflow runs on built-in defaults; no config file is required to start',
345
+ 'Create workflow.config.json only to override a default (schema: workflow/config/workflow.config.schema.json)',
346
+ 'Run `px config` to print the effective configuration',
347
+ 'Override adapters.tasks for a different task tracker or storage path',
348
+ 'Override adapters.missions for mission document layout and branch/worktree conventions',
349
+ 'Override adapters.verification for your repo gate command',
350
+ 'Override adapters.review to enable a review provider and remote naming',
351
+ ];
352
+ }
353
+
354
+ // Returns the override file's adapters object, or {} when there is no override.
355
+ // Each resolver (resolveTaskStorage, resolveVerificationAdapter, …) applies its
356
+ // own code-owned fallback on top of this, so an absent config still yields a
357
+ // fully working tool. loadEffectiveConfig (used by `node parallix config`) is
358
+ // the document-level view; the per-resolver fallbacks remain the runtime source
359
+ // of truth so this change does not shadow them.
360
+ function loadAdapterConfig(rootDir = process.cwd()) {
361
+ const explicit = loadWorkflowConfig(rootDir);
362
+ if (!explicit.found || explicit.parseError || !isPlainObject(explicit.config)) {
363
+ return {};
364
+ }
365
+ return isPlainObject(explicit.config.adapters) ? explicit.config.adapters : {};
366
+ }
367
+
368
+ function resolveTaskStorage(rootDir = process.cwd()) {
369
+ const fallbackBaseDir = path.join(rootDir, 'backlog');
370
+ const fallback = {
371
+ baseDir: fallbackBaseDir,
372
+ tasksDir: path.join(fallbackBaseDir, 'tasks'),
373
+ completedDir: path.join(fallbackBaseDir, 'completed'),
374
+ draftsDir: path.join(fallbackBaseDir, 'drafts'),
375
+ };
376
+
377
+ const storage = loadAdapterConfig(rootDir).tasks && loadAdapterConfig(rootDir).tasks.storage;
378
+ if (!storage) {
379
+ return fallback;
380
+ }
381
+
382
+ if (typeof storage === 'string') {
383
+ const storageDir = path.resolve(rootDir, storage);
384
+ const storageName = path.basename(storageDir);
385
+ if (storageName === 'tasks') {
386
+ const baseDir = path.dirname(storageDir);
387
+ return {
388
+ baseDir,
389
+ tasksDir: storageDir,
390
+ completedDir: path.join(baseDir, 'completed'),
391
+ draftsDir: path.join(baseDir, 'drafts'),
392
+ };
393
+ }
394
+
395
+ return {
396
+ baseDir: storageDir,
397
+ tasksDir: path.join(storageDir, 'tasks'),
398
+ completedDir: path.join(storageDir, 'completed'),
399
+ draftsDir: path.join(storageDir, 'drafts'),
400
+ };
401
+ }
402
+
403
+ if (typeof storage === 'object' && !Array.isArray(storage)) {
404
+ const tasksDir = storage.tasksDir
405
+ ? path.resolve(rootDir, storage.tasksDir)
406
+ : fallback.tasksDir;
407
+ const completedDir = storage.completedDir
408
+ ? path.resolve(rootDir, storage.completedDir)
409
+ : path.join(path.dirname(tasksDir), 'completed');
410
+ const baseDir = path.dirname(tasksDir);
411
+
412
+ return {
413
+ baseDir,
414
+ tasksDir,
415
+ completedDir,
416
+ draftsDir: path.join(baseDir, 'drafts'),
417
+ };
418
+ }
419
+
420
+ return fallback;
421
+ }
422
+
423
+ function resolveReviewAdapter(rootDir = process.cwd()) {
424
+ const review = loadAdapterConfig(rootDir).review || {};
425
+ return {
426
+ provider: review.provider || null,
427
+ remote: review.remote || null,
428
+ baseUrl: review.baseUrl || null,
429
+ repo: review.repo || null,
430
+ };
431
+ }
432
+
433
+ function isForgejoReviewEnabled(rootDir = process.cwd()) {
434
+ const review = resolveReviewAdapter(rootDir);
435
+ if (review.provider === null) return false;
436
+ return review.provider === 'forgejo';
437
+ }
438
+
439
+ function resolveAgentAdapter(rootDir = process.cwd()) {
440
+ return {};
441
+ }
442
+
443
+ // Returns the configured LLM model string for an agent family, or null when the
444
+ // family is not listed under adapters.agents.models. A null return means the
445
+ // launcher omits the model parameter entirely so the agent uses its own default.
446
+ function resolveAgentModel(agentFamily, rootDir = process.cwd()) {
447
+ if (!agentFamily || typeof agentFamily !== 'string') return null;
448
+ const agents = loadEffectiveConfig(rootDir).adapters.agents;
449
+ if (!isPlainObject(agents) || !isPlainObject(agents.models)) return null;
450
+ const model = agents.models[agentFamily];
451
+ return typeof model === 'string' && model.length > 0 ? model : null;
452
+ }
453
+
454
+ function evaluateRepositoryReadiness(rootDir = process.cwd()) {
455
+ const explicit = loadWorkflowConfig(rootDir);
456
+
457
+ // No override file: the tool runs on code-owned defaults. This is a ready
458
+ // state, not an "unconfigured" failure — an absent config is valid.
459
+ if (!explicit.found) {
460
+ return {
461
+ mode: 'default',
462
+ configPath: null,
463
+ issues: [],
464
+ };
465
+ }
466
+
467
+ if (explicit.parseError) {
468
+ return {
469
+ mode: 'invalid',
470
+ configPath: explicit.configPath,
471
+ issues: [`invalid JSON: ${explicit.parseError.message}`],
472
+ };
473
+ }
474
+
475
+ const issues = validateWorkflowConfig(explicit.config);
476
+ return {
477
+ mode: issues.length === 0 ? 'configured' : 'invalid',
478
+ configPath: explicit.configPath,
479
+ issues,
480
+ };
481
+ }
482
+
483
+ module.exports = {
484
+ REQUIRED_ADAPTER_KEYS,
485
+ DEFAULT_CONFIG,
486
+ defaultConfig,
487
+ adapterChecklist,
488
+ commitWorkflowBaseline,
489
+ configCandidates,
490
+ deepMerge,
491
+ detectLegacyRepoLayout,
492
+ evaluateRepositoryReadiness,
493
+ findWorkflowConfig,
494
+ hasGitRepository,
495
+ initializeGitRepository,
496
+ isForgejoReviewEnabled,
497
+ isStandaloneWorkflowLayout,
498
+ loadWorkflowConfig,
499
+ loadEffectiveConfig,
500
+ loadAdapterConfig,
501
+ resolveAgentAdapter,
502
+ resolveAgentModel,
503
+ resolveReviewAdapter,
504
+ resolveTaskStorage,
505
+ ensureStandaloneMissionBaseline,
506
+ ensureStandaloneGitRepo,
507
+ validateWorkflowConfig,
508
+ };