projectstore-claude 0.30.2 → 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 (28) 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 +3 -3
  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 +40 -1
  15. package/node_modules/projectstore/harnesses/codex.json +50 -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 +133 -6
  21. package/node_modules/projectstore/scripts/configure.mjs +156 -19
  22. package/node_modules/projectstore/scripts/doctor.mjs +95 -31
  23. package/node_modules/projectstore/scripts/install-harness.mjs +29 -0
  24. package/node_modules/projectstore/scripts/lib.mjs +32 -9
  25. package/node_modules/projectstore/scripts/menu.mjs +9 -5
  26. package/node_modules/projectstore/scripts/models.mjs +150 -6
  27. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +7 -5
  28. package/package.json +2 -2
@@ -102,7 +102,7 @@ function skill(name, description, body, { runtime = true } = {}) {
102
102
  const COMMAND_OVERRIDES = {
103
103
  agents: {
104
104
  description:
105
- "Register or remove ProjectStore's shared agents block, inspect its Codex model overlay, or configure per-role models. Arguments: <register | unregister | status | configure>.",
105
+ "Register or remove ProjectStore's shared agents block, inspect its Codex model overlay, or configure per-role models and efforts. Arguments: <register | unregister | status | configure>.",
106
106
  body: `You are managing ProjectStore's Codex agent integration. Require a bound project.
107
107
 
108
108
  ## register / unregister
@@ -117,19 +117,22 @@ report the envelope's result. Never edit the managed block by hand.
117
117
 
118
118
  Run \`plan --json --harness codex --surface agents_block --project "$PWD"\`,
119
119
  then \`agents show --json --project "$PWD"\`. Report the block state and each
120
- role's resolved model and source. The active overlay path returned by the core
121
- is authoritative.
120
+ role's resolved model and effort, each with its source. The active overlay path
121
+ returned by the core is authoritative.
122
122
 
123
123
  ## configure
124
124
 
125
- Ask for a default model and optional per-role model ids. Use actual Codex model
126
- ids supplied by the user or visible in the current host; do not translate model
127
- names from another harness. Preview the exact argv, ask for approval, then run
128
- \`agents configure --harness codex [--default <model>] [--agent
129
- <role>=<model> ...] --project "$PWD"\`. The core is the only writer. Do not pass
130
- an effort override: per-role effort is outside this integration's current
131
- contract. A configured model is consumed by the role-orchestration skills on
132
- their next spawn; no restart is needed.`,
125
+ Ask for a default model and optional per-role model ids, then a default effort
126
+ and optional per-role efforts. Use actual Codex model ids and levels supplied by
127
+ the user or visible in the current host; do not translate model names from
128
+ another harness. Preview the exact argv, ask for approval, then run
129
+ \`configure --set agents.default=<model> --set agents.default.effort=<level>
130
+ [--set agents.<role>=<model>] [--set agents.<role>.effort=<level>] ... --harness
131
+ codex --project "$PWD"\`, after the same command with \`--dry-run\` showed its
132
+ plan. \`-\` as a value clears one. The core is the only writer; a level the
133
+ role's model does not list is written with a note in the plan. A configured
134
+ model and effort are consumed by the role-orchestration skills on their next
135
+ spawn; no restart is needed.`,
133
136
  },
134
137
  bind: {
135
138
  description:
@@ -232,13 +235,25 @@ function renderPassive(file) {
232
235
  return [join("skills", name, "SKILL.md"), skill(name, data.description || name, body)];
233
236
  }
234
237
 
238
+ // The spawn's effort parameter is the target manifest's vocabulary: a
239
+ // manifest without it cannot render a role that passes one, and says so
240
+ // rather than guessing a name (as the clerk pin refuses a literal model).
241
+ function effortParam() {
242
+ const p = TARGET.agent_translation?.effort?.spawn_param;
243
+ if (!p)
244
+ throw new Error(
245
+ `${TARGET.id}: agent_translation.effort.spawn_param is missing; a role skill cannot name the effort`,
246
+ );
247
+ return p;
248
+ }
249
+
235
250
  function renderRole(file) {
236
251
  const { data, body } = frontmatter(read(file), file);
237
252
  const role = file.split("/").pop().replace(/\.md$/, "");
238
253
  const name = renderedName("agents", role);
239
- const orchestration = `## Codex orchestration\n\nThis is a role-orchestration skill, not a native agent registration. Resolve the\nrole model by running:\n\n\`\`\`bash\nnode "\${PROJECTSTORE_CORE_ROOT}/bin/projectstore.mjs" agents model ${role} --json --project "$PWD"\n\`\`\`\n\nSpawn a collaboration agent for the bounded task. If the result names a model,\npass that model and use an empty or bounded context fork; otherwise inherit the\ncurrent model. Do not pass a reasoning-effort override: per-role effort belongs\nto a separate accepted story. Give the spawned agent the role contract below\nand the exact artifact/diff it must inspect. Wait for its final result.\n\n## Role contract\n\n`;
254
+ const orchestration = `## Codex orchestration\n\nThis is a role-orchestration skill, not a native agent registration. Resolve the\nrole's model and effort by running:\n\n\`\`\`bash\nnode "\${PROJECTSTORE_CORE_ROOT}/bin/projectstore.mjs" agents model ${role} --json --project "$PWD"\n\`\`\`\n\nSpawn a collaboration agent for the bounded task. If the result names a model,\npass that model and use an empty or bounded context fork; otherwise inherit the\ncurrent model. If the result names an effort, pass it as \`${effortParam()}\`,\nwith or without a model; otherwise pass none. Give the spawned agent the role\ncontract below and the exact artifact/diff it must inspect. Wait for its final\nresult.\n\n## Role contract\n\n`;
240
255
  const description = (data.description || `Run the ProjectStore ${role} role in a fresh collaboration agent.`).replace(
241
- /^(?:Opus|Sonnet) \(max-effort\)\s+/,
256
+ /^(?:Opus|Sonnet) \([a-z]+-effort\)\s+/,
242
257
  "",
243
258
  );
244
259
  return [join("skills", name, "SKILL.md"), skill(name, description, orchestration + body)];
@@ -71,6 +71,7 @@ import {
71
71
  readConfigAt,
72
72
  readOverlayAt,
73
73
  resolveAgentModel,
74
+ resolveAgentEffort,
74
75
  overlayId,
75
76
  layoutRoster,
76
77
  commandForm,
@@ -1407,8 +1408,9 @@ async function runScaffold(ctx) {
1407
1408
  return emit(true, scaffoldResult(plan, done), renderScaffoldPlan(plan, done, { env }), 0);
1408
1409
  }
1409
1410
 
1410
- // agents model <name> — the model a plugin surface passes for that agent, from
1411
- // the active harness's overlay (ADR-008's two terms); agents show — the overlay
1411
+ // agents model <name> — the model and effort a plugin surface passes for that
1412
+ // agent, from the active harness's overlay (ADR-008's two terms, once per
1413
+ // field; the effort ADR, decision 2); agents show — the overlay
1412
1414
  // as read, with the keys the allowlist rejected; agents configure — the one
1413
1415
  // writer, behind the same gate as install: naming --harness is the
1414
1416
  // confirmation, a bare non-TTY call refuses (layout spec, contracts 2–4).
@@ -1439,11 +1441,15 @@ async function runAgents(ctx) {
1439
1441
  const name = positionals[1];
1440
1442
  if (!name) return usage("agents model takes the agent's bare name (critic, planner, …)");
1441
1443
  const r = resolveAgentModel(project, name, { harness });
1444
+ const e = resolveAgentEffort(project, name, { harness });
1445
+ // The effort clause only when one resolves, so the line reads as it did
1446
+ // for an overlay of models alone.
1447
+ const effortText = e.effort ? `; effort ${e.effort} (${e.source})` : "";
1442
1448
  return emit(
1443
1449
  "agents model",
1444
1450
  true,
1445
- r,
1446
- `${name}: ${r.model ? `${r.model} (${r.source}, ${r.overlay})` : "no model configured — the agent's frontmatter decides"}\n`,
1451
+ { ...r, effort: e.effort, effort_source: e.source },
1452
+ `${name}: ${r.model ? `${r.model} (${r.source}, ${r.overlay})` : "no model configured — the agent's frontmatter decides"}${effortText}\n`,
1447
1453
  );
1448
1454
  }
1449
1455
  const before = readOverlayAt(project, harness);
@@ -1451,22 +1457,28 @@ async function runAgents(ctx) {
1451
1457
  if (sub === "show") {
1452
1458
  // Resolved per roster agent (per configured name when no layout loads), so
1453
1459
  // a reader reports what would run without restating the two-term rule.
1454
- const names = [...new Set([...(roster || []), ...Object.keys(before.agents.per_agent)])].sort();
1460
+ const configured = [
1461
+ ...new Set([...Object.keys(before.agents.per_agent), ...Object.keys(before.efforts.per_agent)]),
1462
+ ];
1463
+ const names = [...new Set([...(roster || []), ...configured])].sort();
1455
1464
  const resolved = Object.fromEntries(
1456
1465
  names.map((n) => {
1457
1466
  const r = resolveAgentModel(project, n, { harness });
1458
- return [n, { model: r.model, source: r.source }];
1467
+ const e = resolveAgentEffort(project, n, { harness });
1468
+ return [n, { model: r.model, source: r.source, effort: e.effort, effort_source: e.source }];
1459
1469
  }),
1460
1470
  );
1461
- const unknown = roster ? Object.keys(before.agents.per_agent).filter((n) => !roster.includes(n)) : [];
1471
+ const unknown = roster ? configured.filter((n) => !roster.includes(n)) : [];
1462
1472
  const lines = [
1463
1473
  `overlay: ${before.path || "(none: no harness detected and none named)"}${before.path && !before.present ? " (absent)" : ""}`,
1464
1474
  ];
1465
1475
  if (before.unparseable) lines.push(" not valid JSON");
1466
1476
  if (before.agents.default) lines.push(` default: ${before.agents.default}`);
1477
+ if (before.efforts.default) lines.push(` default effort: ${before.efforts.default}`);
1478
+ const effortOf = (n) => (resolved[n].effort ? `, effort ${resolved[n].effort} (${resolved[n].effort_source})` : "");
1467
1479
  for (const n of names)
1468
1480
  lines.push(
1469
- ` ${n}: ${resolved[n].model ? `${resolved[n].model} (${resolved[n].source})` : "— (the agent's frontmatter)"}${unknown.includes(n) ? " — not in the roster: nothing runs under this name" : ""}`,
1481
+ ` ${n}: ${resolved[n].model ? `${resolved[n].model} (${resolved[n].source})` : "— (the agent's frontmatter)"}${effortOf(n)}${unknown.includes(n) ? " — not in the roster: nothing runs under this name" : ""}`,
1470
1482
  );
1471
1483
  if (before.rejected.length) lines.push(` ignored (not an overlay key): ${before.rejected.join(", ")}`);
1472
1484
  if (inBinding) lines.push(" the binding still carries an agents block — a pre-0.28 leftover; run upgrade");
@@ -1479,6 +1491,7 @@ async function runAgents(ctx) {
1479
1491
  present: before.present,
1480
1492
  unparseable: before.unparseable,
1481
1493
  agents: before.agents,
1494
+ efforts: before.efforts,
1482
1495
  resolved,
1483
1496
  roster,
1484
1497
  unknown,
@@ -1535,7 +1548,16 @@ async function runAgents(ctx) {
1535
1548
  { harness, path: before.path, wrote: false, declined: true, agents: next, dropped },
1536
1549
  preview.join("\n") + "\n nothing written.\n",
1537
1550
  );
1538
- const { path, after } = applyAgentsChange(project, harness, next);
1551
+ const { path, after } = applyAgentsChange(project, harness, next, change.nextEfforts);
1552
+ // Efforts join the envelope only where the overlay holds one, so a run over
1553
+ // models alone answers exactly as it did before efforts existed.
1554
+ const withEfforts =
1555
+ after.efforts.default ||
1556
+ Object.keys(after.efforts.per_agent).length ||
1557
+ before.efforts.default ||
1558
+ Object.keys(before.efforts.per_agent).length
1559
+ ? { efforts: after.efforts, pinnedClerkEffort: change.pinnedClerkEffort }
1560
+ : {};
1539
1561
  return emit(
1540
1562
  "agents configure",
1541
1563
  true,
@@ -1544,6 +1566,7 @@ async function runAgents(ctx) {
1544
1566
  path,
1545
1567
  wrote: true,
1546
1568
  agents: after.agents,
1569
+ ...withEfforts,
1547
1570
  pinnedClerk,
1548
1571
  clerkPinUnavailable,
1549
1572
  dropped,
@@ -16,12 +16,15 @@
16
16
 
17
17
  import * as term from "./term.mjs";
18
18
  import { createMenu, renderMenu, seg, SMALLEST } from "./menu.mjs";
19
- import { knownModels, readLive } from "./models.mjs";
19
+ import { knownModels, readLive, effortSpec, effortLevels } from "./models.mjs";
20
20
  import { loadHarness } from "./harness.mjs";
21
21
  import { readOverlayAt } from "./lib.mjs";
22
22
 
23
23
  const CLEAR = "-";
24
24
 
25
+ // What an effort row sets: the settings panel's first sentence for it.
26
+ const EFFORT = "How long the agent thinks before it answers: a higher level is slower and costs more.";
27
+
25
28
  // What each agent of the roster does: the settings panel's first sentence.
26
29
  const ROLE = Object.freeze({
27
30
  default: "The model for every agent that has none of its own.",
@@ -264,15 +267,17 @@ function agentsGroup(f, id, list, catalogs, { menu, now, caps: c }) {
264
267
  return d ? (menu?.() ? menu().eff(d) : d.value) : null;
265
268
  };
266
269
  for (const s of list) {
267
- const name = s.key.slice(7);
270
+ const isEffort = s.rail === "effort";
271
+ const name = isEffort ? s.key.slice(7, -7) : s.key.slice(7);
268
272
  const isDefault = name === "default";
269
273
  if (s.source === "unparseable") {
270
274
  items.push({
271
275
  id: `${id}:${s.key}`,
272
276
  key: s.key,
273
277
  harness: id,
274
- label: name,
275
- title: `${display} · ${name}`,
278
+ label: isEffort ? "effort" : name,
279
+ ...(isEffort ? { indent: 2, path: `${name} · effort` } : {}),
280
+ title: `${display} · ${name}${isEffort ? " · effort" : ""}`,
276
281
  kind: "info",
277
282
  value: null,
278
283
  tag: SOURCE.unparseable,
@@ -280,6 +285,10 @@ function agentsGroup(f, id, list, catalogs, { menu, now, caps: c }) {
280
285
  });
281
286
  continue;
282
287
  }
288
+ if (isEffort) {
289
+ items.push(effortItem(h, display, id, s, name, isDefault, o, items, catalogs, { defaultNow, menu, now }));
290
+ continue;
291
+ }
283
292
  const item = {
284
293
  id: `${id}:${s.key}`,
285
294
  key: s.key,
@@ -314,11 +323,127 @@ function agentsGroup(f, id, list, catalogs, { menu, now, caps: c }) {
314
323
  return {
315
324
  title: `Agents · ${display}`,
316
325
  footer: file.split("/").pop(),
317
- description: `Which model each projectstore agent runs on in ${display}${h?.agent_translation?.models?.spawn_takes === "id" ? `, in ${display}'s own model names` : ""}. A model set here goes to ${file}, which is committed, so it applies to everyone on this project. default covers every agent without a model of its own.`,
326
+ description: `Which model each projectstore agent runs on in ${display}${h?.agent_translation?.models?.spawn_takes === "id" ? `, in ${display}'s own model names` : ""}, and at what effort. A model or an effort set here goes to ${file}, which is committed, so it applies to everyone on this project. default covers every agent without one of its own.`,
318
327
  items,
319
328
  };
320
329
  }
321
330
 
331
+ // An agent's effort row (the effort ADR, decision 4): under its model row,
332
+ // its value its own level, else the default's, else what the harness does
333
+ // with none; its values the levels of the model it will run on, a model
334
+ // change waiting in the menu counted.
335
+ function effortItem(h, display, id, s, name, isDefault, o, items, catalogs, { defaultNow, menu, now }) {
336
+ const es = effortSpec(h);
337
+ const own = isDefault ? o.efforts.default || null : o.efforts.per_agent[name] || null;
338
+ const eff = (it) => (it ? (menu?.() ? menu().eff(it) : it.value) : null);
339
+ const modelItem = items.find((x) => x.key === (isDefault ? "agents.default" : `agents.${name}`));
340
+ const defaultEffortItem = () => items.find((x) => x.key === "agents.default.effort");
341
+ // The model this agent will run on: its own, else the default's.
342
+ const runModel = () => eff(modelItem) || (isDefault ? null : defaultNow()) || null;
343
+ const defaultEffortNow = () => (isDefault ? null : eff(defaultEffortItem()));
344
+ const levelsNow = () => effortLevels(h, runModel(), catalogs[id]?.known || null);
345
+ // What runs when nothing is configured: the frontmatter's level, or, where
346
+ // a passed model's own default runs, that level when its list names it.
347
+ const unsetText = () => {
348
+ const m = runModel();
349
+ const d = es.unset.model_default && m ? levelsNow()?.default : null;
350
+ return d ? `${d} (${m}'s default)` : es.unset.value;
351
+ };
352
+ const unsetMeaning = es.unset.model_default
353
+ ? "each agent runs at its model's own default level"
354
+ : "each agent runs at its frontmatter's effort";
355
+ const item = {
356
+ id: `${id}:${s.key}`,
357
+ key: s.key,
358
+ harness: id,
359
+ label: "effort",
360
+ indent: 2,
361
+ path: `${name} · effort`,
362
+ title: `${display} · ${name} · effort`,
363
+ kind: "choice",
364
+ value: own,
365
+ tag: own ? (isDefault ? SOURCE.overlay : SOURCE.per_agent) : isDefault ? SOURCE.default : "",
366
+ display: (v) => {
367
+ if (v) return v;
368
+ const d = defaultEffortNow();
369
+ return d ? `${d} (default)` : unsetText();
370
+ },
371
+ };
372
+ item.describe = (v, changed) => {
373
+ if (changed) return `${EFFORT} In the file: ${item.display(item.value)}. After the change: ${item.display(v)}.`;
374
+ if (isDefault)
375
+ return `${EFFORT} ${v ? `Now: ${v}, for every agent without its own.` : `Now: not set, so ${unsetMeaning}.`}`;
376
+ if (v) return `${EFFORT} Now: ${v}, its own.`;
377
+ const d = defaultEffortNow();
378
+ return `${EFFORT} ${d ? `Now: ${d}, from default.` : `Now: ${item.display(null)}.`}`;
379
+ };
380
+ item.options = () =>
381
+ effortOptions(es, display, item, name, isDefault, levelsNow(), runModel(), defaultEffortNow(), menu);
382
+ item.caption = () => {
383
+ const l = levelsNow();
384
+ if (!l) return null;
385
+ if (l.from === "catalog")
386
+ return {
387
+ text: `levels of ${l.model}${l.source ? ` · ${l.source}` : ""}${l.at ? ` · checked ${checkedWhen(l.at, now())}` : ""}`,
388
+ };
389
+ return { text: `${display}'s levels` };
390
+ };
391
+ return item;
392
+ }
393
+
394
+ // An effort row's values: the levels, the value in the file when the list
395
+ // lacks it, then the way back to the default. No free text and no refresh:
396
+ // the levels come with the model list, whose frame refreshes it.
397
+ function effortOptions(es, display, item, name, isDefault, levels, model, d, menu) {
398
+ const opts = (levels?.levels || []).map((l, i) => ({
399
+ value: l.id,
400
+ label: l.id,
401
+ kind: "value",
402
+ note: l.id === levels.default ? "default" : "",
403
+ section: i === 0 ? (levels.from === "catalog" ? `levels of ${model}` : `${display}'s levels`) : undefined,
404
+ description: [
405
+ l.description ? (/[.!?]$/.test(l.description) ? l.description : `${l.description}.`) : "",
406
+ l.id === levels.default ? `${model}'s own default.` : "",
407
+ ]
408
+ .filter(Boolean)
409
+ .join(" "),
410
+ }));
411
+ const m = menu?.();
412
+ for (const v of [item.value, m ? m.eff(item) : null])
413
+ if (v && !opts.some((o) => o.value === v))
414
+ opts.push({
415
+ value: v,
416
+ label: v,
417
+ note: "not in the list",
418
+ kind: "value",
419
+ section: opts.length ? undefined : "",
420
+ description: model
421
+ ? `Not a level ${model} lists. It is kept as it is.`
422
+ : `Not one of ${display}'s levels. It is kept as it is.`,
423
+ });
424
+ const meaning = es.unset.model_default ? "its model's own default level" : "its frontmatter's effort";
425
+ opts.push(
426
+ isDefault
427
+ ? {
428
+ value: null,
429
+ label: es.unset.row,
430
+ kind: "value",
431
+ section: "",
432
+ description: `Drops the default effort: each agent without its own runs at ${meaning}.`,
433
+ }
434
+ : {
435
+ value: null,
436
+ label: "use the default",
437
+ kind: "value",
438
+ section: "",
439
+ description: d
440
+ ? `Drops ${name}'s own effort: it runs at the default, ${d}.`
441
+ : `Drops ${name}'s own effort: it runs at ${meaning}.`,
442
+ },
443
+ );
444
+ return opts;
445
+ }
446
+
322
447
  // The values frame's footer: the list's source and age, the host being
323
448
  // asked, or why nothing can be asked (the ADR, decisions 3 and 6).
324
449
  function caption(cat, now) {
@@ -502,8 +627,10 @@ export async function runMenu(ctx) {
502
627
  menu = createMenu({
503
628
  groups,
504
629
  onRefresh: (it) => it.harness && read(it.harness),
630
+ // A list is read from a model's frame only: an effort frame shows what is
631
+ // known, and its levels follow when a model's list is refreshed.
505
632
  onOpen: (it) => {
506
- const cat = it.harness ? catalogs[it.harness] : null;
633
+ const cat = it.harness && !it.key?.endsWith(".effort") ? catalogs[it.harness] : null;
507
634
  if (cat?.known?.stale) read(it.harness);
508
635
  },
509
636
  });
@@ -22,6 +22,7 @@ import {
22
22
  import {
23
23
  readOverlayAt,
24
24
  resolveAgentModel,
25
+ resolveAgentEffort,
25
26
  layoutRoster,
26
27
  readVaultConfigStrict,
27
28
  nextVaultConfig,
@@ -35,7 +36,15 @@ import { bandKeys, epicIds, planBand, applyBand } from "./band.mjs";
35
36
  import { planAgentsChange, applyAgentsChange } from "./agents-change.mjs";
36
37
  import { plan as planInstall, apply as applyInstall, planOptions, isInteractive } from "./install-harness.mjs";
37
38
  import { analyseLayout } from "./surfaces.mjs";
38
- import { knownModels, offListNote } from "./models.mjs";
39
+ import {
40
+ knownModels,
41
+ offListNote,
42
+ effortSpec,
43
+ effortLevels,
44
+ offLevelNote,
45
+ standingLevelNote,
46
+ LEVEL,
47
+ } from "./models.mjs";
39
48
  import { runMenu } from "./configure-menu.mjs";
40
49
  import { SMALLEST } from "./menu.mjs";
41
50
 
@@ -76,6 +85,7 @@ export function readFacts(project, { env = process.env, root, home = homedir() }
76
85
  return {
77
86
  project,
78
87
  home,
88
+ env,
79
89
  path,
80
90
  cfg,
81
91
  used,
@@ -108,6 +118,29 @@ export function settingsOf(f) {
108
118
  const o = readOverlayAt(f.project, h.id);
109
119
  const file = where(f, o.path);
110
120
  const base = { group: `agents:${h.id}`, harness: h.id, file, class: "changeable", team: true, writer: "overlay" };
121
+ // Each model setting is followed by its effort setting, where the harness
122
+ // passes one at spawn (the effort ADR, decisions 1 and 4). An effort that
123
+ // nothing configures reads as the harness says: the frontmatter's, or the
124
+ // model's own default level, named when its catalog entry is known.
125
+ const es = effortSpec(h);
126
+ const known = es?.unset?.model_default ? knownModels(h, { env: f.env, home: f.home }) : null;
127
+ // frontmatter: the agent's own; model_default: the passed model's own
128
+ // default, named; harness: the harness's own precedence (no model passed,
129
+ // or none whose default is known).
130
+ const unsetEffort = (model) => {
131
+ if (!es?.unset?.model_default) return { source: "frontmatter" };
132
+ const d = model ? effortLevels(h, model, known)?.default : null;
133
+ return d ? { source: "model_default", model_default: d } : { source: "harness" };
134
+ };
135
+ const effortRow = (key, effort, effortSource, model) =>
136
+ es &&
137
+ out.push({
138
+ ...base,
139
+ key,
140
+ value: effort || null,
141
+ ...(o.unparseable ? { source: "unparseable" } : effort ? { source: effortSource } : unsetEffort(model)),
142
+ rail: "effort",
143
+ });
111
144
  out.push({
112
145
  ...base,
113
146
  key: "agents.default",
@@ -115,7 +148,10 @@ export function settingsOf(f) {
115
148
  source: o.unparseable ? "unparseable" : o.agents.default ? "overlay" : "frontmatter",
116
149
  rail: "model",
117
150
  });
118
- const names = [...new Set([...(f.roster || []), ...Object.keys(o.agents.per_agent)])];
151
+ effortRow("agents.default.effort", o.efforts.default, "overlay", o.agents.default);
152
+ const names = [
153
+ ...new Set([...(f.roster || []), ...Object.keys(o.agents.per_agent), ...Object.keys(o.efforts.per_agent)]),
154
+ ];
119
155
  for (const n of names) {
120
156
  const r = resolveAgentModel(f.project, n, { harness: h.id });
121
157
  out.push({
@@ -125,6 +161,8 @@ export function settingsOf(f) {
125
161
  source: o.unparseable ? "unparseable" : r.source || "frontmatter",
126
162
  rail: "model",
127
163
  });
164
+ const e = resolveAgentEffort(f.project, n, { harness: h.id });
165
+ effortRow(`agents.${n}.effort`, e.effort, e.source, r.model);
128
166
  }
129
167
  }
130
168
  if (f.slot)
@@ -239,11 +277,13 @@ function setForm(s, f) {
239
277
  ? "on|off"
240
278
  : s.key === "tags"
241
279
  ? "<a,b>"
242
- : s.key.startsWith("agents.")
243
- ? "<model>"
244
- : s.key === "band.epic"
245
- ? "<epic>"
246
- : `<${s.key}>`;
280
+ : s.rail === "effort"
281
+ ? "<level>"
282
+ : s.key.startsWith("agents.")
283
+ ? "<model>"
284
+ : s.key === "band.epic"
285
+ ? "<epic>"
286
+ : `<${s.key}>`;
247
287
  const h = s.harness && f.used.length > 1 ? ` --harness ${s.harness}` : "";
248
288
  return `--set ${s.key}=${value}${h}`;
249
289
  }
@@ -266,8 +306,10 @@ export function resolveSets(f, sets, { harness = null, nearest = null } = {}) {
266
306
  const value = String(raw)
267
307
  .slice(at + 1)
268
308
  .trim();
269
- const m = /^agents\.([a-z][a-z0-9-]*)(?:\.model)?$/.exec(key);
270
- if (m) key = `agents.${m[1]}`;
309
+ // agents.<name> and agents.<name>.model are one key; agents.<name>.effort
310
+ // is a key of its own (the effort ADR, decision 4).
311
+ const m = /^agents\.([a-z][a-z0-9-]*)(?:\.(model|effort))?$/.exec(key);
312
+ if (m) key = m[2] === "effort" ? `agents.${m[1]}.effort` : `agents.${m[1]}`;
271
313
  if (seen.has(key)) return { usage: `${key} is named twice` };
272
314
  seen.add(key);
273
315
  if (m) {
@@ -280,6 +322,19 @@ export function resolveSets(f, sets, { harness = null, nearest = null } = {}) {
280
322
  else return { usage: `name the overlay: --harness <id> (this project uses ${harnessesNamed})` };
281
323
  }
282
324
  if (!harnessIds().includes(h)) return { usage: `unknown harness: ${h} — known: ${harnessIds().join(", ")}` };
325
+ if (m[2] === "effort") {
326
+ const manifest = loadHarness(h);
327
+ const es = effortSpec(manifest);
328
+ if (!es) return { usage: `${manifest?.display_name || h} takes no effort at spawn: ${key} has nothing to set` };
329
+ // A lowercase token, or - to clear; a level the harness does not list
330
+ // is written with a note, not refused (decision 5).
331
+ if (value !== CLEAR && !LEVEL.test(value))
332
+ return {
333
+ usage: `${key} takes a level (${es.levels.map((l) => l.id).join(", ")}), or ${CLEAR} to clear it, got ${value || "nothing"}`,
334
+ };
335
+ out.push({ key, writer: "overlay", harness: h, name, field: "effort", value: value === CLEAR ? null : value });
336
+ continue;
337
+ }
283
338
  if (!value) return { usage: `${key} takes a model, or ${CLEAR} to clear it` };
284
339
  out.push({ key, writer: "overlay", harness: h, name, value: value === CLEAR ? null : value });
285
340
  continue;
@@ -434,14 +489,19 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
434
489
  for (const [h, list] of byHarness) {
435
490
  const before = readOverlayAt(f.project, h);
436
491
  if (before.unparseable) return refuse(`${before.path} is not valid JSON — fix or remove it first`);
437
- const def = list.find((c) => c.name === "default");
492
+ const models = list.filter((c) => c.field !== "effort");
493
+ const efforts = list.filter((c) => c.field === "effort");
494
+ const def = models.find((c) => c.name === "default");
495
+ const defEffort = efforts.find((c) => c.name === "default");
438
496
  const change = planAgentsChange({
439
497
  before,
440
498
  harness: h,
441
499
  layout: f.cfg.layout,
442
500
  roster: f.roster,
443
501
  defaultModel: def ? def.value || "" : undefined,
444
- agents: list.filter((c) => c.name !== "default").map((c) => `${c.name}=${c.value || ""}`),
502
+ agents: models.filter((c) => c.name !== "default").map((c) => `${c.name}=${c.value || ""}`),
503
+ defaultEffort: defEffort ? defEffort.value || "" : undefined,
504
+ efforts: efforts.filter((c) => c.name !== "default").map((c) => `${c.name}=${c.value || ""}`),
445
505
  });
446
506
  if (change.usage) return { usage: change.usage };
447
507
  if (change.same) continue;
@@ -452,11 +512,47 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
452
512
  // 8 as amended 2026-10-13): the model may be newer than the list.
453
513
  // Only a model the run names gets the note: the clerk's pin does not.
454
514
  const known = knownModels(manifest, { env, home });
455
- const named = new Map(list.map((c) => [c.name, c.value]));
515
+ const named = new Map(models.map((c) => [c.name, c.value]));
456
516
  const offList = (name, model) => (model && named.get(name) === model ? offListNote(manifest, model, known) : null);
457
517
  const notes = (...xs) => xs.filter(Boolean).join("; ") || null;
518
+ // Effort against the model each agent runs on after the run (the effort
519
+ // ADR, decision 5): a level the run names is checked on its own row; a
520
+ // model the run changes is checked against the level its agents keep, on
521
+ // the model's row; a default level the run names is checked against every
522
+ // agent that inherits it on a model of its own, on the default's row.
523
+ const ne = change.nextEfforts;
524
+ const namedEffort = new Map(efforts.map((c) => [c.name, c.value]));
525
+ const roster = [
526
+ ...new Set([...(f.roster || []), ...Object.keys(change.next.per_agent), ...Object.keys(ne.per_agent)]),
527
+ ];
528
+ const runModel = (n) => (n === "default" ? change.next.default : change.next.per_agent[n] || change.next.default);
529
+ const runEffort = (n) => (n === "default" ? ne.default : ne.per_agent[n] || ne.default) || null;
530
+ const levelNote = (n, level) =>
531
+ level && namedEffort.get(n) === level ? offLevelNote(manifest, level, runModel(n) || null, known) : null;
532
+ const inheritors = (level) =>
533
+ level && namedEffort.get("default") === level
534
+ ? roster
535
+ .filter(
536
+ (n) => !ne.per_agent[n] && change.next.per_agent[n] && change.next.per_agent[n] !== change.next.default,
537
+ )
538
+ .map((n) => standingLevelNote(manifest, n, level, runModel(n), known))
539
+ .filter(Boolean)
540
+ .join("; ") || null
541
+ : null;
542
+ // Agents a model change moves, and the level each keeps, when that level
543
+ // was not named by this run (a named one is checked on its own row).
544
+ const moved = (n) => {
545
+ const who = n === "default" ? ["default", ...roster.filter((r) => !change.next.per_agent[r])] : [n];
546
+ return (
547
+ who
548
+ .filter((a) => !namedEffort.has(a) && !(a !== "default" && !ne.per_agent[a] && namedEffort.has("default")))
549
+ .map((a) => standingLevelNote(manifest, a, runEffort(a), runModel(a) || null, known))
550
+ .filter(Boolean)
551
+ .join("; ") || null
552
+ );
553
+ };
458
554
  if (before.agents.default !== change.next.default) {
459
- const listNote = offList("default", change.next.default);
555
+ const listNote = notes(offList("default", change.next.default), moved("default"));
460
556
  items.push({
461
557
  key: "agents.default",
462
558
  harness: h,
@@ -474,7 +570,7 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
474
570
  const a = before.agents.per_agent[n] || null;
475
571
  const b = change.next.per_agent[n] || null;
476
572
  if (a === b) continue;
477
- const listNote = offList(n, b);
573
+ const listNote = notes(offList(n, b), moved(n));
478
574
  items.push({
479
575
  key: `agents.${n}`,
480
576
  harness: h,
@@ -501,7 +597,43 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
501
597
  team: true,
502
598
  note: `${display} declares no cheap model to pin the clerk to; set agents.clerk to name one`,
503
599
  });
504
- steps.push({ writer: "overlay", harness: h, next: change.next });
600
+ const beforeEfforts = before.efforts;
601
+ if (beforeEfforts.default !== ne.default) {
602
+ const listNote = notes(levelNote("default", ne.default), inheritors(ne.default));
603
+ items.push({
604
+ key: "agents.default.effort",
605
+ harness: h,
606
+ display,
607
+ file,
608
+ before: shown(beforeEfforts.default),
609
+ after: shown(ne.default),
610
+ team: true,
611
+ note: listNote,
612
+ listNote,
613
+ });
614
+ }
615
+ const effortNames = new Set([...Object.keys(beforeEfforts.per_agent), ...Object.keys(ne.per_agent)]);
616
+ for (const n of [...effortNames].sort()) {
617
+ const a = beforeEfforts.per_agent[n] || null;
618
+ const b = ne.per_agent[n] || null;
619
+ if (a === b) continue;
620
+ const listNote = levelNote(n, b);
621
+ items.push({
622
+ key: `agents.${n}.effort`,
623
+ harness: h,
624
+ display,
625
+ file,
626
+ before: shown(a),
627
+ after: shown(b),
628
+ team: true,
629
+ note: notes(
630
+ change.pinnedClerkEffort && n === "clerk" ? "pinned: the clerk stays cheap under a strong default" : null,
631
+ listNote,
632
+ ),
633
+ listNote,
634
+ });
635
+ }
636
+ steps.push({ writer: "overlay", harness: h, next: change.next, nextEfforts: ne });
505
637
  }
506
638
 
507
639
  // The status line: install's own plan for the surface, with its flag item
@@ -602,7 +734,7 @@ export function applyChanges(f, steps, { env = process.env, home = homedir(), ro
602
734
  writeVaultConfig(f.vault, nextVaultConfig(now.cfg, s.patch, new Date().toISOString()));
603
735
  wrote.push(vaultConfigPath(f.vault));
604
736
  } else if (s.writer === "overlay") {
605
- wrote.push(applyAgentsChange(f.project, s.harness, s.next).path);
737
+ wrote.push(applyAgentsChange(f.project, s.harness, s.next, s.nextEfforts).path);
606
738
  } else if (s.writer === "statusline") {
607
739
  const finished = [];
608
740
  try {
@@ -640,6 +772,8 @@ const SOURCE_TEXT = Object.freeze({
640
772
  per_agent: "per agent",
641
773
  default: "default",
642
774
  frontmatter: "its frontmatter",
775
+ model_default: "its model's own default",
776
+ harness: "the harness decides",
643
777
  binding: "binding",
644
778
  vault_config: "vault config",
645
779
  unparseable: "not valid JSON: fix the file to change it",
@@ -675,9 +809,11 @@ export function renderView(f, settings, { caps: c = term.PLAIN } = {}) {
675
809
  note:
676
810
  s.class === "other-verb"
677
811
  ? "changed by another command, below"
678
- : s.class === "changeable" && !s.rail
679
- ? `${SOURCE_TEXT[s.source] || s.source}; change it with ${s.set}`
680
- : SOURCE_TEXT[s.source] || s.source,
812
+ : s.source === "model_default" && s.model_default
813
+ ? `${SOURCE_TEXT.model_default}, ${s.model_default}`
814
+ : s.class === "changeable" && !s.rail
815
+ ? `${SOURCE_TEXT[s.source] || s.source}; change it with ${s.set}`
816
+ : SOURCE_TEXT[s.source] || s.source,
681
817
  })),
682
818
  ),
683
819
  );
@@ -764,6 +900,7 @@ export async function runConfigure(ctx) {
764
900
  harness: s.harness || null,
765
901
  team: s.team,
766
902
  set: s.set,
903
+ ...(s.model_default ? { model_default: s.model_default } : {}),
767
904
  }));
768
905
  const interactive =
769
906
  !json && (ask ? true : isInteractive({ stdin, stdout, env, json })) && term.railAvailable(stdin, c);