@codedology/code-forge 0.7.7 → 0.9.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 +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge status [--run <id>] [--json]` (B64a, issue #13 item 6): a short status of a run, so
|
|
3
|
+
* the orchestrator checks progress with one command instead of reading logs or polling
|
|
4
|
+
* transcripts.
|
|
5
|
+
*
|
|
6
|
+
* Read-only: it reads the run record (`~/.code-forge/runs/<id>.json`, as `run status` does — no
|
|
7
|
+
* lock), the project's ledger rows and the workspace config (for `budget.usd`), and writes
|
|
8
|
+
* nothing — no ledger row, no file, no lock. The data comes from `briefStatus`
|
|
9
|
+
* (`src/state/brief-status.mjs`); this file finds the runs, counts open findings and renders.
|
|
10
|
+
*
|
|
11
|
+
* With `--run`, that run (ended or not). Without it, every run that is not ended whose workspace
|
|
12
|
+
* is the current repository (`projectRootFor(cwd)`, compared on realpaths), newest first (by
|
|
13
|
+
* `started_at`, then run id); with none, `no active run` (exit 0; `{"runs":[]}` with `--json`).
|
|
14
|
+
* An unknown `--run` exits 1; a usage error (unknown flag, a positional) exits 2.
|
|
15
|
+
*
|
|
16
|
+
* Text: at most {@link STATUS_MAX_LINES} lines per run, runs separated by one blank line:
|
|
17
|
+
* run <id> · <status> · <plan|brief> · spent $<x>[ of $<budget>]
|
|
18
|
+
* <id> <state> <level> · <model or -> · gate <pass|fail|none>[ (<gate>)] · findings <n>
|
|
19
|
+
* … +<k> more blocks (only when the open blocks do not fit)
|
|
20
|
+
* closed <a> · stopped <b> (only when any exist; printed last, but block lines are
|
|
21
|
+
* dropped first to keep it inside the cap)
|
|
22
|
+
* The gate field is `gate pass`, `gate fail (<gate>)`, `gate fail` (no gate named) or `gate none`.
|
|
23
|
+
* `<x>` is `run status`'s spent_usd with 2 decimals, followed by `+?` when some session has no
|
|
24
|
+
* known price (`run status`'s unknown_usd_sessions > 0); the whole amount is `$?` when the ledger
|
|
25
|
+
* cannot be read. A run record with no status shows `-`.
|
|
26
|
+
*
|
|
27
|
+
* `--json`: exactly one line, `{"runs":[{run_id, status, mode, spent_usd, unknown_usd_sessions,
|
|
28
|
+
* budget_usd, blocks, more, closed, stopped}]}` — every block that is neither closed nor stopped
|
|
29
|
+
* (no cap), `more` always 0, `spent_usd` and `unknown_usd_sessions` null when the ledger cannot be
|
|
30
|
+
* read, `status` null when the record has none.
|
|
31
|
+
*
|
|
32
|
+
* The run lock (`<runs>/<id>.lock`, taken by the worker and the block verbs) is never taken nor
|
|
33
|
+
* waited on: a held lock does not delay `status`.
|
|
34
|
+
*
|
|
35
|
+
* Findings: the number of distinct (file, finding) pairs the block gate would refuse today
|
|
36
|
+
* (`checkBlockReviews`, `src/review/gate-check.mjs` — the same rules `block close` applies: the
|
|
37
|
+
* latest review of each file left it open and no valid waiver, ruling or later approval cleared
|
|
38
|
+
* it). The files are the ones the block's review rows name (`reviewedFiles`, the selection
|
|
39
|
+
* `block close` uses for reviewed files), each at the content hash of its LAST review row (only
|
|
40
|
+
* review rows — the events `reviewedFiles` counts — supply a hash; other rows never do). A file
|
|
41
|
+
* whose review rows carry no hash is checked at the empty hash: the gate counts a refusal row
|
|
42
|
+
* with no `content_hash` for every hash of its file (fail closed), so its open findings count.
|
|
43
|
+
* The gate's other refusals (unreviewed, unsigned, proof, rule breaks) are not findings and are
|
|
44
|
+
* not counted. No transcript, diff or review text is ever read or printed.
|
|
45
|
+
*
|
|
46
|
+
* The run key is only read (`loadKey`: a stat and a read, never a create or a write); with no
|
|
47
|
+
* key, a zero key stands in, so no signed waiver or approval verifies (fail closed).
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { readdir, realpath } from 'node:fs/promises';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
import { loadProjectConfig, projectRootFor } from '../config/load.mjs';
|
|
53
|
+
import { budgetUsdOf } from '../ledger/spend.mjs';
|
|
54
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
55
|
+
import { findingsKey, openFindingsOf } from '../review/open-findings.mjs';
|
|
56
|
+
import { briefStatus } from '../state/brief-status.mjs';
|
|
57
|
+
import { parseFlags } from '../state/cli-args.mjs';
|
|
58
|
+
import { runsDir, StateError } from '../state/paths.mjs';
|
|
59
|
+
import { readRun } from '../state/run.mjs';
|
|
60
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
61
|
+
|
|
62
|
+
const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
|
|
63
|
+
|
|
64
|
+
/** The hard cap on text lines per run. */
|
|
65
|
+
const STATUS_MAX_LINES = 10;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {import('../state/brief-status.mjs').RunStatus} RunStatus
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/** @param {string} p @returns {Promise<string>} */
|
|
72
|
+
async function realOr(p) {
|
|
73
|
+
try {
|
|
74
|
+
return await realpath(p);
|
|
75
|
+
} catch {
|
|
76
|
+
return path.resolve(p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The status data of one run.
|
|
82
|
+
* @param {Record<string, any>} record
|
|
83
|
+
* @returns {Promise<RunStatus>}
|
|
84
|
+
*/
|
|
85
|
+
async function statusOf(record) {
|
|
86
|
+
/** @type {Array<Record<string, any>> | null} null: the ledger cannot be read */
|
|
87
|
+
let rows = null;
|
|
88
|
+
try {
|
|
89
|
+
rows = await readAllRows(record.project);
|
|
90
|
+
} catch {
|
|
91
|
+
rows = null;
|
|
92
|
+
}
|
|
93
|
+
let budget = null;
|
|
94
|
+
try {
|
|
95
|
+
const loaded = await loadProjectConfig(record.workspace);
|
|
96
|
+
budget = loaded.ok ? budgetUsdOf(loaded.config) : null;
|
|
97
|
+
} catch {
|
|
98
|
+
budget = null;
|
|
99
|
+
}
|
|
100
|
+
const key = await findingsKey(record);
|
|
101
|
+
const runId = String(record.run_id);
|
|
102
|
+
const all = rows ?? [];
|
|
103
|
+
/** @type {Map<string, Array<Record<string, any>>>} the run's rows, grouped by block once */
|
|
104
|
+
const byBlock = new Map();
|
|
105
|
+
for (const r of all) {
|
|
106
|
+
if (r?.run !== runId || typeof r.block !== 'string') continue;
|
|
107
|
+
const list = byBlock.get(r.block);
|
|
108
|
+
if (list) list.push(r);
|
|
109
|
+
else byBlock.set(r.block, [r]);
|
|
110
|
+
}
|
|
111
|
+
return briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every run that is not ended whose workspace is `root`, newest first.
|
|
116
|
+
* @param {string} root
|
|
117
|
+
* @returns {Promise<Array<Record<string, any>>>}
|
|
118
|
+
*/
|
|
119
|
+
async function activeRunsFor(root) {
|
|
120
|
+
const want = await realOr(root);
|
|
121
|
+
let names;
|
|
122
|
+
try {
|
|
123
|
+
names = await readdir(runsDir());
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const records = [];
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
if (!name.endsWith('.json')) continue;
|
|
130
|
+
let record;
|
|
131
|
+
try {
|
|
132
|
+
record = await readRun(name.slice(0, -'.json'.length));
|
|
133
|
+
} catch {
|
|
134
|
+
continue; // not a run record (a bad id, unparsable): not a run to show
|
|
135
|
+
}
|
|
136
|
+
if (!record || typeof record !== 'object' || record.status === 'ended' || typeof record.workspace !== 'string') continue;
|
|
137
|
+
if ((await realOr(record.workspace)) !== want) continue;
|
|
138
|
+
records.push(record);
|
|
139
|
+
}
|
|
140
|
+
const at = (/** @type {Record<string, any>} */ r) => (typeof r.started_at === 'string' ? r.started_at : '');
|
|
141
|
+
return records.sort((a, b) => at(b).localeCompare(at(a)) || String(b.run_id).localeCompare(String(a.run_id)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {number} n @returns {string} */
|
|
145
|
+
const usd = (n) => `$${n.toFixed(2)}`;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The text lines of one run, at most {@link STATUS_MAX_LINES}.
|
|
149
|
+
* @param {RunStatus} s @returns {string[]}
|
|
150
|
+
*/
|
|
151
|
+
export function runLines(s) {
|
|
152
|
+
const spent = s.spent_usd === null ? '$?' : `${usd(s.spent_usd)}${(s.unknown_usd_sessions ?? 0) > 0 ? '+?' : ''}`;
|
|
153
|
+
const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
|
|
154
|
+
const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
|
|
155
|
+
const room = STATUS_MAX_LINES - 1 - (tally === null ? 0 : 1);
|
|
156
|
+
const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
|
|
157
|
+
for (const b of shown) {
|
|
158
|
+
lines.push(`${b.id} ${b.state} ${b.level ?? '-'} · ${b.model ?? '-'} · gate ${b.gate.result}${b.gate.name ? ` (${b.gate.name})` : ''} · findings ${b.findings}`);
|
|
159
|
+
}
|
|
160
|
+
if (shown.length < s.blocks.length) lines.push(`… +${s.blocks.length - shown.length} more blocks`);
|
|
161
|
+
if (tally !== null) lines.push(tally);
|
|
162
|
+
return lines;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string[]} args
|
|
167
|
+
* @returns {Promise<number>}
|
|
168
|
+
*/
|
|
169
|
+
export default async function status(args) {
|
|
170
|
+
const out = (/** @type {string} */ s) => writeSafe(process.stdout, s);
|
|
171
|
+
const err = (/** @type {string} */ s) => writeSafe(process.stderr, s);
|
|
172
|
+
let flags;
|
|
173
|
+
try {
|
|
174
|
+
const parsed = parseFlags(args, { values: ['run'], booleans: ['json'] });
|
|
175
|
+
if (parsed.positionals.length > 0) throw new StateError('usage', `unexpected argument ${JSON.stringify(parsed.positionals[0])}`);
|
|
176
|
+
flags = parsed.flags;
|
|
177
|
+
} catch (thrown) {
|
|
178
|
+
err(`status: ${thrown?.message ?? String(thrown)}\n${USAGE}`);
|
|
179
|
+
return 2;
|
|
180
|
+
}
|
|
181
|
+
const json = flags.json === true;
|
|
182
|
+
try {
|
|
183
|
+
/** @type {Array<Record<string, any>>} */
|
|
184
|
+
let records;
|
|
185
|
+
if (typeof flags.run === 'string') {
|
|
186
|
+
const id = flags.run;
|
|
187
|
+
const record = await readRun(id).catch((thrown) => (thrown?.code === 'no-run' ? null : Promise.reject(thrown)));
|
|
188
|
+
if (!record || typeof record !== 'object') {
|
|
189
|
+
err(`status: no run record for ${id}\n`); // the wording every verb uses for an unknown run
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
records = [record];
|
|
193
|
+
} else {
|
|
194
|
+
records = await activeRunsFor(projectRootFor(process.cwd()));
|
|
195
|
+
}
|
|
196
|
+
const shown = [];
|
|
197
|
+
for (const record of records) shown.push(await statusOf(record));
|
|
198
|
+
if (json) {
|
|
199
|
+
out(`${JSON.stringify({ runs: shown })}\n`); // briefStatus builds exactly the clause-3 run object
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if (shown.length === 0) {
|
|
203
|
+
out('no active run\n');
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
out(`${shown.map((s) => runLines(s).join('\n')).join('\n\n')}\n`);
|
|
207
|
+
return 0;
|
|
208
|
+
} catch (thrown) {
|
|
209
|
+
err(`status: ${thrown?.message ?? String(thrown)}\n`);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/config/validate.mjs
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
63
63
|
import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
|
|
64
64
|
import { redact } from '../util/redact.mjs';
|
|
65
|
+
import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
65
66
|
import { checkEffort, displayEffort } from '../engines/efforts.mjs';
|
|
66
67
|
import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
|
|
67
68
|
import { isKnownId, resolveLevel } from './known-ids.mjs';
|
|
@@ -458,19 +459,52 @@ function isArgvArray(value) {
|
|
|
458
459
|
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string') && value[0].length > 0;
|
|
459
460
|
}
|
|
460
461
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
462
|
+
/** B60: the keys a gate given as an object may have. */
|
|
463
|
+
const GATE_OBJECT_KEYS = new Set(['argv', 'env']);
|
|
464
|
+
/** B60: an environment variable name. */
|
|
465
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Rule 9's messages for one gate: an argv array, `{argv, env}` (B60), or null. A message names a
|
|
469
|
+
* bad key or env variable NAME, never an env value.
|
|
470
|
+
* @param {string} key @param {unknown} value @returns {string[]}
|
|
471
|
+
*/
|
|
472
|
+
function gateMessages(key, value) {
|
|
473
|
+
if (value === undefined || value === null) return [];
|
|
474
|
+
// B60: `{test_files}` may expand to nothing, so it cannot be the command — and as the only item a
|
|
475
|
+
// full-suite run would be an empty argv
|
|
476
|
+
/** @param {string[]} argv @returns {string[]} */
|
|
477
|
+
const tokenMessages = (argv) =>
|
|
478
|
+
argv.length === 1 && argv[0] === TEST_FILES_TOKEN
|
|
479
|
+
? [`gates.${key}: '${TEST_FILES_TOKEN}' cannot be the only item (the full suite would run an empty command)`]
|
|
480
|
+
: argv[0] === TEST_FILES_TOKEN
|
|
481
|
+
? [`gates.${key}: '${TEST_FILES_TOKEN}' cannot be the command (argv[0])`]
|
|
482
|
+
: [];
|
|
483
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
484
|
+
if (!isArgvArray(value)) return [`gates.${key} must be a non-empty argv array of strings, an {argv, env} object, or null`];
|
|
485
|
+
return tokenMessages(/** @type {string[]} */ (value));
|
|
486
|
+
}
|
|
487
|
+
const gate = /** @type {Record<string, unknown>} */ (value);
|
|
488
|
+
const messages = Object.keys(gate)
|
|
489
|
+
.filter((k) => !GATE_OBJECT_KEYS.has(k))
|
|
490
|
+
.map((k) => `gates.${key}: unknown key ${JSON.stringify(k)} (an object gate has only argv and env)`);
|
|
491
|
+
if (!isArgvArray(gate.argv)) messages.push(`gates.${key}.argv must be a non-empty argv array of strings`);
|
|
492
|
+
else messages.push(...tokenMessages(/** @type {string[]} */ (gate.argv)));
|
|
493
|
+
if (gate.env !== undefined) {
|
|
494
|
+
if (gate.env === null || typeof gate.env !== 'object' || Array.isArray(gate.env)) {
|
|
495
|
+
messages.push(`gates.${key}.env must be a map of variable names to string values`);
|
|
496
|
+
} else {
|
|
497
|
+
for (const [name, envValue] of Object.entries(gate.env)) {
|
|
498
|
+
if (!ENV_NAME.test(name)) messages.push(`gates.${key}.env: ${JSON.stringify(name)} is not a variable name (letters, digits and _, not starting with a digit)`);
|
|
499
|
+
else if (typeof envValue !== 'string') messages.push(`gates.${key}.env.${name} must be a string`);
|
|
500
|
+
}
|
|
471
501
|
}
|
|
472
502
|
}
|
|
473
|
-
return
|
|
503
|
+
return messages;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function checkGatesNotArgvArray(cfg) {
|
|
507
|
+
return GATE_KEYS.flatMap((key) => gateMessages(key, cfg?.gates?.[key]).map((message) => ({ rule: 'gates-not-argv-array', severity: 'error', message })));
|
|
474
508
|
}
|
|
475
509
|
|
|
476
510
|
// ── 10. secret-looking-value (ERROR) ────────────────────────────────────────
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coder context pack (block B63a; issue #13 item 5, issue #10 item 2).
|
|
3
|
+
*
|
|
4
|
+
* The top-level `context` key of `.code-forge.yml` names what every coder task should carry:
|
|
5
|
+
*
|
|
6
|
+
* context:
|
|
7
|
+
* rules: [CLAUDE.md, docs/conventions.md] repository-relative files, in order
|
|
8
|
+
* lessons: docs/lessons.md one repository-relative file
|
|
9
|
+
* commands: {build: [...], test: [...], lint: [...], format: [...]} argv arrays, each optional
|
|
10
|
+
* max_lines: 300 the pack's line cap (integer >= 20)
|
|
11
|
+
* require_coder_tests: false B63b: close needs the coder's `TESTS … exit=0` line
|
|
12
|
+
*
|
|
13
|
+
* `assemblePack` turns it into ONE pack for a block, in this order: the header (run, block, level,
|
|
14
|
+
* owned files, test files when the block has them, worktree path and branch when it has one), the
|
|
15
|
+
* commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), each
|
|
16
|
+
* rules file, then the lessons file — every file section headed by its source path (an empty
|
|
17
|
+
* file: the heading alone). Over `max_lines` the lessons are cut first, then the
|
|
18
|
+
* rules from the last file backwards, each cut section ending in `[cut: N lines dropped]`; the
|
|
19
|
+
* header and the commands are never cut.
|
|
20
|
+
*
|
|
21
|
+
* `reviewDigest` is the ONE builder of the review packets' rules digest (`## project rules`, ≤ 60
|
|
22
|
+
* lines, `packet.mjs`): the rules files' text, then a `### lessons` subheading and the lessons.
|
|
23
|
+
* With no `context` key it is '' — the packet is then byte-identical to before (`(none)`). A file
|
|
24
|
+
* it cannot read throws: the worker answers that ticket `review.unavailable`, `context-unreadable`.
|
|
25
|
+
*
|
|
26
|
+
* Files are always read from the MAIN tree's root (`repoRoot`), never from a block worktree a coder
|
|
27
|
+
* can write to. A path that is absolute, has a `..` segment, or leads out of the repository through
|
|
28
|
+
* a symlink (a broken one too: its target is checked) is refused, and so is a missing file
|
|
29
|
+
* (`ContextError`). A control character (a newline included) in a command token, a rules or
|
|
30
|
+
* lessons path, or a header value (owned or test file, worktree path or branch) is refused, so
|
|
31
|
+
* nothing can forge a line of the pack. Command lines go through `redact`, as the rules and
|
|
32
|
+
* lessons do. The `context` key is not
|
|
33
|
+
* authority: it is not fixed for the run, and `run reload` takes a change to it — except B63b's
|
|
34
|
+
* `context.require_coder_tests`, which tightens `block close`: it is read from the run-start
|
|
35
|
+
* snapshot only (`requireCoderTests`, `src/state/config-snapshot.mjs`) and `run reload` refuses a
|
|
36
|
+
* change to it. It plays no part in the pack.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { lstatSync, readFileSync, readlinkSync, realpathSync } from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import { estimateTokens } from '../review/packet.mjs';
|
|
42
|
+
import { splitLines } from '../review/context.mjs';
|
|
43
|
+
import { redact } from '../util/redact.mjs';
|
|
44
|
+
|
|
45
|
+
/** The pack's line cap when `context.max_lines` is not set. */
|
|
46
|
+
export const DEFAULT_MAX_LINES = 300;
|
|
47
|
+
|
|
48
|
+
/** The smallest `context.max_lines` accepted. */
|
|
49
|
+
export const MIN_MAX_LINES = 20;
|
|
50
|
+
|
|
51
|
+
/** The `context.commands` keys, in the order the pack lists them. */
|
|
52
|
+
export const COMMAND_KEYS = Object.freeze(['build', 'test', 'lint', 'format']);
|
|
53
|
+
|
|
54
|
+
/** The subheading the lessons get in the review digest. */
|
|
55
|
+
export const DIGEST_LESSONS_HEADING = '### lessons';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A refusal with a stable `code`. Thrown here: `bad-context` (a bad `context` value),
|
|
59
|
+
* `control-character` (in a path or a header value), `outside`, `missing` (ENOENT), `unreadable`
|
|
60
|
+
* (any other lstat or read error, with its code), `not-file`. Thrown by `src/cli/context.mjs`:
|
|
61
|
+
* `no-block`, `bad-config`, `no-context`, `no-run-root`, `write-failed`.
|
|
62
|
+
*/
|
|
63
|
+
export class ContextError extends Error {
|
|
64
|
+
/** @param {string} code @param {string} message */
|
|
65
|
+
constructor(code, message) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = 'ContextError';
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** C0 and C1 control characters and DEL: a newline among them. */
|
|
73
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
74
|
+
|
|
75
|
+
/** @param {unknown} v @returns {v is Record<string, unknown>} */
|
|
76
|
+
const isMapping = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {object} ContextSettings
|
|
80
|
+
* @property {string[]} rules
|
|
81
|
+
* @property {string | null} lessons
|
|
82
|
+
* @property {Record<string, string[]>} commands - only the keys that are set.
|
|
83
|
+
* @property {number} maxLines
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The `context` key of a config, checked; null when the key is absent.
|
|
88
|
+
* @param {Record<string, any> | null | undefined} cfg
|
|
89
|
+
* @returns {ContextSettings | null}
|
|
90
|
+
* @throws {ContextError} `bad-context`, naming the key.
|
|
91
|
+
*/
|
|
92
|
+
export function contextSettings(cfg) {
|
|
93
|
+
const raw = cfg?.context;
|
|
94
|
+
if (raw === undefined || raw === null) return null;
|
|
95
|
+
if (!isMapping(raw)) throw new ContextError('bad-context', 'context must be a mapping');
|
|
96
|
+
const rules = raw.rules ?? [];
|
|
97
|
+
if (!Array.isArray(rules) || !rules.every((r) => typeof r === 'string' && r.length > 0)) throw new ContextError('bad-context', 'context.rules must be a list of file paths');
|
|
98
|
+
// `CLAUDE.md` and `./CLAUDE.md` are the same file
|
|
99
|
+
if (new Set(rules.map((r) => path.posix.normalize(r))).size !== rules.length) throw new ContextError('bad-context', 'context.rules names a file twice');
|
|
100
|
+
const lessons = raw.lessons ?? null;
|
|
101
|
+
if (lessons !== null && (typeof lessons !== 'string' || lessons.length === 0)) throw new ContextError('bad-context', 'context.lessons must be one file path');
|
|
102
|
+
const commandsRaw = raw.commands ?? {};
|
|
103
|
+
if (!isMapping(commandsRaw)) throw new ContextError('bad-context', 'context.commands must be a mapping');
|
|
104
|
+
/** @type {Record<string, string[]>} */
|
|
105
|
+
const commands = {};
|
|
106
|
+
for (const key of Object.keys(commandsRaw)) {
|
|
107
|
+
if (!COMMAND_KEYS.includes(key)) throw new ContextError('bad-context', `context.commands.${key} is not one of ${COMMAND_KEYS.join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
for (const key of COMMAND_KEYS) {
|
|
110
|
+
const argv = commandsRaw[key];
|
|
111
|
+
if (argv === undefined || argv === null) continue;
|
|
112
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) throw new ContextError('bad-context', `context.commands.${key} must be an argv list of non-empty strings`);
|
|
113
|
+
if (argv.some((a) => CONTROL.test(a))) throw new ContextError('bad-context', `context.commands.${key} contains a control character`);
|
|
114
|
+
commands[key] = [...argv];
|
|
115
|
+
}
|
|
116
|
+
// B63b: authority, read by `block close` from the run-start snapshot only; checked here so a
|
|
117
|
+
// pack is never built from a config that carries a malformed value
|
|
118
|
+
const requireTests = raw.require_coder_tests ?? false;
|
|
119
|
+
if (typeof requireTests !== 'boolean') throw new ContextError('bad-context', 'context.require_coder_tests must be true or false');
|
|
120
|
+
const maxLines = raw.max_lines ?? DEFAULT_MAX_LINES;
|
|
121
|
+
if (!Number.isInteger(maxLines) || /** @type {number} */ (maxLines) < MIN_MAX_LINES) throw new ContextError('bad-context', `context.max_lines must be an integer >= ${MIN_MAX_LINES}`);
|
|
122
|
+
return { rules: [...rules], lessons: /** @type {string | null} */ (lessons), commands, maxLines: /** @type {number} */ (maxLines) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** How many symlinks one path may pass through before it counts as a loop (as the kernel's ELOOP). */
|
|
126
|
+
const MAX_LINK_HOPS = 40;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `abs` with every symlink on it resolved, as far as its components exist — a symlink whose target
|
|
130
|
+
* is missing (a broken link) is still followed through its `readlink` text, so where it POINTS is
|
|
131
|
+
* what is checked. A component that does not exist ends the walk (the rest is joined as given); a
|
|
132
|
+
* link loop ends it at the link.
|
|
133
|
+
* @param {string} abs - absolute, without `..` segments. @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
function walkLinks(abs) {
|
|
136
|
+
const parts = abs.split(path.sep).filter(Boolean);
|
|
137
|
+
let cur = path.parse(abs).root;
|
|
138
|
+
let hops = 0;
|
|
139
|
+
while (parts.length > 0) {
|
|
140
|
+
const next = path.join(cur, /** @type {string} */ (parts.shift()));
|
|
141
|
+
let st;
|
|
142
|
+
try {
|
|
143
|
+
st = lstatSync(next);
|
|
144
|
+
} catch {
|
|
145
|
+
return path.join(next, ...parts);
|
|
146
|
+
}
|
|
147
|
+
if (!st.isSymbolicLink()) {
|
|
148
|
+
cur = next;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
hops += 1;
|
|
152
|
+
if (hops > MAX_LINK_HOPS) return path.join(next, ...parts);
|
|
153
|
+
// `cur` holds no symlink, so resolving the target's `..` against it is exact
|
|
154
|
+
const target = path.resolve(cur, readlinkSync(next));
|
|
155
|
+
parts.unshift(...target.split(path.sep).filter(Boolean));
|
|
156
|
+
cur = path.parse(target).root;
|
|
157
|
+
}
|
|
158
|
+
return cur;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {string} root @param {string} p @returns {boolean} `p` is `root` or under it. */
|
|
162
|
+
const inside = (root, p) => {
|
|
163
|
+
const rel = path.relative(root, p);
|
|
164
|
+
// only a real `..` segment leaves the root: a file named `..notes.md` is inside
|
|
165
|
+
return !(rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel));
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The real path of the context file `rel` (named by `key`) under `repoRoot`.
|
|
170
|
+
* @param {string} repoRoot - the main tree's root. @param {string} rel @param {string} key
|
|
171
|
+
* @returns {string}
|
|
172
|
+
* @throws {ContextError} `control-character`, `outside`, `missing`, `unreadable`, `not-file`
|
|
173
|
+
*/
|
|
174
|
+
export function resolveContextFile(repoRoot, rel, key) {
|
|
175
|
+
const shown = JSON.stringify(rel);
|
|
176
|
+
if (CONTROL.test(rel)) throw new ContextError('control-character', `${key}: ${shown} contains a control character`);
|
|
177
|
+
if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
|
|
178
|
+
throw new ContextError('outside', `${key}: ${shown} is outside the repository (give a path relative to the repository root)`);
|
|
179
|
+
}
|
|
180
|
+
const root = realpathSync(repoRoot);
|
|
181
|
+
// every symlink on the way is followed — a broken one too, by its target — and the end checked
|
|
182
|
+
const real = walkLinks(path.resolve(root, rel));
|
|
183
|
+
if (!inside(root, real)) throw new ContextError('outside', `${key}: ${shown} leads outside the repository`);
|
|
184
|
+
let st;
|
|
185
|
+
try {
|
|
186
|
+
st = lstatSync(real);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code ?? 'error';
|
|
189
|
+
if (code === 'ENOENT') throw new ContextError('missing', `${key}: ${shown} does not exist`);
|
|
190
|
+
throw new ContextError('unreadable', `${key}: ${shown} cannot be read (${code})`);
|
|
191
|
+
}
|
|
192
|
+
// still a link here: a loop (or more than MAX_LINK_HOPS links) — nothing to read
|
|
193
|
+
if (st.isSymbolicLink()) throw new ContextError('missing', `${key}: ${shown} does not exist`);
|
|
194
|
+
if (!st.isFile()) throw new ContextError('not-file', `${key}: ${shown} is not a file`);
|
|
195
|
+
return real;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string} repoRoot @param {string} rel @param {string} key
|
|
200
|
+
* @returns {string[]} the file's lines (redacted, trailing whitespace of the file dropped); none
|
|
201
|
+
* for an empty or whitespace-only file.
|
|
202
|
+
* @throws {ContextError} as {@link resolveContextFile}, or `unreadable` when the read fails.
|
|
203
|
+
*/
|
|
204
|
+
function readLines(repoRoot, rel, key) {
|
|
205
|
+
const file = resolveContextFile(repoRoot, rel, key);
|
|
206
|
+
let raw;
|
|
207
|
+
try {
|
|
208
|
+
raw = readFileSync(file, 'utf8');
|
|
209
|
+
} catch (err) {
|
|
210
|
+
throw new ContextError('unreadable', `${key}: ${JSON.stringify(rel)} cannot be read (${/** @type {NodeJS.ErrnoException} */ (err).code ?? 'error'})`);
|
|
211
|
+
}
|
|
212
|
+
const text = /** @type {string} */ (redact(raw)).trimEnd();
|
|
213
|
+
return text === '' ? [] : splitLines(text);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Every rules file and the lessons file, read. A missing or outside file is a refusal.
|
|
218
|
+
* @param {string} repoRoot @param {ContextSettings} settings
|
|
219
|
+
* @returns {{rules: Array<{path: string, lines: string[]}>, lessons: {path: string, lines: string[]} | null}}
|
|
220
|
+
* @throws {ContextError}
|
|
221
|
+
*/
|
|
222
|
+
export function readSources(repoRoot, settings) {
|
|
223
|
+
const rules = settings.rules.map((rel) => ({ path: rel, lines: readLines(repoRoot, rel, 'context.rules') }));
|
|
224
|
+
const lessons = settings.lessons === null ? null : { path: settings.lessons, lines: readLines(repoRoot, settings.lessons, 'context.lessons') };
|
|
225
|
+
return { rules, lessons };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The token as a POSIX shell would take it back: bare when it holds only characters no shell
|
|
230
|
+
* expands, else in single quotes (an embedded `'` as `'\\''`), so a pasted line never expands
|
|
231
|
+
* `$x`, a backtick, a glob or a brace.
|
|
232
|
+
* @param {string} token @returns {string}
|
|
233
|
+
*/
|
|
234
|
+
const argvToken = (token) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(token) ? token : `'${token.replaceAll("'", "'\\''")}'`);
|
|
235
|
+
|
|
236
|
+
/** @param {string[]} argv @returns {string} */
|
|
237
|
+
export const argvText = (argv) => argv.map(argvToken).join(' ');
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @typedef {object} PackBlock
|
|
241
|
+
* @property {string} run
|
|
242
|
+
* @property {string} block
|
|
243
|
+
* @property {string | null} [level]
|
|
244
|
+
* @property {string[]} [owned]
|
|
245
|
+
* @property {string[] | null} [tests]
|
|
246
|
+
* @property {{path: string, branch: string} | null} [worktree]
|
|
247
|
+
*/
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build the pack text for a block (pure over what it is given).
|
|
251
|
+
* @param {{settings: ContextSettings, sources: ReturnType<typeof readSources>, block: PackBlock}} opts
|
|
252
|
+
* @returns {{text: string, lines: number, cut: number}} `cut`: the lines dropped.
|
|
253
|
+
* @throws {ContextError} `control-character` when a header value holds one.
|
|
254
|
+
*/
|
|
255
|
+
export function assemblePack({ settings, sources, block }) {
|
|
256
|
+
/** @type {Array<[string, unknown]>} */
|
|
257
|
+
const values = [['level', block.level], ...(block.owned ?? []).map((f) => /** @type {[string, unknown]} */ (['owned file', f])), ...(block.tests ?? []).map((f) => /** @type {[string, unknown]} */ (['test file', f]))];
|
|
258
|
+
if (block.worktree) values.push(['worktree path', block.worktree.path], ['worktree branch', block.worktree.branch]);
|
|
259
|
+
for (const [label, v] of values) {
|
|
260
|
+
if (typeof v === 'string' && CONTROL.test(v)) throw new ContextError('control-character', `block ${block.block}: ${label} ${JSON.stringify(v)} contains a control character`);
|
|
261
|
+
}
|
|
262
|
+
const header = ['# code-forge context pack', '## block', `run: ${block.run}`, `block: ${block.block}`, `level: ${block.level ?? '(unknown)'}`, 'owned files:'];
|
|
263
|
+
for (const f of block.owned ?? []) header.push(`- ${f}`);
|
|
264
|
+
if (Array.isArray(block.tests) && block.tests.length > 0) {
|
|
265
|
+
header.push('test files:');
|
|
266
|
+
for (const f of block.tests) header.push(`- ${f}`);
|
|
267
|
+
}
|
|
268
|
+
if (block.worktree) header.push(`worktree: ${block.worktree.path}`, `branch: ${block.worktree.branch}`);
|
|
269
|
+
header.push('## commands (never run by code-forge)');
|
|
270
|
+
const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
|
|
271
|
+
if (keys.length === 0) header.push('(none)');
|
|
272
|
+
// a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
|
|
273
|
+
for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
|
|
274
|
+
|
|
275
|
+
/** @type {Array<{heading: string, body: string[], dropped: number}>} */
|
|
276
|
+
const sections = sources.rules.map((r) => ({ heading: `## rules: ${r.path}`, body: [...r.lines], dropped: 0 }));
|
|
277
|
+
if (sources.lessons) sections.push({ heading: `## lessons: ${sources.lessons.path}`, body: [...sources.lessons.lines], dropped: 0 });
|
|
278
|
+
|
|
279
|
+
const total = () => header.length + sections.reduce((n, s) => n + 1 + s.body.length + (s.dropped > 0 ? 1 : 0), 0);
|
|
280
|
+
// cut order: the lessons first, then the rules from the last file backwards
|
|
281
|
+
for (const s of [...sections].reverse()) {
|
|
282
|
+
let excess = total() - settings.maxLines;
|
|
283
|
+
if (excess <= 0) break;
|
|
284
|
+
// the marker takes one line: a section is only cut when that leaves it shorter
|
|
285
|
+
if (s.body.length < 2) continue;
|
|
286
|
+
const drop = Math.min(s.body.length, excess + 1);
|
|
287
|
+
s.body = s.body.slice(0, s.body.length - drop);
|
|
288
|
+
s.dropped = drop;
|
|
289
|
+
}
|
|
290
|
+
const out = [...header];
|
|
291
|
+
for (const s of sections) {
|
|
292
|
+
out.push(s.heading, ...s.body);
|
|
293
|
+
if (s.dropped > 0) out.push(`[cut: ${s.dropped} lines dropped]`);
|
|
294
|
+
}
|
|
295
|
+
return { text: `${out.join('\n')}\n`, lines: out.length, cut: sections.reduce((n, s) => n + s.dropped, 0) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The review packets' rules digest (`## project rules`), built from the MAIN tree each time a
|
|
300
|
+
* packet is built: the rules files' lines in order, then `### lessons` and the lessons' lines.
|
|
301
|
+
* '' when the config has no `context` key (the packet stays as it was), or names no rules and no
|
|
302
|
+
* lessons. `packet.mjs` keeps the first 60 lines and trims from the end over budget, rules and
|
|
303
|
+
* lessons together.
|
|
304
|
+
* @param {{repoRoot: string, cfg: Record<string, any> | null | undefined}} opts
|
|
305
|
+
* @returns {string}
|
|
306
|
+
* @throws {ContextError} a bad `context` value, or a rules or lessons file that is missing,
|
|
307
|
+
* outside the repository or unreadable — the worker makes that ticket `review.unavailable`
|
|
308
|
+
* with `reason: context-unreadable` (no review runs without the rules it was promised).
|
|
309
|
+
*/
|
|
310
|
+
export function reviewDigest({ repoRoot, cfg }) {
|
|
311
|
+
const settings = contextSettings(cfg);
|
|
312
|
+
if (settings === null) return '';
|
|
313
|
+
const out = settings.rules.flatMap((rel) => readLines(repoRoot, rel, 'context.rules'));
|
|
314
|
+
if (settings.lessons !== null) out.push(DIGEST_LESSONS_HEADING, ...readLines(repoRoot, settings.lessons, 'context.lessons'));
|
|
315
|
+
return out.join('\n');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** @param {string} text @returns {number} the pack's estimated tokens (the review packets' bytes / 4). */
|
|
319
|
+
export const packTokens = (text) => estimateTokens(text);
|
package/src/doctor/index.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge doctor` (plan §2.3; block B13b): every row, in print order. `--quick` = the three
|
|
3
3
|
* rows the skill's preflight needs (config, harness links, Jev key resolution); the full run adds
|
|
4
|
-
* the PATH/Solo/gate rows, the live Jev call, ledger, `tmp`,
|
|
5
|
-
* version, flags, one call per role builder, isolation, path deny, Codex
|
|
6
|
-
* start/stop, the signer key and the `signer: same-user boundary only` line.
|
|
4
|
+
* the PATH/Solo/gate rows, the live Jev call, ledger, `tmp`, `review state` (B69), the provider
|
|
5
|
+
* CLI probes (presence, version, flags, one call per role builder, isolation, path deny, Codex
|
|
6
|
+
* rules), the worker start/stop, the signer key and the `signer: same-user boundary only` line.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { randomBytes } from 'node:crypto';
|
|
@@ -13,7 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
14
14
|
import { newRunId } from '../state/paths.mjs';
|
|
15
15
|
import { runRoot } from '../util/tmp.mjs';
|
|
16
|
-
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkTmp, checkTools } from './local.mjs';
|
|
16
|
+
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
|
|
17
17
|
import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
|
|
18
18
|
import { row } from './rows.mjs';
|
|
19
19
|
import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
|
|
@@ -68,6 +68,7 @@ export async function runDoctor(opts = {}, deps = {}) {
|
|
|
68
68
|
rows.push(...(await checkTools(toolOpts)));
|
|
69
69
|
rows.push(...(await checkLedger()));
|
|
70
70
|
rows.push(...checkTmp(cfg));
|
|
71
|
+
rows.push(...(await checkReviewState(cfg)));
|
|
71
72
|
rows.push(checkReviewConcurrency(cfg));
|
|
72
73
|
if (!cfg) {
|
|
73
74
|
rows.push(row('probes', 'FAIL', 'probes', 'skipped: no valid config'));
|