conductor-board 2.0.0 → 2.2.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/bin/cli.js +73 -13
- package/cli/complete.js +47 -6
- package/cli/knowledge.js +109 -0
- package/cli/setup.js +19 -8
- package/cli/validate.js +2 -0
- package/cli/writer.js +245 -4
- package/dist/assets/index-DvTrz5lj.js +34 -0
- package/dist/assets/index-ilfk-igS.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/server.js +183 -54
- package/dist/assets/index-BIA1HyJ3.js +0 -34
- package/dist/assets/index-Di1Pl_pH.css +0 -1
package/bin/cli.js
CHANGED
|
@@ -44,15 +44,18 @@ const HELP = `
|
|
|
44
44
|
gate <id> <state> checking | passed | failed
|
|
45
45
|
heartbeat <id> "note" Append a heartbeat (--iteration, --insight-type,
|
|
46
46
|
--insight-seed, --final, --to <step>)
|
|
47
|
+
suggest "title" --scope SC Append a learning to the conductor's knowledge:
|
|
48
|
+
knowledge [--min N] List knowledge / gate on captured-learnings count
|
|
49
|
+
loop-scope <loop> <item...> Frontload every iteration as pending (scope beat)
|
|
47
50
|
loop <loop> <item> <sub> <state> Update a loop sub-step
|
|
48
|
-
complete <
|
|
51
|
+
complete <step>[::iter::sub] [--attest-soft] Run hard gates, then advance
|
|
49
52
|
|
|
50
53
|
Board options
|
|
51
54
|
--path, -p <file> Path to status.json (default: .conductor/status.json)
|
|
52
55
|
--conductor, -c <file> Path to the conductor (default: auto-discovered)
|
|
53
56
|
--port <n> Port to serve on (default: 3042)
|
|
54
|
-
--
|
|
55
|
-
|
|
57
|
+
--headless Don't open a browser (CI / cloud / no display).
|
|
58
|
+
Same as CONDUCTOR_HEADLESS=1. Default: opens.
|
|
56
59
|
|
|
57
60
|
init options
|
|
58
61
|
--name, -n <name> Workflow name (skips the prompts)
|
|
@@ -110,9 +113,18 @@ if (command === "clean") {
|
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
// status-writer commands (for agents — keep the board live as you work)
|
|
113
|
-
if (["step", "gate", "heartbeat", "loop", "status-init"].includes(command)) {
|
|
116
|
+
if (["step", "gate", "heartbeat", "loop", "loop-scope", "status-init", "suggest", "knowledge"].includes(command)) {
|
|
114
117
|
const w = await import("../cli/writer.js");
|
|
115
|
-
const fn = {
|
|
118
|
+
const fn = {
|
|
119
|
+
step: w.runStep,
|
|
120
|
+
gate: w.runGate,
|
|
121
|
+
heartbeat: w.runHeartbeat,
|
|
122
|
+
loop: w.runLoop,
|
|
123
|
+
"loop-scope": w.runLoopScope,
|
|
124
|
+
"status-init": w.runStatusInit,
|
|
125
|
+
suggest: w.runSuggest,
|
|
126
|
+
knowledge: w.runKnowledge,
|
|
127
|
+
}[command];
|
|
116
128
|
process.exit((await fn(rest)) ? 0 : 1);
|
|
117
129
|
}
|
|
118
130
|
|
|
@@ -131,7 +143,10 @@ const statusPath = String(flag(["--path", "-p"], ".conductor/status.json"));
|
|
|
131
143
|
const conductorArg = flag(["--conductor", "-c"], null);
|
|
132
144
|
const conductorPath = conductorArg ? path.resolve(process.cwd(), String(conductorArg)) : null;
|
|
133
145
|
const wantedPort = Number(flag(["--port"], 3042)) || 3042;
|
|
134
|
-
|
|
146
|
+
// The board always opens the browser — it's meant to be seen. --headless (or
|
|
147
|
+
// CONDUCTOR_HEADLESS=1) suppresses it for CI / cloud / no-display environments.
|
|
148
|
+
// The name signals "I have no display", not "I don't want to look".
|
|
149
|
+
const headless = argv.includes("--headless") || process.env.CONDUCTOR_HEADLESS === "1";
|
|
135
150
|
|
|
136
151
|
function openBrowser(url) {
|
|
137
152
|
const cmd =
|
|
@@ -200,6 +215,23 @@ function pidAlive(pid) {
|
|
|
200
215
|
}
|
|
201
216
|
}
|
|
202
217
|
|
|
218
|
+
// Probe a recorded board's /health endpoint — distinguishes a live, serving
|
|
219
|
+
// board (reuse it) from a wedged pid that holds the port but isn't responding.
|
|
220
|
+
async function boardHealthy(url) {
|
|
221
|
+
if (!url) return false;
|
|
222
|
+
try {
|
|
223
|
+
const ctrl = new AbortController();
|
|
224
|
+
const t = setTimeout(() => ctrl.abort(), 1200);
|
|
225
|
+
const r = await fetch(`${url}/health`, { signal: ctrl.signal });
|
|
226
|
+
clearTimeout(t);
|
|
227
|
+
if (!r.ok) return false;
|
|
228
|
+
const body = await r.json().catch(() => null);
|
|
229
|
+
return !!body && body.status === "ok";
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
203
235
|
/**
|
|
204
236
|
* Before binding a port, look for a board already serving this project.
|
|
205
237
|
*
|
|
@@ -236,12 +268,22 @@ async function preflightStaleBoard(serverJsonPath) {
|
|
|
236
268
|
return null;
|
|
237
269
|
}
|
|
238
270
|
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
271
|
+
const url = info.url || `http://localhost:${info.port ?? "?"}`;
|
|
272
|
+
const reuse = { reuse: true, pid, url, when: info.started_at ? ago(info.started_at) : "" };
|
|
273
|
+
|
|
274
|
+
// Alive but unhealthy (wedged — holds the port but /health doesn't answer):
|
|
275
|
+
// kill it and start fresh, so a hung board can't block a clean restart (§4.6).
|
|
276
|
+
if (!(await boardHealthy(url))) {
|
|
277
|
+
try {
|
|
278
|
+
process.kill(pid, "SIGTERM");
|
|
279
|
+
} catch {
|
|
280
|
+
/* may have just exited */
|
|
281
|
+
}
|
|
282
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
283
|
+
clear();
|
|
284
|
+
console.log(amber(`\n ⚠ board pid ${pid} was unresponsive — stopped it and starting fresh.`));
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
245
287
|
|
|
246
288
|
// Non-interactive (an agent, a script): never duplicate — reuse silently.
|
|
247
289
|
if (!process.stdin.isTTY) return reuse;
|
|
@@ -300,7 +342,24 @@ if (resolvedConductor) {
|
|
|
300
342
|
console.log(` ${dim("press ctrl+c to stop")}`);
|
|
301
343
|
console.log("");
|
|
302
344
|
|
|
303
|
-
if (!
|
|
345
|
+
if (!headless) openBrowser(url);
|
|
346
|
+
|
|
347
|
+
// Subdirectory convention (§4.7): warn if a flat .conductor/status.json is in
|
|
348
|
+
// use. One workflow per .conductor/<name>/ keeps history and insights separate.
|
|
349
|
+
try {
|
|
350
|
+
const flat = path.resolve(process.cwd(), ".conductor", "status.json");
|
|
351
|
+
if (path.resolve(absStatus) === flat && fs.existsSync(flat)) {
|
|
352
|
+
console.log(
|
|
353
|
+
amber(" ⚠ flat .conductor/status.json detected") +
|
|
354
|
+
dim(" — the convention is .conductor/<workflow-name>/. It still works,") +
|
|
355
|
+
"\n" +
|
|
356
|
+
dim(" but subdirectories keep each workflow's history + insights separate."),
|
|
357
|
+
);
|
|
358
|
+
console.log("");
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
/* advisory only */
|
|
362
|
+
}
|
|
304
363
|
|
|
305
364
|
function shutdown() {
|
|
306
365
|
try {
|
|
@@ -314,3 +373,4 @@ function shutdown() {
|
|
|
314
373
|
|
|
315
374
|
process.on("SIGINT", shutdown);
|
|
316
375
|
process.on("SIGTERM", shutdown);
|
|
376
|
+
process.on("SIGQUIT", shutdown);
|
package/cli/complete.js
CHANGED
|
@@ -41,13 +41,24 @@ function discoverConductor(statusPath, explicit) {
|
|
|
41
41
|
* with gate_detail tagging each criterion 🔒 verified (CLI ran it) or ✋ attested.
|
|
42
42
|
*/
|
|
43
43
|
export async function runComplete(args) {
|
|
44
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
45
|
+
console.log(
|
|
46
|
+
"usage: conductor-board complete <step-id> [--attest-soft]\n" +
|
|
47
|
+
" conductor-board complete <loop-id>::<iteration>::<sub-step> [--attest-soft]\n\n" +
|
|
48
|
+
" Runs the step's HARD gates independently (you can't fake them — the board\n" +
|
|
49
|
+
" shows 🔒 verified vs ✋ attested) and only advances when they pass.\n" +
|
|
50
|
+
" The :: form resolves a loop sub-step inside the conductor's loop definition.\n" +
|
|
51
|
+
" --attest-soft: mark the soft gates as attested once you've verified them.",
|
|
52
|
+
);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
44
55
|
const p = flag(args, ["--path", "-p"]);
|
|
45
56
|
const statusPath = path.resolve(process.cwd(), typeof p === "string" ? p : ".conductor/status.json");
|
|
46
57
|
const stepId = args.find((a) => !a.startsWith("-"));
|
|
47
58
|
const attestSoft = args.includes("--attest-soft");
|
|
48
59
|
|
|
49
60
|
if (!stepId) {
|
|
50
|
-
console.error(red("usage: conductor-board complete <step-id> [--attest-soft]"));
|
|
61
|
+
console.error(red("usage: conductor-board complete <step-id>[::iter::sub] [--attest-soft]"));
|
|
51
62
|
return false;
|
|
52
63
|
}
|
|
53
64
|
|
|
@@ -63,10 +74,29 @@ export async function runComplete(args) {
|
|
|
63
74
|
console.error(red(`✗ could not parse conductor: ${e.message}`));
|
|
64
75
|
return false;
|
|
65
76
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
// resolve the step — either a top-level id, or a loop sub-step "loop::iter::sub"
|
|
78
|
+
const parts = stepId.split("::");
|
|
79
|
+
let step;
|
|
80
|
+
let loopPath = null;
|
|
81
|
+
if (parts.length === 3) {
|
|
82
|
+
const [loopId, iter, subId] = parts;
|
|
83
|
+
const loopStep = (doc.steps || []).find((s) => s && s.id === loopId && s.type === "loop");
|
|
84
|
+
if (!loopStep) {
|
|
85
|
+
console.error(red(`✗ conductor has no loop "${loopId}"`));
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
step = (loopStep.steps || []).find((s) => s && s.id === subId);
|
|
89
|
+
if (!step) {
|
|
90
|
+
console.error(red(`✗ loop "${loopId}" has no sub-step "${subId}"`));
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
loopPath = { loopId, iter, subId };
|
|
94
|
+
} else {
|
|
95
|
+
step = (doc.steps || []).find((s) => s && s.id === stepId);
|
|
96
|
+
if (!step) {
|
|
97
|
+
console.error(red(`✗ conductor has no step "${stepId}"`));
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
70
100
|
}
|
|
71
101
|
|
|
72
102
|
const soft = [];
|
|
@@ -117,7 +147,18 @@ export async function runComplete(args) {
|
|
|
117
147
|
if (ok) {
|
|
118
148
|
try {
|
|
119
149
|
const status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
120
|
-
|
|
150
|
+
let st;
|
|
151
|
+
if (loopPath) {
|
|
152
|
+
const lp = (status.steps[loopPath.loopId] = status.steps[loopPath.loopId] || {
|
|
153
|
+
type: "loop",
|
|
154
|
+
iterations: {},
|
|
155
|
+
});
|
|
156
|
+
lp.iterations = lp.iterations || {};
|
|
157
|
+
const it = (lp.iterations[loopPath.iter] = lp.iterations[loopPath.iter] || {});
|
|
158
|
+
st = it[loopPath.subId] = it[loopPath.subId] || { attempt: 1 };
|
|
159
|
+
} else {
|
|
160
|
+
st = status.steps[stepId] = status.steps[stepId] || { attempt: 1 };
|
|
161
|
+
}
|
|
121
162
|
st.status = "done";
|
|
122
163
|
st.gate = "passed";
|
|
123
164
|
st.gate_detail = detail;
|
package/cli/knowledge.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { validateConductor } from "./validate.js";
|
|
5
|
+
|
|
6
|
+
export const SCOPES = ["this-conductor", "upstream", "template", "tooling", "corpus"];
|
|
7
|
+
|
|
8
|
+
/** Find the conductor file paired with a status.json. */
|
|
9
|
+
export function discoverConductor(statusPath, explicit) {
|
|
10
|
+
if (explicit) {
|
|
11
|
+
const p = path.resolve(process.cwd(), explicit);
|
|
12
|
+
return fs.existsSync(p) ? p : null;
|
|
13
|
+
}
|
|
14
|
+
const dir = path.dirname(statusPath);
|
|
15
|
+
for (const c of ["conductor.yaml", "conductor.yml"]) {
|
|
16
|
+
const p = path.join(dir, c);
|
|
17
|
+
if (fs.existsSync(p)) return p;
|
|
18
|
+
}
|
|
19
|
+
if (fs.existsSync(dir)) {
|
|
20
|
+
const y = fs.readdirSync(dir).find((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
|
|
21
|
+
if (y) return path.join(dir, y);
|
|
22
|
+
}
|
|
23
|
+
for (const c of ["conductor.yaml", "conductor.yml"]) {
|
|
24
|
+
const p = path.resolve(process.cwd(), c);
|
|
25
|
+
if (fs.existsSync(p)) return p;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const norm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The status a knowledge entry should hold given its evidence (§10.3):
|
|
34
|
+
* 1 observation → emerging · 3+ → proven (this-conductor) · applied is sticky.
|
|
35
|
+
* Cross-cutting scopes can't be auto-applied, so they sit at `open`.
|
|
36
|
+
*/
|
|
37
|
+
export function statusFor(entry) {
|
|
38
|
+
if (entry.status === "applied") return "applied";
|
|
39
|
+
if (entry.scope && entry.scope !== "this-conductor") return "open";
|
|
40
|
+
return (entry.observed || 1) >= 3 ? "proven" : "emerging";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Merge one learning into a conductor doc's knowledge array (in place). */
|
|
44
|
+
export function mergeKnowledge(doc, entry) {
|
|
45
|
+
doc.knowledge = Array.isArray(doc.knowledge) ? doc.knowledge : [];
|
|
46
|
+
const existing = doc.knowledge.find(
|
|
47
|
+
(k) => k && typeof k === "object" && norm(k.title) === norm(entry.title),
|
|
48
|
+
);
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.observed = (existing.observed || 1) + 1;
|
|
51
|
+
if (entry.scope) existing.scope = entry.scope;
|
|
52
|
+
if (entry.step) existing.step = entry.step;
|
|
53
|
+
if (entry.type) existing.type = entry.type;
|
|
54
|
+
if (entry.current) existing.current = entry.current;
|
|
55
|
+
if (entry.proposed) existing.proposed = entry.proposed;
|
|
56
|
+
if (entry.note) existing.note = entry.note;
|
|
57
|
+
existing.status = statusFor(existing);
|
|
58
|
+
return existing;
|
|
59
|
+
}
|
|
60
|
+
const fresh = {
|
|
61
|
+
title: entry.title,
|
|
62
|
+
scope: entry.scope || "this-conductor",
|
|
63
|
+
observed: entry.observed || 1,
|
|
64
|
+
...(entry.step ? { step: entry.step } : {}),
|
|
65
|
+
...(entry.type ? { type: entry.type } : {}),
|
|
66
|
+
...(entry.current ? { current: entry.current } : {}),
|
|
67
|
+
...(entry.proposed ? { proposed: entry.proposed } : {}),
|
|
68
|
+
...(entry.note ? { note: entry.note } : {}),
|
|
69
|
+
};
|
|
70
|
+
fresh.status = statusFor(fresh);
|
|
71
|
+
doc.knowledge.push(fresh);
|
|
72
|
+
return fresh;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read + parse a conductor file. Throws on parse error. */
|
|
76
|
+
export function loadConductor(conductorPath) {
|
|
77
|
+
return yaml.load(fs.readFileSync(conductorPath, "utf8")) || {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write a conductor doc back, with a single rolling backup and a re-validation.
|
|
82
|
+
* Returns { ok, error } — never throws.
|
|
83
|
+
*/
|
|
84
|
+
export function saveConductor(conductorPath, doc) {
|
|
85
|
+
const errors = validateConductor(doc);
|
|
86
|
+
if (errors.length) return { ok: false, error: `would be invalid: ${errors[0]}` };
|
|
87
|
+
try {
|
|
88
|
+
fs.writeFileSync(`${conductorPath}.bak`, fs.readFileSync(conductorPath, "utf8"));
|
|
89
|
+
} catch {
|
|
90
|
+
/* backup is best-effort */
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
fs.writeFileSync(conductorPath, yaml.dump(doc, { lineWidth: 100 }));
|
|
94
|
+
return { ok: true };
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { ok: false, error: e.message };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Count knowledge entries (optionally filtered by status/scope). */
|
|
101
|
+
export function knowledgeCount(doc, { status, scope } = {}) {
|
|
102
|
+
const k = Array.isArray(doc.knowledge) ? doc.knowledge : [];
|
|
103
|
+
return k.filter(
|
|
104
|
+
(e) =>
|
|
105
|
+
e &&
|
|
106
|
+
(!status || (e.status || "emerging") === status) &&
|
|
107
|
+
(!scope || (e.scope || "this-conductor") === scope),
|
|
108
|
+
).length;
|
|
109
|
+
}
|
package/cli/setup.js
CHANGED
|
@@ -28,8 +28,8 @@ steps:
|
|
|
28
28
|
- id: start-board
|
|
29
29
|
instruction: |
|
|
30
30
|
Start the board server ONCE in the background and leave it running for the
|
|
31
|
-
whole run. It opens the browser automatically — do NOT pass --
|
|
32
|
-
that defeats the live view.
|
|
31
|
+
whole run. It opens the browser automatically — do NOT pass --headless here,
|
|
32
|
+
that defeats the live view (--headless is only for CI / no-display runs).
|
|
33
33
|
npx conductor-board >/tmp/conductor-board.log 2>&1 &
|
|
34
34
|
Wait ~3 seconds for it to initialize. It auto-detects a free port if 3042
|
|
35
35
|
is taken and records the chosen port in .conductor/server.json. Do NOT run
|
|
@@ -77,7 +77,12 @@ steps:
|
|
|
77
77
|
- id: execute-workflow
|
|
78
78
|
instruction: |
|
|
79
79
|
Execute the generated conductor workflow.
|
|
80
|
-
|
|
80
|
+
Initialize the board with: conductor-board status-init .conductor/conductor.yaml
|
|
81
|
+
This also auto-injects a Phase 0 "improvement" pass — if the conductor's
|
|
82
|
+
knowledge section holds any PROVEN this-conductor insights with current/
|
|
83
|
+
proposed text, apply each (rewrite the named step), then validate, before
|
|
84
|
+
step 1. Structural changes (add/remove/reorder a step) wait for human
|
|
85
|
+
Approve on the board. If nothing is proven, Phase 0 is empty — start the work.
|
|
81
86
|
Set the top-level "goal" from the conductor's description, and refresh
|
|
82
87
|
"current_step_goal" each time current_step changes.
|
|
83
88
|
Walk each step in order, updating status.json after EVERY transition
|
|
@@ -97,17 +102,23 @@ steps:
|
|
|
97
102
|
iteration finishes — don't wait until the loop ends.
|
|
98
103
|
At the START of the run, read .conductor/insights.md (if it exists) to carry
|
|
99
104
|
forward what past runs learned — don't repeat insights already recorded there.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
At run end, before setting status "done", write what you learned into the
|
|
106
|
+
conductor's knowledge section — the conductor IS the knowledge base. Use:
|
|
107
|
+
conductor-board suggest "title" --scope <scope> [--step S --current X --proposed Y]
|
|
108
|
+
--scope is REQUIRED (this-conductor | upstream | template | tooling | corpus).
|
|
109
|
+
A repeat sighting escalates emerging -> proven (3x); proven this-conductor
|
|
110
|
+
insights auto-apply in the next run's Phase 0. Browse them on the board's
|
|
111
|
+
✨ Insights page. Set the top-level status to "done" when the last step
|
|
112
|
+
completes.
|
|
105
113
|
requires: [convert-to-conductor]
|
|
106
114
|
gate:
|
|
107
115
|
- name: "Status file exists"
|
|
108
116
|
check: "test -f .conductor/status.json"
|
|
109
117
|
- name: "Workflow completed successfully"
|
|
110
118
|
check: "node -p \\"JSON.parse(require('fs').readFileSync('.conductor/status.json','utf8')).status\\" | grep done"
|
|
119
|
+
- name: "Captured cross-cutting learnings (≥1 insight, ≥2 scopes)"
|
|
120
|
+
check: "npx conductor-board knowledge --min 1 --min-scopes 2"
|
|
121
|
+
- "Answered: what did I learn that does NOT fit a step of this workflow? (upstream, template, tooling, or corpus insights logged with scope tags)"
|
|
111
122
|
`;
|
|
112
123
|
|
|
113
124
|
export async function runSetup(args) {
|
package/cli/validate.js
CHANGED
|
@@ -143,6 +143,8 @@ export function validateConductor(doc) {
|
|
|
143
143
|
if (!Array.isArray(s.steps) || s.steps.length === 0)
|
|
144
144
|
errors.push(`Loop "${s.id}" has no sub-steps`);
|
|
145
145
|
else validateSubSteps(s, errors);
|
|
146
|
+
if (s.parallel !== undefined && s.parallel !== true && s.parallel !== false && s.parallel !== "auto")
|
|
147
|
+
errors.push(`Loop "${s.id}" has invalid "parallel" (use true, false, or auto)`);
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
for (const [field, val] of [
|