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
@@ -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) };
@@ -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;
@@ -12,7 +12,11 @@
12
12
  //
13
13
  // A group is { title, footer, description, items }. An item is
14
14
  // { id, label, kind: "choice" | "text" | "info", toggle, value,
15
- // display(v), tag, describe(v, changed), options(), caption() }
15
+ // display(v), tag, describe(v, changed), options(), caption(),
16
+ // indent, path }
17
+ // where `indent` draws the label that many columns in, under the row it
18
+ // belongs to (an agent's effort under its model), and `path` names the item
19
+ // where its label alone would not: a frame's title and the path line.
16
20
  // where options() answers the values, each
17
21
  // { value, label, note, description, tag, section, kind: "value" | "text" | "refresh" }
18
22
  // and caption() the values frame's footer, { text, busy } or null. A value
@@ -390,13 +394,13 @@ function valueSegs(c, m, it, mode, room) {
390
394
  }
391
395
 
392
396
  function itemRows(c, m, g, cur, mode, w) {
393
- const lw = Math.max(...g.items.map((it) => wlen(it.label))) + 1;
397
+ const lw = Math.max(...g.items.map((it) => (it.indent || 0) + wlen(it.label))) + 1;
394
398
  const room = w - 4 - 3 - lw - 1;
395
399
  return g.items.map((it, k) => [
396
400
  cursorSeg(c, k === cur, mode),
397
401
  S(m.changed(it) ? icon(c, "update") : " ", "action"),
398
402
  S(" "),
399
- S(padTo(G(c, it.label), lw), labelRole(k === cur, mode)),
403
+ S(padTo(" ".repeat(it.indent || 0) + G(c, it.label), lw), labelRole(k === cur, mode)),
400
404
  S(" "),
401
405
  ...valueSegs(c, m, it, mode, room),
402
406
  ]);
@@ -488,7 +492,7 @@ function detailFrame(c, m, it, fr, w, h, focus) {
488
492
  }
489
493
  const cap = focus && it.caption ? it.caption() : null;
490
494
  return frame(c, {
491
- title: it.label,
495
+ title: it.path || it.label,
492
496
  rows,
493
497
  w,
494
498
  h,
@@ -644,7 +648,7 @@ export function renderMenu(m, { caps: c, columns = 100, rows = 30, head = "proje
644
648
  const wideEnough = columns >= STAIRCASE.min;
645
649
  if (!wideEnough) {
646
650
  // One frame: the one the keys act on, under the whole path.
647
- const names = ["Settings", ...(level >= 1 ? [g.title] : []), ...(level >= 2 ? [it.label] : [])];
651
+ const names = ["Settings", ...(level >= 1 ? [g.title] : []), ...(level >= 2 ? [it.path || it.label] : [])];
648
652
  out([
649
653
  S(`${icon(c, "ask")} `, "action"),
650
654
  S(names.slice(0, -1).join(sep) + (names.length > 1 ? sep : ""), "explanation"),
@@ -18,6 +18,18 @@
18
18
  // modelsProblem(manifest) → what is wrong with the block, or null:
19
19
  // the manifest schema test's check
20
20
  //
21
+ // Effort (the ADR "Agent effort is passed at spawn beside the model, from the
22
+ // overlay, in each harness's own levels") reads the same entries: the
23
+ // manifest's agent_translation.effort names the levels its spawn takes, and,
24
+ // where a catalog lists a model's own levels, their field paths.
25
+ //
26
+ // effortSpec(manifest) → the effort block, or null
27
+ // effortLevels(manifest, model, known) → the levels a model takes, from its
28
+ // catalog entry or the manifest's floor
29
+ // offLevelNote(manifest, level, model, known) → the PLAN's note for a level
30
+ // that list lacks, or null (decision 5)
31
+ // effortProblem(manifest) → what is wrong with the block, or null
32
+ //
21
33
  // Local reads are synchronous and cheap: the manifest, a `file` catalog, and
22
34
  // projectstore's own cache. A `command` or `http` catalog is read only by
23
35
  // readLive, which a menu starts in the background. env, home, now, fetch,
@@ -41,6 +53,15 @@ export function modelSpec(manifest) {
41
53
  return m && typeof m === "object" ? m : null;
42
54
  }
43
55
 
56
+ export function effortSpec(manifest) {
57
+ const e = manifest?.agent_translation?.effort;
58
+ return e && typeof e === "object" ? e : null;
59
+ }
60
+
61
+ // A level is a lowercase token: the shape --set accepts and a catalog's
62
+ // level must have to be kept.
63
+ export const LEVEL = /^[a-z][a-z0-9-]*$/;
64
+
44
65
  // One field path, dotted: "data", "models", "fetched_at".
45
66
  const at = (obj, path) =>
46
67
  String(path || "")
@@ -67,9 +88,10 @@ const clean = (s) =>
67
88
  .replace(/[\x00-\x1f\x7f-\x9f]/g, "");
68
89
 
69
90
  // A catalog's answer as rows: { id, label, description, family, hidden },
70
- // in the catalog's order. Entries without an id, and entries `keep_when`
71
- // rejects, are dropped; `shown_when` only hides.
72
- export function entriesOf(body, fields) {
91
+ // in the catalog's order, and `levels` and `default_level` when `levelFields`
92
+ // (the effort block's catalog paths) finds them in an entry. Entries without
93
+ // an id, and entries `keep_when` rejects, are dropped; `shown_when` only hides.
94
+ export function entriesOf(body, fields, levelFields = null) {
73
95
  const list = at(body, fields.list);
74
96
  if (!Array.isArray(list)) return null;
75
97
  const rows = [];
@@ -89,6 +111,7 @@ export function entriesOf(body, fields) {
89
111
  description: text(fields.description),
90
112
  family: text(fields.family),
91
113
  hidden: fields.shown_when ? !matches(e, fields.shown_when) : false,
114
+ ...levelsOf(e, levelFields),
92
115
  order: fields.order ? at(e, fields.order) : k,
93
116
  k,
94
117
  });
@@ -100,7 +123,37 @@ export function entriesOf(body, fields) {
100
123
  if (x === y || x === undefined || y === undefined) return a.k - b.k;
101
124
  return (x < y ? -1 : 1) * (desc ? -1 : 1);
102
125
  });
103
- return rows.map(({ id, label, description, family, hidden }) => ({ id, label, description, family, hidden }));
126
+ return rows.map(({ order: _order, k: _k, ...row }) => row);
127
+ }
128
+
129
+ // One entry's own levels, by the effort block's catalog paths: { levels:
130
+ // [{ id, description }], default_level } or nothing at all, so an entry
131
+ // without them reads exactly as it did before levels were read.
132
+ function levelsOf(e, lf) {
133
+ if (!lf || !lf.list) return {};
134
+ const list = at(e, lf.list);
135
+ if (!Array.isArray(list)) return {};
136
+ const levels = [];
137
+ for (const x of list) {
138
+ const raw = x && typeof x === "object" ? at(x, lf.id || "id") : x;
139
+ const id = typeof raw === "string" ? clean(raw) : "";
140
+ if (!LEVEL.test(id) || levels.some((l) => l.id === id)) continue;
141
+ const d = x && typeof x === "object" && lf.description ? at(x, lf.description) : undefined;
142
+ levels.push({ id, description: typeof d === "string" ? clean(d) : "" });
143
+ }
144
+ if (!levels.length) return {};
145
+ const dRaw = lf.default ? at(e, lf.default) : undefined;
146
+ const d = typeof dRaw === "string" ? clean(dRaw) : "";
147
+ return { levels, ...(levels.some((l) => l.id === d) ? { default_level: d } : {}) };
148
+ }
149
+
150
+ // A cached row's levels, cleaned again: the cache is a file anyone can edit.
151
+ function cachedLevels(x) {
152
+ if (!Array.isArray(x.levels)) return {};
153
+ return levelsOf(
154
+ { l: x.levels, d: x.default_level },
155
+ { list: "l", id: "id", description: "description", default: "d" },
156
+ );
104
157
  }
105
158
 
106
159
  const timeOf = (v) => {
@@ -129,6 +182,7 @@ function readCache(manifest, opts) {
129
182
  description: clean(x.description ?? ""),
130
183
  family: clean(x.family ?? ""),
131
184
  hidden: Boolean(x.hidden),
185
+ ...cachedLevels(x),
132
186
  }));
133
187
  const source = typeof c.source === "string" ? c.source : "";
134
188
  const from = (modelSpec(manifest)?.catalogs || []).find((x) => x.source === source);
@@ -147,7 +201,7 @@ function readFileCatalog(manifest, spec, catalog, opts) {
147
201
  const raw = readFileSync(p, "utf8");
148
202
  const fields = fieldsOf(spec, catalog);
149
203
  const body = JSON.parse(raw);
150
- const entries = entriesOf(body, fields);
204
+ const entries = entriesOf(body, fields, effortSpec(manifest)?.catalog);
151
205
  if (!entries) return null;
152
206
  const t = timeOf(fields.at ? at(body, fields.at) : undefined) ?? statSync(p).mtimeMs;
153
207
  return { at: t, source: catalog.source || "", caption: catalog.caption || catalog.source || "", entries };
@@ -239,6 +293,14 @@ export function knownModels(manifest, { env = process.env, home = homedir(), now
239
293
  if (!offered.some((o) => o.id === s.id))
240
294
  offered.push({ id: s.id, label: s.label || "", description: s.description || "" });
241
295
  }
296
+ // A model's own levels, from the newest list whose entry for it names
297
+ // them: a cache written before levels were kept falls through to the
298
+ // harness's own file, so a refresh never loses what that file says.
299
+ const levels = new Map();
300
+ for (const c of [...(past.length ? past : candidates)].sort((a, b) => b.at - a.at))
301
+ for (const e of c.entries)
302
+ if (e.levels && !levels.has(e.id))
303
+ levels.set(e.id, { levels: e.levels, default: e.default_level || null, source: c.source, at: c.at });
242
304
  const t = t0;
243
305
  const live = liveCatalog(spec, env);
244
306
  return {
@@ -247,6 +309,7 @@ export function knownModels(manifest, { env = process.env, home = homedir(), now
247
309
  offered,
248
310
  known,
249
311
  hidden: new Set(entries.filter((e) => e.hidden).map((e) => e.id)),
312
+ levels,
250
313
  source: newest ? newest.source : "",
251
314
  caption: newest ? newest.caption || newest.source : "",
252
315
  at: newest ? newest.at : null,
@@ -288,6 +351,53 @@ const localDate = (t) => {
288
351
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
289
352
  };
290
353
 
354
+ // The levels a model takes (the effort ADR, decisions 3 and 4): its own, when
355
+ // a list names them, else the manifest's floor. `known` is knownModels'
356
+ // answer, or null. Null when the manifest declares no effort block.
357
+ // { levels: [{ id, description }], default, from: "catalog" | "floor",
358
+ // model, source, at }
359
+ export function effortLevels(manifest, model, known) {
360
+ const spec = effortSpec(manifest);
361
+ if (!spec) return null;
362
+ const own = model ? known?.levels?.get(model) : null;
363
+ if (own) return { levels: own.levels, default: own.default, from: "catalog", model, source: own.source, at: own.at };
364
+ const levels = (spec.levels || []).filter((l) => l && typeof l.id === "string" && l.id);
365
+ return { levels, default: null, from: "floor", model: model || null, source: "", at: null };
366
+ }
367
+
368
+ // The PLAN's note for a level the list lacks (the effort ADR, decision 5), or
369
+ // null when it is listed, or when the harness declares no levels at all.
370
+ // the model's own list: "<model> does not list <level> in <source>'s list
371
+ // as of <date>; written as typed"
372
+ // the floor: "<level> is not one of <harness>'s effort levels
373
+ // (<levels>); written as typed", and, where the
374
+ // spawn takes those levels only, that it refuses it
375
+ export function offLevelNote(manifest, level, model, known) {
376
+ if (!level) return null;
377
+ const l = effortLevels(manifest, model, known);
378
+ if (!l || l.levels.some((x) => x.id === level)) return null;
379
+ const display = manifest?.display_name || manifest?.id || "this harness";
380
+ if (l.from === "catalog")
381
+ return `${l.model} does not list ${level} in ${l.source || display}'s list${l.at ? ` as of ${localDate(l.at)}` : ""}; written as typed`;
382
+ const list = l.levels.map((x) => x.id).join(", ");
383
+ return effortSpec(manifest).strict
384
+ ? `${level} is not one of ${display}'s effort levels (${list}); written as typed, and ${display} refuses it at spawn`
385
+ : `${level} is not one of ${display}'s effort levels (${list}); written as typed`;
386
+ }
387
+
388
+ // The note for a level an agent keeps when the run moves it to a model that
389
+ // does not take it (the effort ADR, decision 5), or null: "<agent> runs at
390
+ // <level>, which <model> does not list in <source>'s list as of <date>".
391
+ export function standingLevelNote(manifest, agent, level, model, known) {
392
+ if (!level) return null;
393
+ const l = effortLevels(manifest, model, known);
394
+ if (!l || l.levels.some((x) => x.id === level)) return null;
395
+ const display = manifest?.display_name || manifest?.id || "this harness";
396
+ return l.from === "catalog"
397
+ ? `${agent} runs at ${level}, which ${l.model} does not list in ${l.source || display}'s list${l.at ? ` as of ${localDate(l.at)}` : ""}`
398
+ : `${agent} runs at ${level}, which is not one of ${display}'s effort levels`;
399
+ }
400
+
291
401
  // What is wrong with a manifest's models block, or null (the manifest schema
292
402
  // test, generation contract 1). A manifest without the block is fine: its
293
403
  // agents' models are typed by name.
@@ -321,6 +431,40 @@ export function modelsProblem(manifest) {
321
431
  return null;
322
432
  }
323
433
 
434
+ // What is wrong with a manifest's effort block, or null (the manifest schema
435
+ // test). A manifest without the block passes no effort, and is fine.
436
+ export function effortProblem(manifest) {
437
+ const spec = effortSpec(manifest);
438
+ if (!spec) return manifest?.agent_translation?.effort === undefined ? null : "effort is not an object";
439
+ const str = (v) => typeof v === "string" && v.length > 0;
440
+ const EVIDENCE = ["measured", "documented", "chosen"];
441
+ if (!str(spec.spawn_param) || !str(spec.spawn_param_reason)) return "spawn_param carries no name or no reason";
442
+ if (!EVIDENCE.includes(spec.spawn_param_evidence))
443
+ return `spawn_param_evidence is ${spec.spawn_param_evidence}, not ${EVIDENCE.join(", ")}`;
444
+ if (!Array.isArray(spec.levels) || !spec.levels.length) return "levels is not a non-empty list";
445
+ for (const l of spec.levels)
446
+ if (!l || !LEVEL.test(String(l.id)) || !str(l.description))
447
+ return `a level is not { id, description }: ${JSON.stringify(l)}`;
448
+ if (!str(spec.levels_reason)) return "levels carries no levels_reason";
449
+ if (spec.strict !== undefined && (typeof spec.strict !== "boolean" || !str(spec.strict_reason)))
450
+ return "strict is not a boolean with a strict_reason";
451
+ if (spec.catalog !== undefined) {
452
+ const c = spec.catalog;
453
+ if (!c || !str(c.list) || !str(c.id) || !str(c.reason)) return "the effort catalog names no list, id or reason";
454
+ if (!modelSpec(manifest)) return "an effort catalog without a models block has no entries to read";
455
+ }
456
+ const u = spec.unset;
457
+ if (!u || !str(u.value) || !str(u.row) || typeof u.model_default !== "boolean" || !str(u.reason))
458
+ return "unset is not { value, row, model_default, reason }";
459
+ if (spec.cheap !== undefined) {
460
+ if (!spec.levels.some((l) => l.id === spec.cheap)) return `cheap is ${spec.cheap}, not one of the levels`;
461
+ if (!str(spec.cheap_reason)) return "cheap carries no cheap_reason";
462
+ if (!EVIDENCE.includes(spec.cheap_evidence))
463
+ return `cheap_evidence is ${spec.cheap_evidence}, not ${EVIDENCE.join(", ")}`;
464
+ }
465
+ return null;
466
+ }
467
+
324
468
  // One bounded fetch, settled by a timer whatever the request does after: the
325
469
  // pattern of convergence.mjs's request(). The key goes in its header and
326
470
  // nowhere else; nothing of the request is kept.
@@ -487,7 +631,7 @@ export async function readLive(
487
631
  }
488
632
  if (r.error) return { error: r.error, source: catalog.source || "" };
489
633
  if (signal?.aborted) return { error: "stopped", source: catalog.source || "" };
490
- const entries = entriesOf(r.body, fieldsOf(spec, catalog));
634
+ const entries = entriesOf(r.body, fieldsOf(spec, catalog), effortSpec(manifest)?.catalog);
491
635
  if (!entries) return { error: "the answer holds no list", source: catalog.source || "" };
492
636
  const t = now();
493
637
  const p = modelCachePath(manifest, { env, home });
@@ -1,4 +1,4 @@
1
- <!-- projectstore:agents v4 (managed by projectstore — edit outside markers) -->
1
+ <!-- projectstore:agents v5 (managed by projectstore — edit outside markers) -->
2
2
  ## projectstore agents
3
3
 
4
4
  - **A feature-sized request opens a vault artifact before it opens an editor.**
@@ -11,10 +11,12 @@
11
11
  harness-level instruction contradicts this block, say so and ask which wins.
12
12
  Resolving it silently is how the contradiction becomes invisible to the person
13
13
  who could have settled it.
14
- - When spawning any agent below, resolve its model from
15
- `.projectstore/harness/<harness>.json` → `agents.per_agent.<name>.model ?? agents.default.model`,
16
- where `<name>` is the **bare** agent name (`critic` for `projectstore:critic`),
17
- and pass it as the spawn's model parameter. No key — pass nothing.
14
+ - When spawning any agent below, resolve its model and its effort from
15
+ `.projectstore/harness/<harness>.json` → `agents.per_agent.<name>.model ?? agents.default.model`
16
+ and `agents.per_agent.<name>.effort ?? agents.default.effort`, where `<name>` is
17
+ the **bare** agent name (`critic` for `projectstore:critic`), and pass them as
18
+ the spawn's model and effort parameters, the effort only when the spawn offers
19
+ one. No key — pass nothing.
18
20
  - After authoring or revising any vault artifact (ADR/research/epic/story) or
19
21
  design proposal: run the `projectstore:critic` agent on it before treating it final.
20
22
  - Before implementing an epic/story: consult `projectstore:planner` — it plans