neurain 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +57 -0
- package/README.md +205 -0
- package/SECURITY.md +22 -0
- package/bin/neurain.mjs +7 -0
- package/docs/comparison-mem0.en.md +22 -0
- package/docs/connect-claude.en.md +48 -0
- package/docs/connect-claude.kr.md +51 -0
- package/docs/connect-codex.en.md +38 -0
- package/docs/connect-codex.kr.md +40 -0
- package/docs/connect-gemini.en.md +71 -0
- package/docs/connect-gemini.kr.md +71 -0
- package/docs/connect-runtime.en.md +61 -0
- package/docs/connect-runtime.kr.md +61 -0
- package/docs/development-status.en.md +157 -0
- package/docs/development-status.kr.md +157 -0
- package/docs/knowledge-os.en.md +105 -0
- package/docs/knowledge-os.kr.md +106 -0
- package/docs/pricing.en.md +14 -0
- package/docs/privacy-and-data-flow.en.md +25 -0
- package/docs/public-saas-readiness.en.md +39 -0
- package/docs/quickstart.en.md +64 -0
- package/docs/quickstart.kr.md +64 -0
- package/docs/release-checklist.en.md +38 -0
- package/docs/safety.en.md +36 -0
- package/docs/self-improvement-90-roadmap.en.md +429 -0
- package/docs/self-improvement-90-roadmap.kr.md +429 -0
- package/docs/self-improving-workflows.en.md +163 -0
- package/docs/self-improving-workflows.kr.md +163 -0
- package/docs/support.en.md +17 -0
- package/docs/troubleshooting.en.md +35 -0
- package/package.json +36 -0
- package/src/cli.mjs +261 -0
- package/src/core/adopt.mjs +304 -0
- package/src/core/answer_eval.mjs +450 -0
- package/src/core/capabilities.mjs +217 -0
- package/src/core/capture_durable.mjs +181 -0
- package/src/core/classify.mjs +237 -0
- package/src/core/compile_desk.mjs +324 -0
- package/src/core/complete.mjs +108 -0
- package/src/core/config.mjs +142 -0
- package/src/core/connect.mjs +355 -0
- package/src/core/curator.mjs +351 -0
- package/src/core/daemon.mjs +536 -0
- package/src/core/digest.mjs +155 -0
- package/src/core/doctor.mjs +115 -0
- package/src/core/durable.mjs +96 -0
- package/src/core/envelope.mjs +97 -0
- package/src/core/flush.mjs +190 -0
- package/src/core/fs.mjs +121 -0
- package/src/core/init.mjs +194 -0
- package/src/core/journal.mjs +269 -0
- package/src/core/labels.mjs +117 -0
- package/src/core/lessons.mjs +793 -0
- package/src/core/lifecycle.mjs +1138 -0
- package/src/core/link_check.mjs +180 -0
- package/src/core/live_cases.mjs +221 -0
- package/src/core/onboard.mjs +175 -0
- package/src/core/plan_receipt.mjs +177 -0
- package/src/core/plan_writeback.mjs +176 -0
- package/src/core/queue.mjs +62 -0
- package/src/core/queue_archive.mjs +87 -0
- package/src/core/queue_model.mjs +161 -0
- package/src/core/queue_write.mjs +28 -0
- package/src/core/recall.mjs +1802 -0
- package/src/core/recall_bench.mjs +275 -0
- package/src/core/recall_corpus.mjs +152 -0
- package/src/core/recall_facts.mjs +233 -0
- package/src/core/recall_intel.mjs +233 -0
- package/src/core/recall_lexical.mjs +269 -0
- package/src/core/recap.mjs +78 -0
- package/src/core/review_queue.mjs +131 -0
- package/src/core/review_worker.mjs +284 -0
- package/src/core/route.mjs +73 -0
- package/src/core/safety.mjs +57 -0
- package/src/core/scheduler.mjs +697 -0
- package/src/core/search.mjs +54 -0
- package/src/core/secret_scan.mjs +143 -0
- package/src/core/semantic.mjs +187 -0
- package/src/core/source_digest.mjs +56 -0
- package/src/core/source_digest_gen.mjs +311 -0
- package/src/core/stage.mjs +105 -0
- package/src/core/status.mjs +175 -0
- package/src/core/vault_state.mjs +115 -0
- package/src/core/watch.mjs +282 -0
- package/src/core/wiki_log.mjs +29 -0
- package/src/core/wrap.mjs +62 -0
- package/src/mcp/server.mjs +865 -0
- package/templates/starter-vault/README.md +9 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { absPath, ensureDir, generatedPath, readText, relPath, safeResolve, sha256 } from './fs.mjs';
|
|
5
|
+
import { appendJournalEvent } from './journal.mjs';
|
|
6
|
+
import { buildReviewWorkerReport } from './review_worker.mjs';
|
|
7
|
+
import { buildWatchReport } from './watch.mjs';
|
|
8
|
+
|
|
9
|
+
const MEANINGFUL_EVENT_TYPES = new Set(['correction', 'review', 'rollback', 'test', 'lesson', 'curator']);
|
|
10
|
+
const MODES = new Set(['status', 'tick', 'monitor', 'eval']);
|
|
11
|
+
const MAX_SCHEDULER_EVAL_CASES = 500;
|
|
12
|
+
const MAX_CASE_FILE_BYTES = 1024 * 1024;
|
|
13
|
+
const MAX_CASE_TEXT_BYTES = 20 * 1024;
|
|
14
|
+
const MAX_CASE_TOTAL_TEXT_BYTES = 250 * 1024;
|
|
15
|
+
const MAX_CASE_FILES = 20;
|
|
16
|
+
const MAX_CASE_EVENTS = 50;
|
|
17
|
+
const MAX_PRIVATE_MARKERS = 20;
|
|
18
|
+
const TARGET_SNAPSHOT_MAX_FILES = 20000;
|
|
19
|
+
const TARGET_SNAPSHOT_LARGE_FILE_BYTES = 5 * 1024 * 1024;
|
|
20
|
+
const SNAPSHOT_EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.next', 'out', '.vercel', '.cache', '.DS_Store']);
|
|
21
|
+
|
|
22
|
+
export async function schedulerCommand(args) {
|
|
23
|
+
const { mode, root } = parseSchedulerTarget(args);
|
|
24
|
+
if (args.daemon || args.follow || args.install || args.apply || args.write || args.promote) {
|
|
25
|
+
throw new Error('Public alpha supports scheduler status, tick, eval, and bounded monitor reports only. It cannot install, follow, run as a daemon, write, apply, or promote.');
|
|
26
|
+
}
|
|
27
|
+
if (mode === 'eval') {
|
|
28
|
+
const payload = await evaluateSchedulerTriggering(root, {
|
|
29
|
+
fixtureSize: positiveIntegerOption(args['fixture-size'], 100, 'fixture-size'),
|
|
30
|
+
minCases: positiveIntegerOption(args['min-cases'], 100, 'min-cases'),
|
|
31
|
+
caseFile: args['case-file'] || '',
|
|
32
|
+
top: Number(args.top || 10),
|
|
33
|
+
sinceMinutes: Number(args['since-minutes'] || 1440),
|
|
34
|
+
});
|
|
35
|
+
if (args.json) return { json: true, payload };
|
|
36
|
+
return renderSchedulerEvalText(payload);
|
|
37
|
+
}
|
|
38
|
+
if (mode === 'monitor') {
|
|
39
|
+
const payload = await buildSchedulerMonitor(root, {
|
|
40
|
+
area: args.area || '',
|
|
41
|
+
top: Number(args.top || 10),
|
|
42
|
+
sinceMinutes: Number(args['since-minutes'] || 1440),
|
|
43
|
+
minTriggers: Number(args['min-triggers'] || 1),
|
|
44
|
+
minEvents: Number(args['min-events'] || 1),
|
|
45
|
+
maxTicks: numberOrDefault(args['max-ticks'], 3),
|
|
46
|
+
intervalSeconds: numberOrDefault(args['interval-seconds'], 60),
|
|
47
|
+
});
|
|
48
|
+
if (args.json) return { json: true, payload };
|
|
49
|
+
return renderMonitorText(payload);
|
|
50
|
+
}
|
|
51
|
+
const payload = buildSchedulerTick(root, {
|
|
52
|
+
command: `scheduler ${mode}`,
|
|
53
|
+
area: args.area || '',
|
|
54
|
+
top: Number(args.top || 10),
|
|
55
|
+
sinceMinutes: Number(args['since-minutes'] || 1440),
|
|
56
|
+
minTriggers: Number(args['min-triggers'] || 1),
|
|
57
|
+
minEvents: Number(args['min-events'] || 1),
|
|
58
|
+
includeReview: mode === 'tick',
|
|
59
|
+
});
|
|
60
|
+
if (args.json) return { json: true, payload };
|
|
61
|
+
return {
|
|
62
|
+
text: [
|
|
63
|
+
`# Neurain scheduler ${mode}`,
|
|
64
|
+
'',
|
|
65
|
+
`- Root: ${root}`,
|
|
66
|
+
`- Area: ${payload.area || 'all'}`,
|
|
67
|
+
'- Durable write: no',
|
|
68
|
+
'- Daemon started: no',
|
|
69
|
+
`- Review due: ${payload.review_fork.should_run ? 'yes' : 'no'}`,
|
|
70
|
+
`- Reason: ${payload.review_fork.reason}`,
|
|
71
|
+
`- Suggested command: ${payload.next_suggested_command}`,
|
|
72
|
+
].join('\n'),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function evaluateSchedulerTriggering(root, {
|
|
77
|
+
fixtureSize = 100,
|
|
78
|
+
minCases = 100,
|
|
79
|
+
caseFile = '',
|
|
80
|
+
top = 10,
|
|
81
|
+
sinceMinutes = 1440,
|
|
82
|
+
} = {}) {
|
|
83
|
+
const snapshot = targetSnapshot(root);
|
|
84
|
+
const cases = caseFile
|
|
85
|
+
? readSchedulerEvalCaseFile(root, caseFile)
|
|
86
|
+
: syntheticSchedulerCases(fixtureSize);
|
|
87
|
+
const requiredCases = Math.max(1, Number(minCases || (caseFile ? 1 : 100)));
|
|
88
|
+
const results = [];
|
|
89
|
+
let tempRootRetained = false;
|
|
90
|
+
for (const item of cases) {
|
|
91
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'neurain-scheduler-eval-'));
|
|
92
|
+
try {
|
|
93
|
+
applySchedulerEvalCase(tempRoot, item);
|
|
94
|
+
const tick = buildSchedulerTick(tempRoot, {
|
|
95
|
+
command: 'scheduler eval tick',
|
|
96
|
+
top,
|
|
97
|
+
sinceMinutes,
|
|
98
|
+
minTriggers: Number(item.min_triggers || 1),
|
|
99
|
+
minEvents: Number(item.min_events || 1),
|
|
100
|
+
includeReview: true,
|
|
101
|
+
});
|
|
102
|
+
results.push(resultForSchedulerCase(item, tick));
|
|
103
|
+
} finally {
|
|
104
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
105
|
+
if (fs.existsSync(tempRoot)) tempRootRetained = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const stats = schedulerEvalStats(results);
|
|
110
|
+
const afterSnapshot = targetSnapshot(root);
|
|
111
|
+
const targetUntouched = afterSnapshot.hash === snapshot.hash;
|
|
112
|
+
const snapshotComplete = !snapshot.limit_reached
|
|
113
|
+
&& !afterSnapshot.limit_reached
|
|
114
|
+
&& snapshot.unreadable_count === 0
|
|
115
|
+
&& afterSnapshot.unreadable_count === 0;
|
|
116
|
+
const gates = {
|
|
117
|
+
enough_cases: results.length >= requiredCases,
|
|
118
|
+
trigger_precision: stats.trigger_precision >= 0.9,
|
|
119
|
+
trigger_recall: stats.trigger_recall >= 0.9,
|
|
120
|
+
false_positive_rate: stats.false_positive_rate <= 0.1,
|
|
121
|
+
false_negative_rate: stats.false_negative_rate <= 0.1,
|
|
122
|
+
review_report_cases_present: stats.review_report_case_count > 0,
|
|
123
|
+
no_recursion_rate: stats.no_recursion_rate >= 1,
|
|
124
|
+
triggered_no_recursion_rate: stats.triggered_no_recursion_rate >= 1,
|
|
125
|
+
private_boundary_cases_present: stats.private_boundary_case_count > 0,
|
|
126
|
+
private_boundary_rate: stats.private_boundary_rate >= 0.95,
|
|
127
|
+
target_root_untouched: targetUntouched,
|
|
128
|
+
target_snapshot_complete: snapshotComplete,
|
|
129
|
+
temp_root_cleanup: !tempRootRetained,
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
ok: Object.values(gates).every(Boolean),
|
|
133
|
+
command: 'scheduler eval',
|
|
134
|
+
eval_type: caseFile ? 'scheduler_trigger_cases' : 'scheduler_trigger_fixture',
|
|
135
|
+
metric_scope: caseFile
|
|
136
|
+
? 'reviewed_background_review_trigger_cases'
|
|
137
|
+
: 'synthetic_background_review_trigger_precision_recall_regression',
|
|
138
|
+
scheduler_trigger_evaluated: true,
|
|
139
|
+
reviewed_scheduler_trigger_evaluated: Boolean(caseFile),
|
|
140
|
+
background_review_trigger_quality_evaluated: true,
|
|
141
|
+
durable_write: false,
|
|
142
|
+
model_calls: false,
|
|
143
|
+
external_tool_calls: false,
|
|
144
|
+
case_file: caseFile || null,
|
|
145
|
+
evaluated_cases: results.length,
|
|
146
|
+
required_cases: requiredCases,
|
|
147
|
+
...stats,
|
|
148
|
+
gates,
|
|
149
|
+
target_root_untouched: targetUntouched,
|
|
150
|
+
target_root_snapshot: {
|
|
151
|
+
file_count_before: snapshot.file_count,
|
|
152
|
+
file_count_after: afterSnapshot.file_count,
|
|
153
|
+
limit_reached_before: snapshot.limit_reached,
|
|
154
|
+
limit_reached_after: afterSnapshot.limit_reached,
|
|
155
|
+
unreadable_before: snapshot.unreadable_count,
|
|
156
|
+
unreadable_after: afterSnapshot.unreadable_count,
|
|
157
|
+
large_file_count_before: snapshot.large_file_count,
|
|
158
|
+
large_file_count_after: afterSnapshot.large_file_count,
|
|
159
|
+
},
|
|
160
|
+
temp_root_retained: tempRootRetained,
|
|
161
|
+
temp_root_cleanup_verified: !tempRootRetained,
|
|
162
|
+
failed_cases: results.filter((result) => !result.ok).map((result) => ({
|
|
163
|
+
id: result.id,
|
|
164
|
+
expected_should_run: result.expected_should_run,
|
|
165
|
+
predicted_should_run: result.predicted_should_run,
|
|
166
|
+
failed_reasons: result.failed_reasons,
|
|
167
|
+
})),
|
|
168
|
+
case_results: results.map((result) => ({
|
|
169
|
+
id: result.id,
|
|
170
|
+
expected_should_run: result.expected_should_run,
|
|
171
|
+
predicted_should_run: result.predicted_should_run,
|
|
172
|
+
no_recursion_pass: result.no_recursion_pass,
|
|
173
|
+
review_report_evaluated: result.review_report_evaluated,
|
|
174
|
+
private_boundary_pass: result.private_boundary_pass,
|
|
175
|
+
private_boundary_evaluated: result.private_boundary_evaluated,
|
|
176
|
+
})),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function buildSchedulerMonitor(root, {
|
|
181
|
+
area = '',
|
|
182
|
+
top = 10,
|
|
183
|
+
sinceMinutes = 1440,
|
|
184
|
+
minTriggers = 1,
|
|
185
|
+
minEvents = 1,
|
|
186
|
+
maxTicks = 3,
|
|
187
|
+
intervalSeconds = 60,
|
|
188
|
+
} = {}) {
|
|
189
|
+
const numericMaxTicks = Math.max(1, Math.min(numberOrDefault(maxTicks, 3), 20));
|
|
190
|
+
const numericInterval = Math.max(0, Math.min(numberOrDefault(intervalSeconds, 60), 3600));
|
|
191
|
+
const ticks = [];
|
|
192
|
+
for (let index = 0; index < numericMaxTicks; index += 1) {
|
|
193
|
+
const tick = buildSchedulerTick(root, {
|
|
194
|
+
command: 'scheduler monitor tick',
|
|
195
|
+
area,
|
|
196
|
+
top,
|
|
197
|
+
sinceMinutes,
|
|
198
|
+
minTriggers,
|
|
199
|
+
minEvents,
|
|
200
|
+
includeReview: true,
|
|
201
|
+
});
|
|
202
|
+
ticks.push({
|
|
203
|
+
...tick,
|
|
204
|
+
monitor_tick: {
|
|
205
|
+
index: index + 1,
|
|
206
|
+
max_ticks: numericMaxTicks,
|
|
207
|
+
interval_seconds: numericInterval,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
if (index < numericMaxTicks - 1 && numericInterval > 0) {
|
|
211
|
+
await sleep(numericInterval * 1000);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
command: 'scheduler monitor',
|
|
217
|
+
root,
|
|
218
|
+
area: area || null,
|
|
219
|
+
durable_write: false,
|
|
220
|
+
model_calls: false,
|
|
221
|
+
external_tool_calls: false,
|
|
222
|
+
daemon_started: false,
|
|
223
|
+
write_policy: 'read_only_scheduler_monitor',
|
|
224
|
+
monitor: {
|
|
225
|
+
mode: 'foreground_bounded',
|
|
226
|
+
max_ticks: numericMaxTicks,
|
|
227
|
+
interval_seconds: numericInterval,
|
|
228
|
+
completed_ticks: ticks.length,
|
|
229
|
+
install_supported: false,
|
|
230
|
+
daemon_supported: false,
|
|
231
|
+
background_job_started: false,
|
|
232
|
+
},
|
|
233
|
+
ticks,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function buildSchedulerTick(root, {
|
|
238
|
+
command = 'scheduler tick',
|
|
239
|
+
area = '',
|
|
240
|
+
top = 10,
|
|
241
|
+
sinceMinutes = 1440,
|
|
242
|
+
minTriggers = 1,
|
|
243
|
+
minEvents = 1,
|
|
244
|
+
includeReview = true,
|
|
245
|
+
} = {}) {
|
|
246
|
+
const numericTop = Math.max(1, Math.min(Number(top || 10), 50));
|
|
247
|
+
const numericSince = Math.max(1, Math.min(Number(sinceMinutes || 1440), 60 * 24 * 30));
|
|
248
|
+
const numericMinTriggers = Math.max(1, Math.min(Number(minTriggers || 1), 20));
|
|
249
|
+
const numericMinEvents = Math.max(1, Math.min(Number(minEvents || 1), 20));
|
|
250
|
+
const watch = buildWatchReport(root, {
|
|
251
|
+
area,
|
|
252
|
+
top: numericTop,
|
|
253
|
+
sinceMinutes: numericSince,
|
|
254
|
+
pollOnce: true,
|
|
255
|
+
includePrivate: false,
|
|
256
|
+
});
|
|
257
|
+
const strongTriggers = watch.review_triggers.filter((trigger) => ['high', 'medium'].includes(trigger.priority));
|
|
258
|
+
const meaningfulEvents = watch.recent_events.filter((event) => MEANINGFUL_EVENT_TYPES.has(event.type));
|
|
259
|
+
const reasons = schedulerReasons({
|
|
260
|
+
watch,
|
|
261
|
+
strongTriggers,
|
|
262
|
+
meaningfulEvents,
|
|
263
|
+
minTriggers: numericMinTriggers,
|
|
264
|
+
minEvents: numericMinEvents,
|
|
265
|
+
});
|
|
266
|
+
const shouldRun = reasons.length > 0;
|
|
267
|
+
const review = shouldRun && includeReview
|
|
268
|
+
? buildReviewWorkerReport(root, { area, top: numericTop, sinceMinutes: numericSince })
|
|
269
|
+
: null;
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
command,
|
|
273
|
+
root,
|
|
274
|
+
area: area || null,
|
|
275
|
+
durable_write: false,
|
|
276
|
+
model_calls: false,
|
|
277
|
+
external_tool_calls: false,
|
|
278
|
+
daemon_started: false,
|
|
279
|
+
write_policy: 'read_only_scheduler_tick',
|
|
280
|
+
scheduler: {
|
|
281
|
+
mode: 'one_shot',
|
|
282
|
+
install_supported: false,
|
|
283
|
+
daemon_supported: false,
|
|
284
|
+
recommended_next_tick_minutes: shouldRun ? 60 : 240,
|
|
285
|
+
min_triggers: numericMinTriggers,
|
|
286
|
+
min_events: numericMinEvents,
|
|
287
|
+
},
|
|
288
|
+
review_fork: {
|
|
289
|
+
should_run: shouldRun,
|
|
290
|
+
reason: reasons.join('; ') || 'no high or medium review trigger reached the scheduler threshold',
|
|
291
|
+
trigger_count: strongTriggers.length,
|
|
292
|
+
meaningful_event_count: meaningfulEvents.length,
|
|
293
|
+
includes_review_report: Boolean(review),
|
|
294
|
+
},
|
|
295
|
+
watch_report: watch,
|
|
296
|
+
review_worker_report: review,
|
|
297
|
+
next_suggested_command: shouldRun
|
|
298
|
+
? 'neurain review <folder> --json'
|
|
299
|
+
: 'neurain scheduler tick <folder> --json',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function parseSchedulerTarget(args) {
|
|
304
|
+
const parts = args._ || [];
|
|
305
|
+
const first = parts[0] || 'tick';
|
|
306
|
+
if (first === 'install' || first === 'daemon' || first === 'follow') {
|
|
307
|
+
throw new Error('Public alpha scheduler does not install background jobs or start daemons.');
|
|
308
|
+
}
|
|
309
|
+
const mode = MODES.has(first) ? first : 'tick';
|
|
310
|
+
const rootArg = MODES.has(first) ? parts[1] : first;
|
|
311
|
+
return {
|
|
312
|
+
mode,
|
|
313
|
+
root: absPath(rootArg || args.root || process.cwd()),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function renderSchedulerEvalText(payload) {
|
|
318
|
+
return {
|
|
319
|
+
text: [
|
|
320
|
+
'# Neurain scheduler eval',
|
|
321
|
+
'',
|
|
322
|
+
`- OK: ${payload.ok ? 'yes' : 'no'}`,
|
|
323
|
+
`- Cases: ${payload.evaluated_cases}`,
|
|
324
|
+
`- Trigger precision: ${payload.trigger_precision}`,
|
|
325
|
+
`- Trigger recall: ${payload.trigger_recall}`,
|
|
326
|
+
`- False positive rate: ${payload.false_positive_rate}`,
|
|
327
|
+
`- No recursion rate: ${payload.no_recursion_rate}`,
|
|
328
|
+
`- Triggered no recursion rate: ${payload.triggered_no_recursion_rate}`,
|
|
329
|
+
`- Review report cases: ${payload.review_report_case_count}`,
|
|
330
|
+
`- Private boundary rate: ${payload.private_boundary_rate}`,
|
|
331
|
+
`- Private boundary cases: ${payload.private_boundary_case_count}`,
|
|
332
|
+
`- Target root untouched: ${payload.target_root_untouched ? 'yes' : 'no'}`,
|
|
333
|
+
`- Target snapshot complete: ${payload.gates.target_snapshot_complete ? 'yes' : 'no'}`,
|
|
334
|
+
`- Temp root cleanup verified: ${payload.temp_root_cleanup_verified ? 'yes' : 'no'}`,
|
|
335
|
+
].join('\n'),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function renderMonitorText(payload) {
|
|
340
|
+
const due = payload.ticks.filter((tick) => tick.review_fork.should_run).length;
|
|
341
|
+
return {
|
|
342
|
+
text: [
|
|
343
|
+
'# Neurain scheduler monitor',
|
|
344
|
+
'',
|
|
345
|
+
`- Root: ${payload.root}`,
|
|
346
|
+
`- Area: ${payload.area || 'all'}`,
|
|
347
|
+
'- Durable write: no',
|
|
348
|
+
'- Daemon started: no',
|
|
349
|
+
'- Model calls: no',
|
|
350
|
+
'- External tool calls: no',
|
|
351
|
+
`- Ticks completed: ${payload.monitor.completed_ticks}`,
|
|
352
|
+
`- Review due ticks: ${due}`,
|
|
353
|
+
].join('\n'),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function syntheticSchedulerCases(total) {
|
|
358
|
+
const count = Math.max(10, Math.min(Number(total || 100), 500));
|
|
359
|
+
const cases = [];
|
|
360
|
+
for (let index = 0; index < count; index += 1) {
|
|
361
|
+
const id = `scheduler-fixture-${String(index + 1).padStart(3, '0')}`;
|
|
362
|
+
const slot = index % 12;
|
|
363
|
+
if (slot === 0) {
|
|
364
|
+
cases.push(eventCase(id, true, 'correction', 'Corrected repeated background review routing mistake.'));
|
|
365
|
+
} else if (slot === 1) {
|
|
366
|
+
cases.push(eventCase(id, true, 'review', 'Reviewed private scheduler signal without exposing body.', {
|
|
367
|
+
source: '10_areas/_private/current/private-area-brief.md',
|
|
368
|
+
private_markers: ['private-area-brief.md'],
|
|
369
|
+
}));
|
|
370
|
+
} else if (slot === 2) {
|
|
371
|
+
cases.push(eventCase(id, true, 'rollback', 'Rollback completed after unsafe lesson promotion attempt.'));
|
|
372
|
+
} else if (slot === 3) {
|
|
373
|
+
cases.push(eventCase(id, true, 'test', 'npm test passed after scheduler evaluation implementation.'));
|
|
374
|
+
} else if (slot === 4) {
|
|
375
|
+
cases.push(fileCase(id, true, 'log.md', '- Lesson candidate should capture rollback proof workflow.\n'));
|
|
376
|
+
} else if (slot === 5) {
|
|
377
|
+
cases.push(eventCase(id, false, 'note', 'Ordinary meeting note about next design review.'));
|
|
378
|
+
} else if (slot === 6) {
|
|
379
|
+
cases.push(fileCase(id, false, 'wiki/status.md', '# Status\n\nRoutine project note without review trigger.\n'));
|
|
380
|
+
} else if (slot === 7) {
|
|
381
|
+
cases.push({ id, expected_should_run: false, setup: {} });
|
|
382
|
+
} else if (slot === 8) {
|
|
383
|
+
cases.push(eventCase(id, true, 'curator', 'Curator marked agent-created lesson stale for manual review.'));
|
|
384
|
+
} else if (slot === 9) {
|
|
385
|
+
cases.push(eventCase(id, true, 'lesson', 'Lesson candidate created from repeated verification failure.'));
|
|
386
|
+
} else if (slot === 10) {
|
|
387
|
+
cases.push(eventCase(id, false, 'test', 'Single weak test event below scheduler min-events threshold.', {
|
|
388
|
+
min_events: 2,
|
|
389
|
+
}));
|
|
390
|
+
} else {
|
|
391
|
+
cases.push(fileCase(id, false, 'notes.md', '- TODO: resolve repeated scheduler trigger uncertainty before release.\n'));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return cases;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function readSchedulerEvalCaseFile(root, caseFile) {
|
|
398
|
+
const rel = String(caseFile || '').replace(/\\/g, '/');
|
|
399
|
+
if (!rel || path.isAbsolute(rel)) throw new Error('Scheduler eval --case-file must be a relative JSON file inside the target root.');
|
|
400
|
+
const abs = safeResolve(root, rel);
|
|
401
|
+
const stat = fs.statSync(abs);
|
|
402
|
+
if (stat.size > MAX_CASE_FILE_BYTES) throw new Error('Scheduler eval case file is too large. Keep it at or below 1MB.');
|
|
403
|
+
const parsed = JSON.parse(readText(abs, ''));
|
|
404
|
+
const cases = Array.isArray(parsed) ? parsed : parsed.cases;
|
|
405
|
+
if (!Array.isArray(cases)) throw new Error('Scheduler eval case file must contain a cases array.');
|
|
406
|
+
if (cases.length > MAX_SCHEDULER_EVAL_CASES) throw new Error('Scheduler eval case file is too large. Keep it at or below 500 cases.');
|
|
407
|
+
return cases.map((item, index) => normalizeSchedulerCase(item, index));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function eventCase(id, expected, type, summary, extra = {}) {
|
|
411
|
+
return {
|
|
412
|
+
id,
|
|
413
|
+
expected_should_run: expected,
|
|
414
|
+
min_triggers: extra.min_triggers,
|
|
415
|
+
min_events: extra.min_events,
|
|
416
|
+
setup: {
|
|
417
|
+
events: [{ type, summary, source: extra.source || '', host: extra.host || 'cli' }],
|
|
418
|
+
private_markers: extra.private_markers || [],
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function fileCase(id, expected, rel, text) {
|
|
424
|
+
return {
|
|
425
|
+
id,
|
|
426
|
+
expected_should_run: expected,
|
|
427
|
+
setup: {
|
|
428
|
+
files: [{ path: rel, text }],
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function applySchedulerEvalCase(root, item) {
|
|
434
|
+
const setup = item.setup || {};
|
|
435
|
+
for (const file of setup.files || []) {
|
|
436
|
+
const rel = String(file.path || '').replace(/\\/g, '/');
|
|
437
|
+
const abs = safeResolve(root, rel);
|
|
438
|
+
ensureDir(path.dirname(abs));
|
|
439
|
+
fs.writeFileSync(abs, String(file.text || ''), 'utf8');
|
|
440
|
+
}
|
|
441
|
+
for (const event of setup.events || []) {
|
|
442
|
+
appendJournalEvent(root, {
|
|
443
|
+
type: event.type || 'note',
|
|
444
|
+
summary: event.summary || 'Scheduler eval event.',
|
|
445
|
+
source: event.source || '',
|
|
446
|
+
host: event.host || 'cli',
|
|
447
|
+
scope: event.scope || 'global',
|
|
448
|
+
confirm: '1건 저장 진행',
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (setup.tamper_journal) {
|
|
452
|
+
const journalAbs = safeResolve(root, '00_system/neurain/events.ndjson');
|
|
453
|
+
const lines = readText(journalAbs, '').trim().split(/\r?\n/).filter(Boolean);
|
|
454
|
+
if (lines.length) {
|
|
455
|
+
const event = JSON.parse(lines[0]);
|
|
456
|
+
event.summary = 'tampered scheduler eval summary';
|
|
457
|
+
fs.writeFileSync(journalAbs, `${JSON.stringify(event)}\n`, 'utf8');
|
|
458
|
+
} else {
|
|
459
|
+
ensureDir(path.dirname(journalAbs));
|
|
460
|
+
fs.writeFileSync(journalAbs, '{invalid json}\n', 'utf8');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function resultForSchedulerCase(item, tick) {
|
|
466
|
+
const predicted = Boolean(tick.review_fork?.should_run);
|
|
467
|
+
const expected = Boolean(item.expected_should_run);
|
|
468
|
+
const reviewReportEvaluated = Boolean(tick.review_worker_report);
|
|
469
|
+
const noRecursionPass = tick.review_worker_report
|
|
470
|
+
? tick.review_worker_report.recursion_guard?.starts_nested_review_worker === false
|
|
471
|
+
&& tick.review_worker_report.durable_write === false
|
|
472
|
+
&& tick.review_worker_report.model_calls === false
|
|
473
|
+
&& tick.review_worker_report.external_tool_calls === false
|
|
474
|
+
: true;
|
|
475
|
+
const markers = item.setup?.private_markers || [];
|
|
476
|
+
const text = JSON.stringify(tick);
|
|
477
|
+
const privateBoundaryPass = markers.every((marker) => !text.includes(marker));
|
|
478
|
+
const privateBoundaryEvaluated = markers.length > 0;
|
|
479
|
+
const failedReasons = [];
|
|
480
|
+
if (predicted !== expected) failedReasons.push('trigger_decision_mismatch');
|
|
481
|
+
if (!noRecursionPass) failedReasons.push('review_worker_recursion_or_write');
|
|
482
|
+
if (!privateBoundaryPass) failedReasons.push('private_marker_leaked');
|
|
483
|
+
if (tick.durable_write !== false) failedReasons.push('scheduler_durable_write');
|
|
484
|
+
if (tick.model_calls !== false) failedReasons.push('scheduler_model_call');
|
|
485
|
+
if (tick.external_tool_calls !== false) failedReasons.push('scheduler_external_tool_call');
|
|
486
|
+
if (tick.daemon_started !== false) failedReasons.push('scheduler_daemon_started');
|
|
487
|
+
return {
|
|
488
|
+
id: item.id,
|
|
489
|
+
expected_should_run: expected,
|
|
490
|
+
predicted_should_run: predicted,
|
|
491
|
+
no_recursion_pass: noRecursionPass,
|
|
492
|
+
review_report_evaluated: reviewReportEvaluated,
|
|
493
|
+
private_boundary_pass: privateBoundaryPass,
|
|
494
|
+
private_boundary_evaluated: privateBoundaryEvaluated,
|
|
495
|
+
ok: failedReasons.length === 0,
|
|
496
|
+
failed_reasons: failedReasons,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function schedulerEvalStats(results) {
|
|
501
|
+
let tp = 0;
|
|
502
|
+
let fp = 0;
|
|
503
|
+
let tn = 0;
|
|
504
|
+
let fn = 0;
|
|
505
|
+
for (const result of results) {
|
|
506
|
+
if (result.expected_should_run && result.predicted_should_run) tp += 1;
|
|
507
|
+
else if (!result.expected_should_run && result.predicted_should_run) fp += 1;
|
|
508
|
+
else if (!result.expected_should_run && !result.predicted_should_run) tn += 1;
|
|
509
|
+
else fn += 1;
|
|
510
|
+
}
|
|
511
|
+
const reviewCases = results.filter((result) => result.review_report_evaluated);
|
|
512
|
+
const privateCases = results.filter((result) => result.private_boundary_evaluated);
|
|
513
|
+
return {
|
|
514
|
+
true_positive_count: tp,
|
|
515
|
+
false_positive_count: fp,
|
|
516
|
+
true_negative_count: tn,
|
|
517
|
+
false_negative_count: fn,
|
|
518
|
+
trigger_precision: roundRate(tp + fp === 0 ? 1 : tp / (tp + fp)),
|
|
519
|
+
trigger_recall: roundRate(tp + fn === 0 ? 1 : tp / (tp + fn)),
|
|
520
|
+
false_positive_rate: roundRate(fp + tn === 0 ? 0 : fp / (fp + tn)),
|
|
521
|
+
false_negative_rate: roundRate(fn + tp === 0 ? 0 : fn / (fn + tp)),
|
|
522
|
+
no_recursion_rate: roundRate(results.filter((result) => result.no_recursion_pass).length / Math.max(1, results.length)),
|
|
523
|
+
review_report_case_count: reviewCases.length,
|
|
524
|
+
triggered_no_recursion_rate: roundRate(reviewCases.filter((result) => result.no_recursion_pass).length / Math.max(1, reviewCases.length)),
|
|
525
|
+
private_boundary_case_count: privateCases.length,
|
|
526
|
+
private_boundary_rate: roundRate(privateCases.filter((result) => result.private_boundary_pass).length / Math.max(1, privateCases.length)),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function targetSnapshot(root) {
|
|
531
|
+
const files = listSnapshotFiles(root);
|
|
532
|
+
const payload = {
|
|
533
|
+
files: Object.fromEntries(files.map((item) => [item.rel, item.fingerprint])),
|
|
534
|
+
};
|
|
535
|
+
return {
|
|
536
|
+
hash: sha256(JSON.stringify(payload)),
|
|
537
|
+
payload,
|
|
538
|
+
file_count: files.length,
|
|
539
|
+
limit_reached: files.limit_reached === true,
|
|
540
|
+
unreadable_count: files.unreadable_count || 0,
|
|
541
|
+
large_file_count: files.large_file_count || 0,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function listSnapshotFiles(root) {
|
|
546
|
+
const out = [];
|
|
547
|
+
let limitReached = false;
|
|
548
|
+
let unreadableCount = 0;
|
|
549
|
+
let largeFileCount = 0;
|
|
550
|
+
function rec(current) {
|
|
551
|
+
if (out.length >= TARGET_SNAPSHOT_MAX_FILES) {
|
|
552
|
+
limitReached = true;
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
let entries = [];
|
|
556
|
+
try {
|
|
557
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
558
|
+
} catch {
|
|
559
|
+
unreadableCount += 1;
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
for (const entry of entries) {
|
|
563
|
+
if (out.length >= TARGET_SNAPSHOT_MAX_FILES) {
|
|
564
|
+
limitReached = true;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (SNAPSHOT_EXCLUDED_NAMES.has(entry.name)) continue;
|
|
568
|
+
const abs = path.join(current, entry.name);
|
|
569
|
+
const rel = relPath(root, abs);
|
|
570
|
+
if (generatedPath(rel) || entry.isSymbolicLink()) continue;
|
|
571
|
+
if (entry.isDirectory()) {
|
|
572
|
+
rec(abs);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (!entry.isFile()) continue;
|
|
576
|
+
let stat;
|
|
577
|
+
try {
|
|
578
|
+
stat = fs.statSync(abs);
|
|
579
|
+
} catch {
|
|
580
|
+
unreadableCount += 1;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
const metadata = {
|
|
584
|
+
size: stat.size,
|
|
585
|
+
mode: stat.mode,
|
|
586
|
+
mtime_ms: Math.round(stat.mtimeMs),
|
|
587
|
+
};
|
|
588
|
+
if (stat.size > TARGET_SNAPSHOT_LARGE_FILE_BYTES) {
|
|
589
|
+
largeFileCount += 1;
|
|
590
|
+
out.push({ rel, fingerprint: { ...metadata, content_hash: null, large_file: true } });
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
out.push({ rel, fingerprint: { ...metadata, content_hash: sha256(fs.readFileSync(abs)) } });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
rec(root);
|
|
597
|
+
out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
598
|
+
out.limit_reached = limitReached;
|
|
599
|
+
out.unreadable_count = unreadableCount;
|
|
600
|
+
out.large_file_count = largeFileCount;
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function normalizeSchedulerCase(item, index) {
|
|
605
|
+
if (!item || typeof item !== 'object') throw new Error(`Scheduler eval case ${index + 1} must be an object.`);
|
|
606
|
+
const setup = item.setup && typeof item.setup === 'object' ? item.setup : {};
|
|
607
|
+
const files = arrayOrEmpty(setup.files, `case ${index + 1} setup.files`);
|
|
608
|
+
const events = arrayOrEmpty(setup.events, `case ${index + 1} setup.events`);
|
|
609
|
+
const privateMarkers = arrayOrEmpty(setup.private_markers, `case ${index + 1} setup.private_markers`);
|
|
610
|
+
if (files.length > MAX_CASE_FILES) throw new Error(`Scheduler eval case ${index + 1} has too many files. Keep it at or below ${MAX_CASE_FILES}.`);
|
|
611
|
+
if (events.length > MAX_CASE_EVENTS) throw new Error(`Scheduler eval case ${index + 1} has too many events. Keep it at or below ${MAX_CASE_EVENTS}.`);
|
|
612
|
+
if (privateMarkers.length > MAX_PRIVATE_MARKERS) throw new Error(`Scheduler eval case ${index + 1} has too many private markers. Keep it at or below ${MAX_PRIVATE_MARKERS}.`);
|
|
613
|
+
let totalTextBytes = 0;
|
|
614
|
+
const normalizedFiles = files.map((file, fileIndex) => {
|
|
615
|
+
if (!file || typeof file !== 'object') throw new Error(`Scheduler eval case ${index + 1} file ${fileIndex + 1} must be an object.`);
|
|
616
|
+
const rel = String(file.path || '').replace(/\\/g, '/');
|
|
617
|
+
if (!rel || path.isAbsolute(rel)) throw new Error(`Scheduler eval case ${index + 1} file path must be relative.`);
|
|
618
|
+
const text = boundedCaseText(file.text || '', `case ${index + 1} file ${fileIndex + 1}`);
|
|
619
|
+
totalTextBytes += Buffer.byteLength(text, 'utf8');
|
|
620
|
+
return { path: rel, text };
|
|
621
|
+
});
|
|
622
|
+
const normalizedEvents = events.map((event, eventIndex) => {
|
|
623
|
+
if (!event || typeof event !== 'object') throw new Error(`Scheduler eval case ${index + 1} event ${eventIndex + 1} must be an object.`);
|
|
624
|
+
const summary = boundedCaseText(event.summary || 'Scheduler eval event.', `case ${index + 1} event ${eventIndex + 1} summary`);
|
|
625
|
+
const source = boundedCaseText(event.source || '', `case ${index + 1} event ${eventIndex + 1} source`, 1000);
|
|
626
|
+
totalTextBytes += Buffer.byteLength(summary, 'utf8') + Buffer.byteLength(source, 'utf8');
|
|
627
|
+
return {
|
|
628
|
+
type: boundedCaseText(event.type || 'note', `case ${index + 1} event ${eventIndex + 1} type`, 200),
|
|
629
|
+
summary,
|
|
630
|
+
source,
|
|
631
|
+
host: boundedCaseText(event.host || 'cli', `case ${index + 1} event ${eventIndex + 1} host`, 200),
|
|
632
|
+
scope: boundedCaseText(event.scope || 'global', `case ${index + 1} event ${eventIndex + 1} scope`, 200),
|
|
633
|
+
};
|
|
634
|
+
});
|
|
635
|
+
const normalizedMarkers = privateMarkers.map((marker, markerIndex) => {
|
|
636
|
+
const value = boundedCaseText(marker || '', `case ${index + 1} private marker ${markerIndex + 1}`, 1000);
|
|
637
|
+
totalTextBytes += Buffer.byteLength(value, 'utf8');
|
|
638
|
+
return value;
|
|
639
|
+
}).filter(Boolean);
|
|
640
|
+
if (totalTextBytes > MAX_CASE_TOTAL_TEXT_BYTES) throw new Error(`Scheduler eval case ${index + 1} text payload is too large. Keep it at or below ${MAX_CASE_TOTAL_TEXT_BYTES} bytes.`);
|
|
641
|
+
return {
|
|
642
|
+
id: boundedCaseText(item.id || `case-${index + 1}`, `case ${index + 1} id`, 200),
|
|
643
|
+
expected_should_run: Boolean(item.expected_should_run),
|
|
644
|
+
min_triggers: item.min_triggers,
|
|
645
|
+
min_events: item.min_events,
|
|
646
|
+
setup: {
|
|
647
|
+
files: normalizedFiles,
|
|
648
|
+
events: normalizedEvents,
|
|
649
|
+
private_markers: normalizedMarkers,
|
|
650
|
+
tamper_journal: Boolean(setup.tamper_journal),
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function arrayOrEmpty(value, label) {
|
|
656
|
+
if (value === undefined || value === null) return [];
|
|
657
|
+
if (!Array.isArray(value)) throw new Error(`Scheduler eval ${label} must be an array.`);
|
|
658
|
+
return value;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function boundedCaseText(value, label, limit = MAX_CASE_TEXT_BYTES) {
|
|
662
|
+
const text = String(value || '');
|
|
663
|
+
if (Buffer.byteLength(text, 'utf8') > limit) throw new Error(`Scheduler eval ${label} is too large. Keep it at or below ${limit} bytes.`);
|
|
664
|
+
return text;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function roundRate(value) {
|
|
668
|
+
return Math.round(Number(value || 0) * 1000) / 1000;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function schedulerReasons({ watch, strongTriggers, meaningfulEvents, minTriggers, minEvents }) {
|
|
672
|
+
const reasons = [];
|
|
673
|
+
if (!watch.journal_integrity.ok) reasons.push('journal integrity requires review before trusting automation');
|
|
674
|
+
const high = strongTriggers.filter((trigger) => trigger.priority === 'high');
|
|
675
|
+
if (high.length) reasons.push(`${high.length} high-priority trigger(s) found`);
|
|
676
|
+
if (strongTriggers.length >= minTriggers) reasons.push(`${strongTriggers.length} high/medium trigger(s) reached threshold ${minTriggers}`);
|
|
677
|
+
if (meaningfulEvents.length >= minEvents) reasons.push(`${meaningfulEvents.length} meaningful event(s) reached threshold ${minEvents}`);
|
|
678
|
+
if (watch.lesson_candidate_count > 0) reasons.push(`${watch.lesson_candidate_count} lesson candidate(s) available`);
|
|
679
|
+
return [...new Set(reasons)];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function sleep(ms) {
|
|
683
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function numberOrDefault(value, fallback) {
|
|
687
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
688
|
+
const parsed = Number(value);
|
|
689
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function positiveIntegerOption(value, fallback, label) {
|
|
693
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
694
|
+
const parsed = Number(value);
|
|
695
|
+
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`--${label} must be a positive integer.`);
|
|
696
|
+
return parsed;
|
|
697
|
+
}
|