agentxchain 0.8.7 → 2.1.1

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 (94) hide show
  1. package/README.md +123 -154
  2. package/bin/agentxchain.js +240 -8
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +16 -7
  13. package/scripts/agentxchain-autonudge.applescript +32 -5
  14. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  15. package/scripts/publish-from-tag.sh +88 -0
  16. package/scripts/release-postflight.sh +231 -0
  17. package/scripts/release-preflight.sh +167 -0
  18. package/scripts/run-autonudge.sh +1 -1
  19. package/src/adapters/claude-code.js +7 -14
  20. package/src/adapters/cursor-local.js +17 -16
  21. package/src/commands/accept-turn.js +160 -0
  22. package/src/commands/approve-completion.js +80 -0
  23. package/src/commands/approve-transition.js +85 -0
  24. package/src/commands/branch.js +2 -2
  25. package/src/commands/claim.js +84 -9
  26. package/src/commands/config.js +16 -0
  27. package/src/commands/dashboard.js +70 -0
  28. package/src/commands/doctor.js +9 -1
  29. package/src/commands/init.js +540 -5
  30. package/src/commands/migrate.js +348 -0
  31. package/src/commands/multi.js +549 -0
  32. package/src/commands/plugin.js +157 -0
  33. package/src/commands/reject-turn.js +204 -0
  34. package/src/commands/resume.js +389 -0
  35. package/src/commands/status.js +196 -3
  36. package/src/commands/step.js +947 -0
  37. package/src/commands/stop.js +65 -33
  38. package/src/commands/template-list.js +33 -0
  39. package/src/commands/template-set.js +279 -0
  40. package/src/commands/update.js +24 -3
  41. package/src/commands/validate.js +20 -11
  42. package/src/commands/verify.js +71 -0
  43. package/src/commands/watch.js +112 -25
  44. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  45. package/src/lib/adapters/local-cli-adapter.js +337 -0
  46. package/src/lib/adapters/manual-adapter.js +169 -0
  47. package/src/lib/blocked-state.js +94 -0
  48. package/src/lib/config.js +143 -12
  49. package/src/lib/context-compressor.js +121 -0
  50. package/src/lib/context-section-parser.js +220 -0
  51. package/src/lib/coordinator-acceptance.js +428 -0
  52. package/src/lib/coordinator-config.js +461 -0
  53. package/src/lib/coordinator-dispatch.js +276 -0
  54. package/src/lib/coordinator-gates.js +487 -0
  55. package/src/lib/coordinator-hooks.js +239 -0
  56. package/src/lib/coordinator-recovery.js +523 -0
  57. package/src/lib/coordinator-state.js +365 -0
  58. package/src/lib/cross-repo-context.js +247 -0
  59. package/src/lib/dashboard/bridge-server.js +284 -0
  60. package/src/lib/dashboard/file-watcher.js +93 -0
  61. package/src/lib/dashboard/state-reader.js +96 -0
  62. package/src/lib/dispatch-bundle.js +568 -0
  63. package/src/lib/dispatch-manifest.js +252 -0
  64. package/src/lib/filter-agents.js +12 -0
  65. package/src/lib/gate-evaluator.js +285 -0
  66. package/src/lib/generate-vscode.js +158 -68
  67. package/src/lib/governed-state.js +2139 -0
  68. package/src/lib/governed-templates.js +145 -0
  69. package/src/lib/hook-runner.js +788 -0
  70. package/src/lib/next-owner.js +61 -6
  71. package/src/lib/normalized-config.js +539 -0
  72. package/src/lib/notify.js +14 -12
  73. package/src/lib/plugin-config-schema.js +192 -0
  74. package/src/lib/plugins.js +692 -0
  75. package/src/lib/prompt-core.js +108 -0
  76. package/src/lib/protocol-conformance.js +291 -0
  77. package/src/lib/reference-conformance-adapter.js +717 -0
  78. package/src/lib/repo-observer.js +597 -0
  79. package/src/lib/repo.js +0 -31
  80. package/src/lib/safe-write.js +44 -0
  81. package/src/lib/schema.js +189 -0
  82. package/src/lib/schemas/turn-result.schema.json +205 -0
  83. package/src/lib/seed-prompt-polling.js +15 -73
  84. package/src/lib/seed-prompt.js +17 -63
  85. package/src/lib/token-budget.js +206 -0
  86. package/src/lib/token-counter.js +27 -0
  87. package/src/lib/turn-paths.js +67 -0
  88. package/src/lib/turn-result-validator.js +496 -0
  89. package/src/lib/validation.js +167 -19
  90. package/src/lib/verify-command.js +72 -0
  91. package/src/templates/governed/api-service.json +31 -0
  92. package/src/templates/governed/cli-tool.json +30 -0
  93. package/src/templates/governed/generic.json +10 -0
  94. package/src/templates/governed/web-app.json +30 -0
package/src/lib/config.js CHANGED
@@ -1,36 +1,167 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
- import { join } from 'path';
2
+ import { join, parse as pathParse, resolve } from 'path';
3
+ import { safeParseJson, validateConfigSchema, validateLockSchema, validateProjectStateSchema, validateStateSchema } from './schema.js';
4
+ import { loadNormalizedConfig } from './normalized-config.js';
5
+ import { safeWriteJson } from './safe-write.js';
6
+ import { normalizeGovernedStateShape, getActiveTurn } from './governed-state.js';
7
+
8
+ function attachLegacyCurrentTurnAlias(state) {
9
+ if (!state || typeof state !== 'object') {
10
+ return state;
11
+ }
12
+
13
+ const existing = Object.getOwnPropertyDescriptor(state, 'current_turn');
14
+ if (existing && existing.enumerable === false) {
15
+ return state;
16
+ }
17
+
18
+ Object.defineProperty(state, 'current_turn', {
19
+ configurable: true,
20
+ enumerable: false,
21
+ get() {
22
+ return getActiveTurn(state);
23
+ },
24
+ });
25
+
26
+ return state;
27
+ }
3
28
 
4
29
  const CONFIG_FILE = 'agentxchain.json';
5
30
  const LOCK_FILE = 'lock.json';
6
31
  const STATE_FILE = 'state.json';
7
32
 
8
33
  export function findProjectRoot(startDir = process.cwd()) {
9
- let dir = startDir;
10
- while (dir !== '/') {
34
+ let dir = resolve(startDir);
35
+ const { root: fsRoot } = pathParse(dir);
36
+ while (true) {
11
37
  if (existsSync(join(dir, CONFIG_FILE))) return dir;
38
+ if (dir === fsRoot) return null;
12
39
  dir = join(dir, '..');
13
40
  }
14
- return null;
15
41
  }
16
42
 
17
43
  export function loadConfig(dir = process.cwd()) {
18
44
  const root = findProjectRoot(dir);
19
45
  if (!root) return null;
20
- const raw = readFileSync(join(root, CONFIG_FILE), 'utf8');
21
- return { root, config: JSON.parse(raw) };
46
+ const filePath = join(root, CONFIG_FILE);
47
+ let raw;
48
+ try {
49
+ raw = readFileSync(filePath, 'utf8');
50
+ } catch {
51
+ return null;
52
+ }
53
+ const result = safeParseJson(raw, validateConfigSchema);
54
+ if (!result.ok) {
55
+ console.error(` Warning: agentxchain.json has issues: ${result.errors.join(', ')}`);
56
+ return null;
57
+ }
58
+ return { root, config: result.data };
59
+ }
60
+
61
+ export function loadProjectContext(dir = process.cwd()) {
62
+ const root = findProjectRoot(dir);
63
+ if (!root) return null;
64
+
65
+ const filePath = join(root, CONFIG_FILE);
66
+ let raw;
67
+ try {
68
+ raw = readFileSync(filePath, 'utf8');
69
+ } catch {
70
+ return null;
71
+ }
72
+
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ } catch (err) {
77
+ console.error(` Warning: agentxchain.json is invalid JSON: ${err.message}`);
78
+ return null;
79
+ }
80
+
81
+ const normalized = loadNormalizedConfig(parsed, root);
82
+ if (!normalized.ok) {
83
+ console.error(` Warning: agentxchain.json has issues: ${normalized.errors.join(', ')}`);
84
+ return null;
85
+ }
86
+
87
+ return {
88
+ root,
89
+ rawConfig: parsed,
90
+ config: normalized.normalized,
91
+ version: normalized.version,
92
+ };
22
93
  }
23
94
 
24
95
  export function loadLock(root) {
25
- const path = join(root, LOCK_FILE);
26
- if (!existsSync(path)) return null;
27
- return JSON.parse(readFileSync(path, 'utf8'));
96
+ const filePath = join(root, LOCK_FILE);
97
+ if (!existsSync(filePath)) return null;
98
+ let raw;
99
+ try {
100
+ raw = readFileSync(filePath, 'utf8');
101
+ } catch {
102
+ return null;
103
+ }
104
+ const result = safeParseJson(raw, validateLockSchema);
105
+ if (!result.ok) {
106
+ console.error(` Warning: lock.json has issues: ${result.errors.join(', ')}`);
107
+ return null;
108
+ }
109
+ return result.data;
28
110
  }
29
111
 
30
112
  export function loadState(root) {
31
- const path = join(root, STATE_FILE);
32
- if (!existsSync(path)) return null;
33
- return JSON.parse(readFileSync(path, 'utf8'));
113
+ const filePath = join(root, STATE_FILE);
114
+ if (!existsSync(filePath)) return null;
115
+ let raw;
116
+ try {
117
+ raw = readFileSync(filePath, 'utf8');
118
+ } catch {
119
+ return null;
120
+ }
121
+ const result = safeParseJson(raw, validateStateSchema);
122
+ if (!result.ok) {
123
+ console.error(` Warning: state.json has issues: ${result.errors.join(', ')}`);
124
+ return null;
125
+ }
126
+ return result.data;
127
+ }
128
+
129
+ export function loadProjectState(root, config) {
130
+ const relPath = config?.files?.state || STATE_FILE;
131
+ const filePath = join(root, relPath);
132
+ if (!existsSync(filePath)) return null;
133
+
134
+ let raw;
135
+ try {
136
+ raw = readFileSync(filePath, 'utf8');
137
+ } catch {
138
+ return null;
139
+ }
140
+
141
+ const parsed = safeParseJson(raw);
142
+ if (!parsed.ok) {
143
+ console.error(` Warning: ${relPath} has issues: ${parsed.errors.join(', ')}`);
144
+ return null;
145
+ }
146
+
147
+ let stateData = parsed.data;
148
+ if (config?.protocol_mode === 'governed') {
149
+ const normalized = normalizeGovernedStateShape(stateData);
150
+ stateData = normalized.state;
151
+ if (normalized.changed) {
152
+ safeWriteJson(filePath, stateData);
153
+ }
154
+ }
155
+
156
+ const result = validateProjectStateSchema(stateData);
157
+ if (!result.ok) {
158
+ console.error(` Warning: ${relPath} has issues: ${result.errors.join(', ')}`);
159
+ return null;
160
+ }
161
+ if (config?.protocol_mode === 'governed') {
162
+ return attachLegacyCurrentTurnAlias(stateData);
163
+ }
164
+ return stateData;
34
165
  }
35
166
 
36
167
  export { CONFIG_FILE, LOCK_FILE, STATE_FILE };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Bounded context compressor for preflight tokenization.
3
+ *
4
+ * Takes parsed context sections and applies the v1.1 deterministic
5
+ * compression order from PREEMPTIVE_TOKENIZATION_SPEC.md §4.
6
+ * Does not perform token counting — that is the caller's responsibility.
7
+ */
8
+
9
+ import { renderContextSections } from './context-section-parser.js';
10
+
11
+ /**
12
+ * The fixed compression order. Each step is tried in sequence until the
13
+ * caller's budget check passes or all steps are exhausted.
14
+ */
15
+ const COMPRESSION_STEPS = [
16
+ { id: 'budget', action: 'drop' },
17
+ { id: 'phase_gate_status', action: 'drop' },
18
+ { id: 'gate_required_files', action: 'drop' },
19
+ { id: 'last_turn_objections', action: 'drop' },
20
+ { id: 'last_turn_decisions', action: 'drop' },
21
+ { id: 'last_turn_summary', action: 'truncate', max_chars: 240 },
22
+ { id: 'last_turn_summary', action: 'drop' },
23
+ ];
24
+
25
+ export { COMPRESSION_STEPS };
26
+
27
+ /**
28
+ * @typedef {Object} SectionAction
29
+ * @property {string} id - Section ID
30
+ * @property {boolean} required - Whether the section is sticky
31
+ * @property {number} original_tokens - Token count before compression (set by caller)
32
+ * @property {number} final_tokens - Token count after compression (set by caller)
33
+ * @property {'kept' | 'dropped' | 'truncated'} action - What happened to this section
34
+ */
35
+
36
+ /**
37
+ * @typedef {Object} CompressionResult
38
+ * @property {Array<Object>} sections - The (possibly compressed) section array
39
+ * @property {Array<SectionAction>} actions - Per-section action log
40
+ * @property {string} effective_context - Rendered markdown of the compressed sections
41
+ * @property {boolean} exhausted - True if all compression steps were applied
42
+ * @property {number} steps_applied - Number of compression steps applied
43
+ */
44
+
45
+ /**
46
+ * Compress parsed context sections using the v1.1 bounded compression order.
47
+ *
48
+ * The `fitsInBudget` callback is called after each compression step with the
49
+ * current effective context string. It should return true if the full outbound
50
+ * request (including system prompt, PROMPT.md, separator, and the provided
51
+ * effective context) fits within the available input token budget.
52
+ *
53
+ * @param {Array<Object>} sections - Parsed sections from parseContextSections()
54
+ * @param {(effectiveContext: string) => boolean} fitsInBudget - Budget check callback
55
+ * @returns {CompressionResult}
56
+ */
57
+ export function compressContextSections(sections, fitsInBudget) {
58
+ // Deep copy so we don't mutate the caller's array
59
+ let working = sections.map((s) => ({ ...s }));
60
+
61
+ // Track actions per section
62
+ const actionMap = new Map(
63
+ sections.map((s) => [s.id, { id: s.id, required: s.required, action: 'kept' }])
64
+ );
65
+
66
+ // Check if it already fits before any compression
67
+ let effectiveContext = renderContextSections(working);
68
+ if (fitsInBudget(effectiveContext)) {
69
+ return buildResult(working, actionMap, effectiveContext, false, 0);
70
+ }
71
+
72
+ // Apply compression steps in order
73
+ let stepsApplied = 0;
74
+ for (const step of COMPRESSION_STEPS) {
75
+ const sectionIndex = working.findIndex((s) => s.id === step.id);
76
+ if (sectionIndex === -1) {
77
+ // Section not present — skip this step
78
+ continue;
79
+ }
80
+
81
+ const section = working[sectionIndex];
82
+
83
+ // Safety: never compress a required/sticky section
84
+ if (section.required) {
85
+ continue;
86
+ }
87
+
88
+ if (step.action === 'drop') {
89
+ working = working.filter((s) => s.id !== step.id);
90
+ actionMap.get(step.id).action = 'dropped';
91
+ stepsApplied += 1;
92
+ } else if (step.action === 'truncate') {
93
+ const original = section.content;
94
+ if (original.length > step.max_chars) {
95
+ section.content = original.slice(0, step.max_chars);
96
+ // Only count as a step if we actually truncated
97
+ actionMap.get(step.id).action = 'truncated';
98
+ stepsApplied += 1;
99
+ }
100
+ }
101
+
102
+ effectiveContext = renderContextSections(working);
103
+ if (fitsInBudget(effectiveContext)) {
104
+ return buildResult(working, actionMap, effectiveContext, false, stepsApplied);
105
+ }
106
+ }
107
+
108
+ // All compression steps exhausted and still doesn't fit
109
+ effectiveContext = renderContextSections(working);
110
+ return buildResult(working, actionMap, effectiveContext, true, stepsApplied);
111
+ }
112
+
113
+ function buildResult(sections, actionMap, effectiveContext, exhausted, stepsApplied) {
114
+ return {
115
+ sections,
116
+ actions: Array.from(actionMap.values()),
117
+ effective_context: effectiveContext,
118
+ exhausted,
119
+ steps_applied: stepsApplied,
120
+ };
121
+ }
@@ -0,0 +1,220 @@
1
+ const CONTEXT_TITLE = '# Execution Context';
2
+
3
+ const SECTION_DEFINITIONS = [
4
+ { id: 'current_state', header: 'Current State', required: true },
5
+ { id: 'budget', header: null, required: false },
6
+ { id: 'last_turn_header', header: 'Last Accepted Turn', required: true },
7
+ { id: 'last_turn_summary', header: null, required: false },
8
+ { id: 'last_turn_decisions', header: null, required: false },
9
+ { id: 'last_turn_objections', header: null, required: false },
10
+ { id: 'blockers', header: 'Blockers', required: true },
11
+ { id: 'escalation', header: 'Escalation', required: true },
12
+ { id: 'gate_required_files', header: 'Gate Required Files', required: false },
13
+ { id: 'phase_gate_status', header: 'Phase Gate Status', required: false },
14
+ ];
15
+
16
+ const REQUIRED_BY_ID = new Map(SECTION_DEFINITIONS.map((section) => [section.id, section.required]));
17
+ const HEADER_TO_ID = new Map(
18
+ SECTION_DEFINITIONS
19
+ .filter((section) => section.header)
20
+ .map((section) => [section.header, section.id])
21
+ );
22
+
23
+ const BUDGET_LINE_PATTERN = /^- \*\*Budget (spent|remaining):\*\*/;
24
+ const SUMMARY_LINE_PATTERN = /^- \*\*Summary:\*\*/;
25
+ const DECISIONS_LINE_PATTERN = /^- \*\*Decisions:\*\*/;
26
+ const OBJECTIONS_LINE_PATTERN = /^- \*\*Objections:\*\*/;
27
+
28
+ export { CONTEXT_TITLE, SECTION_DEFINITIONS };
29
+
30
+ export function parseContextSections(contextMd) {
31
+ const normalized = normalizeNewlines(contextMd);
32
+ const topLevelSections = splitTopLevelSections(normalized);
33
+ const parsedSections = [];
34
+
35
+ const currentStateBody = topLevelSections.get('Current State');
36
+ if (currentStateBody) {
37
+ const budgetLines = currentStateBody.filter((line) => BUDGET_LINE_PATTERN.test(line));
38
+ const stickyLines = currentStateBody.filter((line) => !BUDGET_LINE_PATTERN.test(line));
39
+
40
+ pushSection(parsedSections, 'current_state', stickyLines);
41
+ pushSection(parsedSections, 'budget', budgetLines);
42
+ }
43
+
44
+ const lastAcceptedTurnBody = topLevelSections.get('Last Accepted Turn');
45
+ if (lastAcceptedTurnBody) {
46
+ const {
47
+ headerLines,
48
+ summaryLines,
49
+ decisionsLines,
50
+ objectionsLines,
51
+ } = splitLastAcceptedTurn(lastAcceptedTurnBody);
52
+
53
+ pushSection(parsedSections, 'last_turn_header', headerLines);
54
+ pushSection(parsedSections, 'last_turn_summary', summaryLines);
55
+ pushSection(parsedSections, 'last_turn_decisions', decisionsLines);
56
+ pushSection(parsedSections, 'last_turn_objections', objectionsLines);
57
+ }
58
+
59
+ for (const [header, id] of HEADER_TO_ID.entries()) {
60
+ if (header === 'Current State' || header === 'Last Accepted Turn') continue;
61
+ pushSection(parsedSections, id, topLevelSections.get(header));
62
+ }
63
+
64
+ return SECTION_DEFINITIONS
65
+ .map((definition) => parsedSections.find((section) => section.id === definition.id))
66
+ .filter(Boolean);
67
+ }
68
+
69
+ export function renderContextSections(sections) {
70
+ const sectionMap = new Map((sections || []).map((section) => [section.id, section]));
71
+ const lines = [CONTEXT_TITLE, ''];
72
+
73
+ appendTopLevelSection(lines, 'Current State', [
74
+ sectionMap.get('current_state')?.content,
75
+ sectionMap.get('budget')?.content,
76
+ ]);
77
+
78
+ appendTopLevelSection(lines, 'Last Accepted Turn', [
79
+ sectionMap.get('last_turn_header')?.content,
80
+ sectionMap.get('last_turn_summary')?.content,
81
+ sectionMap.get('last_turn_decisions')?.content,
82
+ sectionMap.get('last_turn_objections')?.content,
83
+ ]);
84
+
85
+ appendTopLevelSection(lines, 'Blockers', [sectionMap.get('blockers')?.content]);
86
+ appendTopLevelSection(lines, 'Escalation', [sectionMap.get('escalation')?.content]);
87
+ appendTopLevelSection(lines, 'Gate Required Files', [sectionMap.get('gate_required_files')?.content]);
88
+ appendTopLevelSection(lines, 'Phase Gate Status', [sectionMap.get('phase_gate_status')?.content]);
89
+
90
+ return `${lines.join('\n')}\n`;
91
+ }
92
+
93
+ function appendTopLevelSection(lines, header, fragments) {
94
+ const content = fragments
95
+ .filter((fragment) => typeof fragment === 'string' && fragment.length > 0)
96
+ .join('\n');
97
+
98
+ if (!content) return;
99
+
100
+ lines.push(`## ${header}`);
101
+ lines.push('');
102
+ lines.push(...content.split('\n'));
103
+ lines.push('');
104
+ }
105
+
106
+ function splitTopLevelSections(contextMd) {
107
+ const lines = normalizeNewlines(contextMd).split('\n');
108
+ const sectionStarts = [];
109
+
110
+ for (let index = 0; index < lines.length; index += 1) {
111
+ if (lines[index].startsWith('## ')) {
112
+ sectionStarts.push(index);
113
+ }
114
+ }
115
+
116
+ const sections = new Map();
117
+ for (let index = 0; index < sectionStarts.length; index += 1) {
118
+ const start = sectionStarts[index];
119
+ const end = sectionStarts[index + 1] ?? lines.length;
120
+ const header = lines[start].slice(3).trim();
121
+ const bodyLines = trimBlankLines(lines.slice(start + 1, end));
122
+ sections.set(header, bodyLines);
123
+ }
124
+
125
+ return sections;
126
+ }
127
+
128
+ function splitLastAcceptedTurn(lines) {
129
+ const headerLines = [];
130
+ let summaryLines = [];
131
+ let decisionsLines = [];
132
+ let objectionsLines = [];
133
+
134
+ for (let index = 0; index < lines.length; index += 1) {
135
+ const line = lines[index];
136
+
137
+ if (SUMMARY_LINE_PATTERN.test(line)) {
138
+ summaryLines = [line];
139
+ continue;
140
+ }
141
+
142
+ if (DECISIONS_LINE_PATTERN.test(line)) {
143
+ const { blockLines, nextIndex } = consumeIndentedBlock(lines, index);
144
+ decisionsLines = blockLines;
145
+ index = nextIndex - 1;
146
+ continue;
147
+ }
148
+
149
+ if (OBJECTIONS_LINE_PATTERN.test(line)) {
150
+ const { blockLines, nextIndex } = consumeIndentedBlock(lines, index);
151
+ objectionsLines = blockLines;
152
+ index = nextIndex - 1;
153
+ continue;
154
+ }
155
+
156
+ headerLines.push(line);
157
+ }
158
+
159
+ return {
160
+ headerLines: trimBlankLines(headerLines),
161
+ summaryLines: trimBlankLines(summaryLines),
162
+ decisionsLines: trimBlankLines(decisionsLines),
163
+ objectionsLines: trimBlankLines(objectionsLines),
164
+ };
165
+ }
166
+
167
+ function consumeIndentedBlock(lines, startIndex) {
168
+ const blockLines = [lines[startIndex]];
169
+ let index = startIndex + 1;
170
+
171
+ while (index < lines.length) {
172
+ const line = lines[index];
173
+ if (line.startsWith(' ') || line.trim() === '') {
174
+ blockLines.push(line);
175
+ index += 1;
176
+ continue;
177
+ }
178
+ break;
179
+ }
180
+
181
+ return {
182
+ blockLines: trimBlankLines(blockLines),
183
+ nextIndex: index,
184
+ };
185
+ }
186
+
187
+ function pushSection(target, id, lines) {
188
+ const normalizedLines = trimBlankLines(lines || []);
189
+ if (!normalizedLines.length) return;
190
+
191
+ target.push({
192
+ id,
193
+ content: normalizedLines.join('\n'),
194
+ required: REQUIRED_BY_ID.get(id) === true,
195
+ });
196
+ }
197
+
198
+ function trimBlankLines(lines) {
199
+ return trimTrailingBlankLines(trimLeadingBlankLines(lines));
200
+ }
201
+
202
+ function trimLeadingBlankLines(lines) {
203
+ let start = 0;
204
+ while (start < lines.length && lines[start].trim() === '') {
205
+ start += 1;
206
+ }
207
+ return lines.slice(start);
208
+ }
209
+
210
+ function trimTrailingBlankLines(lines) {
211
+ let end = lines.length;
212
+ while (end > 0 && lines[end - 1].trim() === '') {
213
+ end -= 1;
214
+ }
215
+ return lines.slice(0, end);
216
+ }
217
+
218
+ function normalizeNewlines(value) {
219
+ return String(value || '').replace(/\r\n/g, '\n');
220
+ }