dual-brain 4.2.0 → 4.5.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/CLAUDE.md +130 -35
- package/README.md +171 -44
- package/hooks/agent-chains.mjs +369 -0
- package/hooks/agent-templates.mjs +441 -0
- package/hooks/atomic-write.mjs +5 -3
- package/hooks/config-validator.mjs +156 -0
- package/hooks/confirmation-policy.mjs +167 -0
- package/hooks/cost-logger.mjs +32 -12
- package/hooks/cost-report.mjs +60 -114
- package/hooks/decision-ledger.mjs +3 -2
- package/hooks/dual-brain-review.mjs +249 -2
- package/hooks/dual-brain-think.mjs +294 -25
- package/hooks/enforce-tier.mjs +246 -87
- package/hooks/error-channel.mjs +68 -0
- package/hooks/failure-detector.mjs +2 -1
- package/hooks/health-check.mjs +16 -17
- package/hooks/risk-classifier.mjs +135 -2
- package/hooks/session-report.mjs +41 -71
- package/hooks/ship-captain.mjs +1176 -0
- package/hooks/ship-gate.mjs +971 -0
- package/hooks/summary-checkpoint.mjs +31 -4
- package/hooks/test-orchestrator.mjs +1975 -11
- package/install.mjs +1064 -31
- package/orchestrator.json +73 -96
- package/package.json +7 -2
|
@@ -2,9 +2,22 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* risk-classifier.mjs — File-path risk classification for adaptive routing.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Exports:
|
|
6
|
+
* classifyRisk(paths) → { level, reason } (static, backward-compat)
|
|
7
|
+
* classifyRiskEnhanced(filePath) → { risk, basis, details } (empirical, v4.3.0+)
|
|
8
|
+
* getGitChurn(filePath, days?) → { commits, isHot } | null
|
|
9
|
+
* getFileRiskHistory(filePath) → { total, failures, success_rate, risk_adjustment }
|
|
10
|
+
* extractPaths(text) → string[]
|
|
6
11
|
*/
|
|
7
12
|
|
|
13
|
+
import { execSync } from 'child_process';
|
|
14
|
+
import { existsSync, readFileSync } from 'fs';
|
|
15
|
+
import { dirname, join } from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
|
|
20
|
+
|
|
8
21
|
const PATTERNS = [
|
|
9
22
|
{ level: 'critical', regex: /\b(auth|credential|secret|\.env|key[s]?|token[s]?|password|encrypt|certificate|cert[s]?|\.pem|\.key)\b/i, label: 'security-sensitive' },
|
|
10
23
|
{ level: 'high', regex: /\b(billing|payment|migration|deploy|ci[-/]cd|\.github\/workflows|security|permission|policy|schema\.prisma|schema\.sql|api[-_]?contract|openapi|swagger)\b/i, label: 'high-impact infrastructure' },
|
|
@@ -13,6 +26,7 @@ const PATTERNS = [
|
|
|
13
26
|
];
|
|
14
27
|
|
|
15
28
|
const LEVEL_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
29
|
+
const LEVEL_UP = { low: 'medium', medium: 'high', high: 'critical', critical: 'critical' };
|
|
16
30
|
|
|
17
31
|
function classifyRisk(paths) {
|
|
18
32
|
if (!paths || paths.length === 0) return { level: 'low', reason: 'no file paths detected' };
|
|
@@ -31,6 +45,125 @@ function classifyRisk(paths) {
|
|
|
31
45
|
return highest;
|
|
32
46
|
}
|
|
33
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Count how many commits touched a file in the last N days using git log.
|
|
50
|
+
* Returns { commits, isHot: commits > 10 }, or null if git is unavailable.
|
|
51
|
+
*/
|
|
52
|
+
function getGitChurn(filePath, days = 30) {
|
|
53
|
+
try {
|
|
54
|
+
const output = execSync(
|
|
55
|
+
`git log --oneline --since="${days} days ago" -- "${filePath}"`,
|
|
56
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
|
|
57
|
+
);
|
|
58
|
+
const commits = output.split('\n').filter(Boolean).length;
|
|
59
|
+
return { commits, isHot: commits > 10 };
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read decision-ledger.jsonl and compute success rate for entries that
|
|
67
|
+
* touched this file path or its parent directory.
|
|
68
|
+
*
|
|
69
|
+
* Returns { total, failures, success_rate, risk_adjustment } where
|
|
70
|
+
* risk_adjustment is 'escalate' if success_rate < 60% with 3+ entries,
|
|
71
|
+
* 'normal' otherwise.
|
|
72
|
+
*/
|
|
73
|
+
function getFileRiskHistory(filePath) {
|
|
74
|
+
const empty = { total: 0, failures: 0, success_rate: 100, risk_adjustment: 'normal' };
|
|
75
|
+
if (!existsSync(LEDGER_FILE)) return empty;
|
|
76
|
+
|
|
77
|
+
let raw;
|
|
78
|
+
try { raw = readFileSync(LEDGER_FILE, 'utf8'); } catch { return empty; }
|
|
79
|
+
|
|
80
|
+
// Normalize the file path and compute its parent directory prefix
|
|
81
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
82
|
+
const parentDir = normalizedPath.includes('/') ? normalizedPath.slice(0, normalizedPath.lastIndexOf('/')) : '';
|
|
83
|
+
|
|
84
|
+
let total = 0;
|
|
85
|
+
let failures = 0;
|
|
86
|
+
|
|
87
|
+
for (const line of raw.split('\n').filter(Boolean)) {
|
|
88
|
+
try {
|
|
89
|
+
const entry = JSON.parse(line);
|
|
90
|
+
if (entry.type !== 'outcome') continue;
|
|
91
|
+
|
|
92
|
+
const files = entry.files_changed || entry.files_read || [];
|
|
93
|
+
if (!Array.isArray(files)) continue;
|
|
94
|
+
|
|
95
|
+
const matches = files.some(f => {
|
|
96
|
+
const nf = String(f).replace(/\\/g, '/');
|
|
97
|
+
return nf === normalizedPath ||
|
|
98
|
+
nf.includes(normalizedPath) ||
|
|
99
|
+
(parentDir && nf.includes(parentDir));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (!matches) continue;
|
|
103
|
+
|
|
104
|
+
total++;
|
|
105
|
+
if (entry.success === false) failures++;
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (total === 0) return empty;
|
|
110
|
+
|
|
111
|
+
const success_rate = Math.round(((total - failures) / total) * 100);
|
|
112
|
+
const risk_adjustment = (success_rate < 60 && total >= 3) ? 'escalate' : 'normal';
|
|
113
|
+
|
|
114
|
+
return { total, failures, success_rate, risk_adjustment };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Enhanced risk classifier that combines static patterns with empirical data.
|
|
119
|
+
*
|
|
120
|
+
* Returns { risk, basis, details } where:
|
|
121
|
+
* risk — 'low' | 'medium' | 'high' | 'critical'
|
|
122
|
+
* basis — 'static' | 'churn' | 'history' | 'churn+history'
|
|
123
|
+
* details — { static_risk, churn_commits, history_success_rate }
|
|
124
|
+
*/
|
|
125
|
+
function classifyRiskEnhanced(filePath) {
|
|
126
|
+
// Step 1: static pattern classification
|
|
127
|
+
const staticResult = classifyRisk([filePath]);
|
|
128
|
+
let risk = staticResult.level;
|
|
129
|
+
const details = {
|
|
130
|
+
static_risk: risk,
|
|
131
|
+
churn_commits: null,
|
|
132
|
+
history_success_rate: null,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let bumpedByChurn = false;
|
|
136
|
+
let bumpedByHistory = false;
|
|
137
|
+
|
|
138
|
+
// Step 2: git churn check
|
|
139
|
+
const churn = getGitChurn(filePath);
|
|
140
|
+
if (churn !== null) {
|
|
141
|
+
details.churn_commits = churn.commits;
|
|
142
|
+
if (churn.isHot && risk !== 'critical') {
|
|
143
|
+
risk = LEVEL_UP[risk];
|
|
144
|
+
bumpedByChurn = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Step 3: file risk history check
|
|
149
|
+
const history = getFileRiskHistory(filePath);
|
|
150
|
+
details.history_success_rate = history.success_rate;
|
|
151
|
+
if (history.risk_adjustment === 'escalate' && risk !== 'critical') {
|
|
152
|
+
risk = LEVEL_UP[risk];
|
|
153
|
+
bumpedByHistory = true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Cap at critical
|
|
157
|
+
if (LEVEL_ORDER[risk] > LEVEL_ORDER['critical']) risk = 'critical';
|
|
158
|
+
|
|
159
|
+
let basis = 'static';
|
|
160
|
+
if (bumpedByChurn && bumpedByHistory) basis = 'churn+history';
|
|
161
|
+
else if (bumpedByChurn) basis = 'churn';
|
|
162
|
+
else if (bumpedByHistory) basis = 'history';
|
|
163
|
+
|
|
164
|
+
return { risk, basis, details };
|
|
165
|
+
}
|
|
166
|
+
|
|
34
167
|
function extractPaths(text) {
|
|
35
168
|
if (!text) return [];
|
|
36
169
|
const matches = text.match(/(?:^|\s|["'`])([./~]?(?:[\w@.-]+\/)+[\w@.*-]+(?:\.\w+)?)/g);
|
|
@@ -38,4 +171,4 @@ function extractPaths(text) {
|
|
|
38
171
|
return matches.map(m => m.trim().replace(/^["'`]/, ''));
|
|
39
172
|
}
|
|
40
173
|
|
|
41
|
-
export { classifyRisk, extractPaths };
|
|
174
|
+
export { classifyRisk, classifyRiskEnhanced, getGitChurn, getFileRiskHistory, extractPaths };
|
package/hooks/session-report.mjs
CHANGED
|
@@ -61,56 +61,34 @@ function boxTitle(s) {
|
|
|
61
61
|
// ---------------------------------------------------------------------------
|
|
62
62
|
function padR(s, n) { s = String(s); return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length); }
|
|
63
63
|
function padL(s, n) { s = String(s); return s.length >= n ? s.slice(0, n) : ' '.repeat(n - s.length) + s; }
|
|
64
|
-
function fmt$(n) { return '$' + n.toFixed(2); }
|
|
65
64
|
|
|
66
65
|
// ---------------------------------------------------------------------------
|
|
67
|
-
// Load orchestrator config
|
|
66
|
+
// Load orchestrator config (used by drift section)
|
|
68
67
|
// ---------------------------------------------------------------------------
|
|
69
68
|
function loadConfig() {
|
|
70
69
|
try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return null; }
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
function buildRateMap(config) {
|
|
74
|
-
const rates = {};
|
|
75
|
-
if (!config?.subscriptions) return rates;
|
|
76
|
-
for (const provider of Object.values(config.subscriptions)) {
|
|
77
|
-
for (const [modelKey, data] of Object.entries(provider.models || {})) {
|
|
78
|
-
rates[modelKey] = {
|
|
79
|
-
tier: data.tier,
|
|
80
|
-
input_per_mtok: data.input_per_mtok,
|
|
81
|
-
output_per_mtok: data.output_per_mtok,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return rates;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
72
|
// ---------------------------------------------------------------------------
|
|
89
|
-
//
|
|
73
|
+
// Activity scoring (mirrors summary-checkpoint.mjs formula)
|
|
90
74
|
// ---------------------------------------------------------------------------
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
function estimateCost(tier, model, rateMap, record = {}) {
|
|
98
|
-
const heuristic = TOKEN_HEURISTICS[tier] || TOKEN_HEURISTICS.execute;
|
|
75
|
+
const TIER_ACTIVITY_WEIGHTS = { search: 3, execute: 10, think: 25 };
|
|
76
|
+
const SESSION_ACTIVITY_CEILING = 5_000_000;
|
|
77
|
+
|
|
78
|
+
function computeActivity(tier, record = {}) {
|
|
99
79
|
const hasActual = record.input_tokens != null && record.output_tokens != null;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const rate = rateMap[model] || rateMap['main-session'];
|
|
103
|
-
if (!rate) {
|
|
104
|
-
const fallbackTier = (model === 'main-session' || model === 'unknown') ? 'think' : tier;
|
|
105
|
-
const tierRate =
|
|
106
|
-
Object.values(rateMap).find(r => r.tier === fallbackTier) ||
|
|
107
|
-
Object.values(rateMap).find(r => r.tier === tier);
|
|
108
|
-
if (!tierRate) return 0;
|
|
109
|
-
return (inputTok / 1_000_000) * tierRate.input_per_mtok +
|
|
110
|
-
(outputTok / 1_000_000) * tierRate.output_per_mtok;
|
|
80
|
+
if (hasActual) {
|
|
81
|
+
return (record.input_tokens * 1) + (record.output_tokens * 3);
|
|
111
82
|
}
|
|
112
|
-
return
|
|
113
|
-
|
|
83
|
+
return TIER_ACTIVITY_WEIGHTS[tier] || TIER_ACTIVITY_WEIGHTS.execute;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function activityLabel(score) {
|
|
87
|
+
if (score <= 10) return 'minimal';
|
|
88
|
+
if (score <= 30) return 'light';
|
|
89
|
+
if (score <= 60) return 'moderate';
|
|
90
|
+
if (score <= 85) return 'heavy';
|
|
91
|
+
return 'intense';
|
|
114
92
|
}
|
|
115
93
|
|
|
116
94
|
// ---------------------------------------------------------------------------
|
|
@@ -153,52 +131,54 @@ function loadTodayRecords() {
|
|
|
153
131
|
const TIER_ORDER = ['search', 'execute', 'think'];
|
|
154
132
|
const TIER_LABELS = { search: 'Search ', execute: 'Execute', think: 'Think ' };
|
|
155
133
|
|
|
156
|
-
function buildActivitySection(records
|
|
134
|
+
function buildActivitySection(records) {
|
|
157
135
|
// Aggregate by tier — only non-recommendation records
|
|
158
136
|
const activity = records.filter(r => r.type !== 'tier_recommendation');
|
|
159
137
|
|
|
160
138
|
const buckets = {};
|
|
161
139
|
for (const r of activity) {
|
|
162
140
|
const tier = r.tier || 'execute';
|
|
163
|
-
|
|
164
|
-
if (!buckets[tier]) buckets[tier] = { calls: 0, cost: 0, actualCount: 0 };
|
|
141
|
+
if (!buckets[tier]) buckets[tier] = { calls: 0, activityRaw: 0, actualCount: 0 };
|
|
165
142
|
buckets[tier].calls += 1;
|
|
166
|
-
buckets[tier].
|
|
143
|
+
buckets[tier].activityRaw += computeActivity(tier, r);
|
|
167
144
|
if (r.input_tokens != null && r.output_tokens != null) buckets[tier].actualCount += 1;
|
|
168
145
|
}
|
|
169
146
|
|
|
147
|
+
const totalRaw = Object.values(buckets).reduce((s, b) => s + b.activityRaw, 0);
|
|
148
|
+
const totalScore = Math.min(100, Math.round((totalRaw / SESSION_ACTIVITY_CEILING) * 100));
|
|
149
|
+
|
|
170
150
|
const lines = [];
|
|
171
151
|
lines.push(boxLine('Activity Summary'));
|
|
172
152
|
lines.push(boxLine('─'.repeat(INNER)));
|
|
173
153
|
|
|
174
|
-
// Column widths: Tier(8) │ Calls(6) │
|
|
175
|
-
const header = padR('Tier', 8) + ' │ ' + padL('Calls', 5) + ' │ ' + padL('
|
|
154
|
+
// Column widths: Tier(8) │ Calls(6) │ Activity %(10)
|
|
155
|
+
const header = padR('Tier', 8) + ' │ ' + padL('Calls', 5) + ' │ ' + padL('Activity %', 10);
|
|
176
156
|
const divRow = '─'.repeat(8) + '─┼─' + '─'.repeat(5) + '─┼─' + '─'.repeat(10);
|
|
177
157
|
lines.push(boxLine(header));
|
|
178
158
|
lines.push(boxLine(divRow));
|
|
179
159
|
|
|
180
160
|
let totalCalls = 0;
|
|
181
|
-
let totalCost = 0;
|
|
182
161
|
|
|
183
162
|
for (const tier of TIER_ORDER) {
|
|
184
163
|
const b = buckets[tier];
|
|
185
164
|
if (!b) continue;
|
|
186
165
|
const label = padR(TIER_LABELS[tier] || tier, 8);
|
|
187
166
|
const calls = padL(String(b.calls), 5);
|
|
188
|
-
const
|
|
189
|
-
|
|
167
|
+
const pct = totalRaw > 0 ? Math.round((b.activityRaw / totalRaw) * 100) : 0;
|
|
168
|
+
const pctStr = padL(`${pct}%`, 10);
|
|
169
|
+
lines.push(boxLine(`${label} │ ${calls} │ ${pctStr}`));
|
|
190
170
|
totalCalls += b.calls;
|
|
191
|
-
totalCost += b.cost;
|
|
192
171
|
}
|
|
193
172
|
|
|
194
173
|
lines.push(boxLine(divRow));
|
|
195
|
-
lines.push(boxLine(padR('Total', 8) + ' │ ' + padL(String(totalCalls), 5) + ' │ ' + padL(
|
|
174
|
+
lines.push(boxLine(padR('Total', 8) + ' │ ' + padL(String(totalCalls), 5) + ' │ ' + padL(`${totalScore}/100`, 10)));
|
|
175
|
+
lines.push(boxLine(`Activity: ${totalScore}/100 (${activityLabel(totalScore)})`));
|
|
196
176
|
|
|
197
177
|
if (totalCalls === 0) {
|
|
198
178
|
lines.push(boxLine(' (no usage data recorded today)'));
|
|
199
179
|
}
|
|
200
180
|
|
|
201
|
-
return { lines, totalCalls,
|
|
181
|
+
return { lines, totalCalls, totalScore, buckets };
|
|
202
182
|
}
|
|
203
183
|
|
|
204
184
|
// ---------------------------------------------------------------------------
|
|
@@ -267,7 +247,7 @@ function buildProviderBalanceSection(records) {
|
|
|
267
247
|
// ---------------------------------------------------------------------------
|
|
268
248
|
// Section 2: Routing Compliance
|
|
269
249
|
// ---------------------------------------------------------------------------
|
|
270
|
-
function buildComplianceSection(records
|
|
250
|
+
function buildComplianceSection(records) {
|
|
271
251
|
const recs = records.filter(r => r.type === 'tier_recommendation');
|
|
272
252
|
|
|
273
253
|
const total = recs.length;
|
|
@@ -276,24 +256,15 @@ function buildComplianceSection(records, rateMap) {
|
|
|
276
256
|
const followPct = total > 0 ? Math.round((followed / total) * 100) : 0;
|
|
277
257
|
const ignorePct = total > 0 ? 100 - followPct : 0;
|
|
278
258
|
|
|
279
|
-
//
|
|
280
|
-
let
|
|
259
|
+
// Activity waste: diff between actual-tier weight and recommended-tier weight
|
|
260
|
+
let wastedActivity = 0;
|
|
281
261
|
for (const r of recs) {
|
|
282
262
|
if (r.followed === true) continue;
|
|
283
263
|
if (!r.recommended_tier || !r.actual_tier) continue;
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const actRate = Object.values(rateMap).find(x => x.tier === r.actual_tier);
|
|
289
|
-
if (!recRate || !actRate) continue;
|
|
290
|
-
|
|
291
|
-
const recCost = (recommended.input / 1_000_000) * recRate.input_per_mtok +
|
|
292
|
-
(recommended.output / 1_000_000) * recRate.output_per_mtok;
|
|
293
|
-
const actCost = (actual.input / 1_000_000) * actRate.input_per_mtok +
|
|
294
|
-
(actual.output / 1_000_000) * actRate.output_per_mtok;
|
|
295
|
-
const delta = actCost - recCost;
|
|
296
|
-
if (delta > 0) overspend += delta;
|
|
264
|
+
const recWeight = TIER_ACTIVITY_WEIGHTS[r.recommended_tier] || TIER_ACTIVITY_WEIGHTS.execute;
|
|
265
|
+
const actWeight = TIER_ACTIVITY_WEIGHTS[r.actual_tier] || TIER_ACTIVITY_WEIGHTS.execute;
|
|
266
|
+
const delta = actWeight - recWeight;
|
|
267
|
+
if (delta > 0) wastedActivity += delta;
|
|
297
268
|
}
|
|
298
269
|
|
|
299
270
|
const lines = [];
|
|
@@ -302,7 +273,7 @@ function buildComplianceSection(records, rateMap) {
|
|
|
302
273
|
lines.push(boxLine(`Recommendations: ${total}`));
|
|
303
274
|
lines.push(boxLine(`Followed: ${followed} (${followPct}%)`));
|
|
304
275
|
lines.push(boxLine(`Ignored: ${ignored} (${ignorePct}%)`));
|
|
305
|
-
lines.push(boxLine(`
|
|
276
|
+
lines.push(boxLine(`Wasted activity: ${wastedActivity} units (from misrouted calls)`));
|
|
306
277
|
|
|
307
278
|
return { lines };
|
|
308
279
|
}
|
|
@@ -463,7 +434,6 @@ function buildDriftSection(config) {
|
|
|
463
434
|
// ---------------------------------------------------------------------------
|
|
464
435
|
function main() {
|
|
465
436
|
const config = loadConfig();
|
|
466
|
-
const rateMap = buildRateMap(config);
|
|
467
437
|
const records = loadTodayRecords();
|
|
468
438
|
|
|
469
439
|
const output = [];
|
|
@@ -473,7 +443,7 @@ function main() {
|
|
|
473
443
|
output.push(boxDiv());
|
|
474
444
|
|
|
475
445
|
// --- Section 1: Activity Summary ---
|
|
476
|
-
const { lines: actLines } = buildActivitySection(records
|
|
446
|
+
const { lines: actLines } = buildActivitySection(records);
|
|
477
447
|
output.push(...actLines);
|
|
478
448
|
output.push(boxBlank());
|
|
479
449
|
|
|
@@ -483,7 +453,7 @@ function main() {
|
|
|
483
453
|
output.push(boxBlank());
|
|
484
454
|
|
|
485
455
|
// --- Section 2: Routing Compliance ---
|
|
486
|
-
const { lines: compLines } = buildComplianceSection(records
|
|
456
|
+
const { lines: compLines } = buildComplianceSection(records);
|
|
487
457
|
output.push(...compLines);
|
|
488
458
|
output.push(boxBlank());
|
|
489
459
|
|