projectstore-claude 0.30.1 → 0.30.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  3. package/node_modules/projectstore/AGENTS.md +7 -5
  4. package/node_modules/projectstore/README.md +5 -5
  5. package/node_modules/projectstore/agents/clerk.md +2 -2
  6. package/node_modules/projectstore/commands/agents.md +38 -26
  7. package/node_modules/projectstore/commands/bind.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +15 -3
  9. package/node_modules/projectstore/commands/reconcile.md +2 -2
  10. package/node_modules/projectstore/commands/review.md +1 -1
  11. package/node_modules/projectstore/commands/story.md +2 -1
  12. package/node_modules/projectstore/docs/getting-started.md +1 -1
  13. package/node_modules/projectstore/docs/harnesses.md +3 -2
  14. package/node_modules/projectstore/harnesses/claude-code.json +92 -2
  15. package/node_modules/projectstore/harnesses/codex.json +81 -14
  16. package/node_modules/projectstore/package.json +1 -1
  17. package/node_modules/projectstore/scripts/agents-change.mjs +64 -10
  18. package/node_modules/projectstore/scripts/build-adapters.mjs +28 -13
  19. package/node_modules/projectstore/scripts/cli.mjs +32 -9
  20. package/node_modules/projectstore/scripts/configure-menu.mjs +716 -0
  21. package/node_modules/projectstore/scripts/configure.mjs +239 -91
  22. package/node_modules/projectstore/scripts/doctor.mjs +95 -31
  23. package/node_modules/projectstore/scripts/harness.mjs +13 -3
  24. package/node_modules/projectstore/scripts/install-harness.mjs +29 -0
  25. package/node_modules/projectstore/scripts/lib.mjs +32 -9
  26. package/node_modules/projectstore/scripts/menu.mjs +746 -0
  27. package/node_modules/projectstore/scripts/models.mjs +649 -0
  28. package/node_modules/projectstore/scripts/term.mjs +167 -0
  29. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +7 -5
  30. package/package.json +2 -2
@@ -60,6 +60,7 @@ import {
60
60
  statusLineLauncherPath,
61
61
  LAUNCHER_HEADER,
62
62
  installedPluginEntries,
63
+ projectCopies,
63
64
  selectCopy,
64
65
  takesNoRegister,
65
66
  copyUpdateAdvice,
@@ -98,7 +99,9 @@ import {
98
99
  configPath as harnessConfigPath,
99
100
  packageCommand,
100
101
  invocation,
102
+ harnessForOverlay,
101
103
  } from "./harness.mjs";
104
+ import { effortSpec } from "./models.mjs";
102
105
 
103
106
  // A remedy used to interpolate the surface's harness variable here. It cannot:
104
107
  // measured 2026-09-06, NO harness gives its Bash tool that variable, and a
@@ -891,7 +894,7 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
891
894
  "install",
892
895
  "issue",
893
896
  "overlay-forbidden-key",
894
- `${where} carries \`${k}\`, which an overlay may not: only agents.default.model and agents.per_agent.<name>.model are read; the key is ignored.`,
897
+ `${where} carries \`${k}\`, which an overlay may not: only agents.default.model, agents.default.effort, agents.per_agent.<name>.model and agents.per_agent.<name>.effort are read; the key is ignored.`,
895
898
  where,
896
899
  ),
897
900
  );
@@ -953,19 +956,42 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
953
956
  }
954
957
  }
955
958
  }
956
- // A configured name no roster agent carries runs nothing: the model never
957
- // applies. A warn, not an issue — a newer package's agent is a legitimate
958
- // reason for a committed overlay to name one this copy does not ship.
959
+ // A configured name no roster agent carries runs nothing: what it
960
+ // configures never applies. A warn, not an issue — a newer package's agent
961
+ // is a legitimate reason for a committed overlay to name one this copy does
962
+ // not ship.
963
+ // An effort the harness's levels lack fails at spawn on a harness whose
964
+ // spawn takes those levels only, and is the harness's to judge elsewhere:
965
+ // a warn names it either way (the effort ADR, decision 7). Read against the
966
+ // manifest's floor alone: doctor reads no model catalog.
967
+ const levels = effortSpec(harnessForOverlay(o.id))?.levels?.map((l) => l.id) || null;
968
+ if (levels) {
969
+ const set = [
970
+ ...(o.efforts.default ? [["agents.default.effort", o.efforts.default]] : []),
971
+ ...Object.entries(o.efforts.per_agent).map(([n, v]) => [`agents.per_agent.${n}.effort`, v]),
972
+ ];
973
+ for (const [k, v] of set)
974
+ if (!levels.includes(v))
975
+ out.push(
976
+ finding(
977
+ "install",
978
+ "warn",
979
+ "overlay-unknown-effort",
980
+ `${where} sets \`${k}\` to ${v}, which is not one of ${harnessForOverlay(o.id)?.display_name || o.id}'s effort levels (${levels.join(", ")}) — a spawn may refuse it.`,
981
+ where,
982
+ ),
983
+ );
984
+ }
959
985
  const roster = layoutRoster(cfg);
960
986
  if (roster) {
961
- for (const n of Object.keys(o.agents.per_agent)) {
987
+ for (const n of new Set([...Object.keys(o.agents.per_agent), ...Object.keys(o.efforts.per_agent)])) {
962
988
  if (!roster.includes(n))
963
989
  out.push(
964
990
  finding(
965
991
  "install",
966
992
  "warn",
967
993
  "overlay-unknown-agent",
968
- `${where} configures \`${n}\`, which is not in the ${cfg.layout} roster (${roster.join(", ")}) — no agent by that name runs, so the model never applies. A typo, or an agent this package does not ship yet.`,
994
+ `${where} configures \`${n}\`, which is not in the ${cfg.layout} roster (${roster.join(", ")}) — no agent by that name runs, so what it configures never applies. A typo, or an agent this package does not ship yet.`,
969
995
  where,
970
996
  ),
971
997
  );
@@ -1148,7 +1174,16 @@ export function checkVersionDrift(home = homedir(), states = [], proj = null, {
1148
1174
  const seen = [];
1149
1175
  // The registry read here is the source harness's own.
1150
1176
  const registryOf = sourceHarness()?.id || null;
1151
- for (const e of installedPluginEntries(home, proj)) {
1177
+ // The copies the host loads for this project (contract 17, amended
1178
+ // 2026-10-14): this checkout's own row or a user-scope row, never another
1179
+ // project's — the registry keeps every project's local rows, and another
1180
+ // checkout's old copy is not a drift here. Every row, not one per key: this
1181
+ // project's own row against a user-scope row is the drift contract 17 names.
1182
+ // Without a project, the user-scope rows alone.
1183
+ const rows = proj
1184
+ ? projectCopies(home, proj)
1185
+ : installedPluginEntries(home).filter((e) => e.present && e.enabled !== false && !e.projectPath);
1186
+ for (const e of rows) {
1152
1187
  // A disabled registration is not a copy anyone runs (contract 17, amended 2026-09-05).
1153
1188
  if (e.version && e.present && e.enabled !== false)
1154
1189
  seen.push({
@@ -1284,24 +1319,32 @@ export function checkOverrideCopies(proj, home = homedir(), harness = speakingHa
1284
1319
  return out;
1285
1320
  }
1286
1321
 
1287
- // ADR-008 made `effort` unconfigurable per project, which promotes this env var
1288
- // from a curiosity to the ONLY thing that can move our agents off `effort: max`.
1289
- // It beats frontmatter, so a value set for cost or latency silently drops all
1290
- // five agents below the quality floor the plugin advertises — exactly the class
1291
- // of silent downgrade doctor exists to name.
1322
+ // The variable outranks every effort projectstore passes at spawn and the
1323
+ // bundled agents' frontmatter effort: with it set, the spawn offers no effort
1324
+ // parameter at all (the effort ADR, decision 7). So a value set for cost or
1325
+ // latency silently replaces the efforts the overlay configures — exactly the
1326
+ // class of "the value configured is not the value that runs" doctor names.
1292
1327
  // The variable names come from the manifest (runtime.agent_overrides); the
1293
- // wording is projectstore's, because the claim — ADR-008's quality floor — is.
1294
- export function checkEnvEffort() {
1328
+ // overlay is read only when one is set, so a clean startup reads nothing.
1329
+ export function checkEnvEffort(proj = null) {
1295
1330
  return agentOverrides()
1296
1331
  .filter((o) => o.kind === "effort")
1297
- .map((o) =>
1298
- finding(
1332
+ .map((o) => {
1333
+ const ov = proj ? readOverlayAt(proj) : null;
1334
+ const set = ov
1335
+ ? [
1336
+ ...(ov.efforts.default ? ["agents.default.effort"] : []),
1337
+ ...Object.keys(ov.efforts.per_agent).map((n) => `agents.per_agent.${n}.effort`),
1338
+ ]
1339
+ : [];
1340
+ const where = set.length ? ` (${relative(proj, ov.path) || ov.path} sets ${set.join(", ")})` : "";
1341
+ return finding(
1299
1342
  "install",
1300
1343
  "warn",
1301
1344
  "env-effort",
1302
- `${o.env}=${o.value} is set — it overrides the bundled agents' "effort: max" frontmatter, so every projectstore agent runs at "${o.value}". Effort is not configurable per project (ADR-008); unset the variable to restore the quality floor.`,
1303
- ),
1304
- );
1345
+ `${o.env}=${o.value} is set — it outranks every effort projectstore passes at spawn${where} and the bundled agents' frontmatter, so every projectstore agent runs at "${o.value}". Unset it for the configured efforts to run.`,
1346
+ );
1347
+ });
1305
1348
  }
1306
1349
 
1307
1350
  export function checkEnvModel() {
@@ -1538,28 +1581,48 @@ export function checkGitignore(proj) {
1538
1581
  }
1539
1582
  }
1540
1583
  }
1541
- if (!existsSync(join(proj, ".git"))) return out;
1584
+ // The host's settings file is checkHostIgnored's (the layout spec, contract 9
1585
+ // as amended 2026-10-14): only git knows every rule that ignores it, and this
1586
+ // function runs in the SessionStart subset, whose budget forbids a subprocess.
1587
+ return out;
1588
+ }
1589
+
1590
+ // The line read the host-file check used before it asked git, and still uses
1591
+ // when git cannot answer: the project's own .gitignore, exact lines only.
1592
+ function hostIgnoreLineMissing(proj, env) {
1542
1593
  let lines = [];
1543
1594
  try {
1544
1595
  lines = readFileSync(join(proj, ".gitignore"), "utf8")
1545
1596
  .split("\n")
1546
1597
  .map((l) => l.trim());
1547
1598
  } catch {}
1548
- const coveredAll = lines.includes(".claude/") || lines.includes(".claude");
1549
- if (coveredAll) return out;
1550
- // Our own files are self-ignored inside .projectstore/; what is left is the host's.
1551
- const wanted = [relative(proj, hostSettingsPath(proj))];
1552
- const missing = wanted.filter((w) => !lines.includes(w));
1553
- if (!missing.length) return out;
1554
- out.push(
1599
+ if (lines.includes(".claude/") || lines.includes(".claude")) return [];
1600
+ return [relative(proj, hostSettingsPath(proj, env))].filter((w) => !lines.includes(w));
1601
+ }
1602
+
1603
+ // Whether git ignores the host's machine-local settings file (the layout spec,
1604
+ // contract 9 as amended 2026-10-14). Our own files are self-ignored inside
1605
+ // .projectstore/; what is left is the host's. Git's answer covers every rule
1606
+ // it applies — nested .gitignore files, .git/info/exclude, core.excludesFile
1607
+ // and its XDG default — where the line read saw only the project's exact
1608
+ // lines. --no-index keeps this about rules: a tracked file with a matching rule
1609
+ // is quiet here (tracking is checkTrackedRuntime's). Exit 0 is ignored, 1 is
1610
+ // not; anything else — no git on PATH, an error, a timeout — falls back to the
1611
+ // line read. Full report only: a subprocess, like checkTrackedRuntime.
1612
+ export function checkHostIgnored(proj, { env = process.env } = {}) {
1613
+ if (!existsSync(join(proj, ".git"))) return [];
1614
+ const file = relative(proj, hostSettingsPath(proj, env));
1615
+ const r = spawnSync("git", ["check-ignore", "-q", "--no-index", "--", file], { cwd: proj, env, timeout: 5000 });
1616
+ const missing = r.status === 0 ? [] : r.status === 1 ? [file] : hostIgnoreLineMissing(proj, env);
1617
+ if (!missing.length) return [];
1618
+ return [
1555
1619
  finding(
1556
1620
  "install",
1557
1621
  "warn",
1558
1622
  "gitignore",
1559
1623
  `Machine-specific files not gitignored: ${missing.join(", ")} (or ignore ".claude/" wholesale).`,
1560
1624
  ),
1561
- );
1562
- return out;
1625
+ ];
1563
1626
  }
1564
1627
 
1565
1628
  // An ignore line never untracks a file already in the index, so the project
@@ -2956,8 +3019,9 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
2956
3019
  ...checkAgentsBlock(proj, { env: opts.env, root: opts.root }),
2957
3020
  ...checkOverrideCopies(proj),
2958
3021
  ...checkEnvModel(),
2959
- ...checkEnvEffort(),
3022
+ ...checkEnvEffort(proj),
2960
3023
  ...checkGitignore(proj),
3024
+ ...checkHostIgnored(proj, { env: opts.env }),
2961
3025
  ...checkTrackedRuntime(proj),
2962
3026
  ...checkVaultGit(cfg),
2963
3027
  ...checkAutoUpdate(),
@@ -3042,7 +3106,7 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
3042
3106
  restamp: findings.some((f) => f.level === "info" && f.check === "upgrade"),
3043
3107
  }),
3044
3108
  () => checkEnvModel(),
3045
- () => checkEnvEffort(),
3109
+ () => checkEnvEffort(proj),
3046
3110
  ];
3047
3111
  for (const step of steps) {
3048
3112
  if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings: foldIntoMove(findings) };
@@ -302,9 +302,7 @@ export function hostPresence(manifest, { env = process.env, home = homedir() } =
302
302
  x.cli.bin,
303
303
  )?.[1] || null;
304
304
  const bin = s ? s.cli.bin : null;
305
- const r = manifest?.runtime || {};
306
- const fromEnv = r.home_env && env ? env[r.home_env] : null;
307
- const homeDir = fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
305
+ const homeDir = harnessHome(manifest, { env, home });
308
306
  let present = false;
309
307
  if (homeDir) {
310
308
  try {
@@ -314,6 +312,15 @@ export function hostPresence(manifest, { env = process.env, home = homedir() } =
314
312
  return { cli: bin ? whichOnPath(bin, env || {}) : null, home: present ? homeDir : null, bin, homeDir };
315
313
  }
316
314
 
315
+ // A harness's own home directory, as the harness resolves it: its home
316
+ // variable (runtime.home_env) when set, else home_default under the home
317
+ // directory, else null. Whether it exists is the caller's question.
318
+ export function harnessHome(manifest, { env = process.env, home = homedir() } = {}) {
319
+ const r = manifest?.runtime || {};
320
+ const fromEnv = r.home_env && env ? env[r.home_env] : null;
321
+ return fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
322
+ }
323
+
317
324
  // The agents block of a RUN (the story "One run plans the agents block once,
318
325
  // and a bare uninstall selects every harness the project uses", rules 1–2):
319
326
  // the files every manifest of the run names for it, in the FIRST manifest's
@@ -703,6 +710,9 @@ export function cachePaths({ env = process.env, home = homedir() } = {}) {
703
710
  fetch: root ? join(root, "fetch") : null,
704
711
  fetchRun: (id) => (root ? join(root, "fetch", id) : null),
705
712
  registry: root ? join(root, "registry.json") : null,
713
+ // A harness's model list, as its last live read answered (the ADR "Agent
714
+ // model lists are read from each harness's own catalog…", decision 4).
715
+ models: (id) => (root && id ? join(root, "models", `${id}.json`) : null),
706
716
  from: fromEnv ? variable : "the home directory",
707
717
  variable,
708
718
  };
@@ -2630,11 +2630,40 @@ export function plan(
2630
2630
  placeBlock();
2631
2631
  for (const item of moved.last) out.items.push({ harness: layoutHarness.id, ...item });
2632
2632
  out.items.push(...marks);
2633
+ oneAddPerFile(out.items);
2633
2634
  if (out.items.some((i) => i.action === "refuse")) out.ok = false;
2634
2635
  if (out.refusals.length) out.ok = false;
2635
2636
  return out;
2636
2637
  }
2637
2638
 
2639
+ // One add per file (the presentation spec, contract 7 as amended 2026-10-14):
2640
+ // within one plan, a file an earlier item creates is added once. A later
2641
+ // `create` or `add` on that path changes the file the run has just made, so it
2642
+ // reads `update`; its state, steps and detail stay. Each item is planned
2643
+ // against the tree before the run, so the binding the vault item writes was
2644
+ // "absent" to the status-line flag and the channel mark, and all three read
2645
+ // "add". A creator is an earlier `create` or `add` item that is neither a
2646
+ // registration (its host-owned files are the host CLI's) nor a binding row (it
2647
+ // never makes the binding: apply skips it on an unbound project, and the vault
2648
+ // item or the layout move makes the file), counted for the paths absent at
2649
+ // plan time: its own and each step's `path` — never a move step's `to`, so a
2650
+ // migrating project's binding rows keep `add`. An entry row on a file that was
2651
+ // there keeps `add`.
2652
+ function oneAddPerFile(items) {
2653
+ const created = new Set();
2654
+ const writes = (i) => i.action === "create" || i.action === "add";
2655
+ for (const item of items) {
2656
+ if (!writes(item) || item.kind === "registration") continue;
2657
+ if (item.path && created.has(resolve(item.path))) {
2658
+ item.action = "update";
2659
+ continue;
2660
+ }
2661
+ if (item.kind === "binding") continue;
2662
+ for (const p of [item.path, ...(item.steps || []).map((s) => s.path)])
2663
+ if (p && !existsSync(p)) created.add(resolve(p));
2664
+ }
2665
+ }
2666
+
2638
2667
  // ─── the layout migration (the layout ADR; layout spec contract 6) ──────
2639
2668
  //
2640
2669
  // Two items, kind `layout`. The FIRST moves what only we read — the legacy
@@ -196,9 +196,11 @@ export { adoptHookInput, resetHookInput };
196
196
  // ─── Harness overlays (the layout ADR decision 3; layout spec contracts 2–4) ──
197
197
  //
198
198
  // <project>/.projectstore/harness/<id>.json carries ONE thing: the agents block
199
- // for that harness — agents.default.model and agents.per_agent.<name>.model.
200
- // Every other key is ignored on read and named for doctor (rejected). The
201
- // binding never carries agents: ADR-008's two-term chain is read from here.
199
+ // for that harness — agents.default.model, agents.default.effort,
200
+ // agents.per_agent.<name>.model and agents.per_agent.<name>.effort (the effort
201
+ // ADR, decision 1). Every other key is ignored on read and named for doctor
202
+ // (rejected). The binding never carries agents: ADR-008's two-term chain is
203
+ // read from here, once per field.
202
204
  export function readOverlayAt(projectDir, id = overlayId()) {
203
205
  // No id (no manifest at all): nothing is read, and the writer below refuses
204
206
  // the same id rather than inventing a file name for it.
@@ -209,6 +211,7 @@ export function readOverlayAt(projectDir, id = overlayId()) {
209
211
  present: Boolean(path) && existsSync(path),
210
212
  unparseable: false,
211
213
  agents: { default: null, per_agent: {} },
214
+ efforts: { default: null, per_agent: {} },
212
215
  rejected: [],
213
216
  raw: null,
214
217
  };
@@ -235,8 +238,10 @@ export function readOverlayAt(projectDir, id = overlayId()) {
235
238
  for (const k of Object.keys(a)) if (k !== "default" && k !== "per_agent") out.rejected.push(`agents.${k}`);
236
239
  if (a.default !== undefined) {
237
240
  if (a.default && typeof a.default === "object") {
238
- for (const k of Object.keys(a.default)) if (k !== "model") out.rejected.push(`agents.default.${k}`);
241
+ for (const k of Object.keys(a.default))
242
+ if (k !== "model" && k !== "effort") out.rejected.push(`agents.default.${k}`);
239
243
  if (typeof a.default.model === "string" && a.default.model) out.agents.default = a.default.model;
244
+ if (typeof a.default.effort === "string" && a.default.effort) out.efforts.default = a.default.effort;
240
245
  } else out.rejected.push("agents.default");
241
246
  }
242
247
  if (a.per_agent !== undefined) {
@@ -246,8 +251,10 @@ export function readOverlayAt(projectDir, id = overlayId()) {
246
251
  out.rejected.push(`agents.per_agent.${name}`);
247
252
  continue;
248
253
  }
249
- for (const k of Object.keys(v)) if (k !== "model") out.rejected.push(`agents.per_agent.${name}.${k}`);
254
+ for (const k of Object.keys(v))
255
+ if (k !== "model" && k !== "effort") out.rejected.push(`agents.per_agent.${name}.${k}`);
250
256
  if (typeof v.model === "string" && v.model) out.agents.per_agent[name] = v.model;
257
+ if (typeof v.effort === "string" && v.effort) out.efforts.per_agent[name] = v.effort;
251
258
  }
252
259
  } else out.rejected.push("agents.per_agent");
253
260
  }
@@ -264,9 +271,22 @@ export function resolveAgentModel(projectDir, name, { harness = overlayId() } =
264
271
  return { name, model: null, source: null, overlay: o.path, harness };
265
272
  }
266
273
 
274
+ // The same two terms for effort, on their own (the effort ADR, decision 1): an
275
+ // agent's own model does not stop it taking the default's effort. Null passes
276
+ // nothing: the frontmatter's effort, or the harness's own default, runs.
277
+ export function resolveAgentEffort(projectDir, name, { harness = overlayId() } = {}) {
278
+ const o = readOverlayAt(projectDir, harness);
279
+ const per = o.efforts.per_agent[name];
280
+ if (per) return { name, effort: per, source: "per_agent", overlay: o.path, harness };
281
+ if (o.efforts.default) return { name, effort: o.efforts.default, source: "default", overlay: o.path, harness };
282
+ return { name, effort: null, source: null, overlay: o.path, harness };
283
+ }
284
+
267
285
  // The one writer of an overlay: rewrites its agents block, keeps any other key
268
286
  // as it found it (doctor names them; this never silently drops a user's key).
269
- export function writeOverlayAt(projectDir, id, agents) {
287
+ // `efforts` left out keeps the efforts the file holds, so a caller that
288
+ // changes models only never drops one.
289
+ export function writeOverlayAt(projectDir, id, agents, efforts) {
270
290
  if (!id) throw new Error("writeOverlayAt: the overlay's harness id is required");
271
291
  const path = layoutPaths(projectDir).overlay(id);
272
292
  let raw = {};
@@ -274,10 +294,13 @@ export function writeOverlayAt(projectDir, id, agents) {
274
294
  raw = JSON.parse(readFileSync(path, "utf8"));
275
295
  } catch {}
276
296
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) raw = {};
297
+ const e = efforts === undefined ? readOverlayAt(projectDir, id).efforts : efforts;
298
+ const pair = (model, effort) => ({ ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
277
299
  const block = {};
278
- if (agents.default) block.default = { model: agents.default };
279
- const names = Object.keys(agents.per_agent || {}).sort();
280
- if (names.length) block.per_agent = Object.fromEntries(names.map((n) => [n, { model: agents.per_agent[n] }]));
300
+ if (agents.default || e.default) block.default = pair(agents.default, e.default);
301
+ const names = [...new Set([...Object.keys(agents.per_agent || {}), ...Object.keys(e.per_agent || {})])].sort();
302
+ if (names.length)
303
+ block.per_agent = Object.fromEntries(names.map((n) => [n, pair(agents.per_agent?.[n], e.per_agent?.[n])]));
281
304
  const next = { ...raw };
282
305
  if (Object.keys(block).length) next.agents = block;
283
306
  else delete next.agents;