projectstore-claude 0.30.2 → 0.30.4

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 (32) 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 +43 -28
  7. package/node_modules/projectstore/commands/bind.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +19 -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/convergence.mjs +56 -1
  23. package/node_modules/projectstore/scripts/doctor.mjs +134 -35
  24. package/node_modules/projectstore/scripts/harness.mjs +14 -9
  25. package/node_modules/projectstore/scripts/install-harness.mjs +182 -14
  26. package/node_modules/projectstore/scripts/lib.mjs +83 -24
  27. package/node_modules/projectstore/scripts/menu.mjs +9 -5
  28. package/node_modules/projectstore/scripts/models.mjs +150 -6
  29. package/node_modules/projectstore/scripts/setup.mjs +7 -5
  30. package/node_modules/projectstore/scripts/surfaces.mjs +36 -9
  31. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +7 -5
  32. package/package.json +2 -2
@@ -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 });
@@ -64,7 +64,7 @@ import {
64
64
  languageNames,
65
65
  } from "./binding.mjs";
66
66
  import { analyseRegistration, analyseJsonEntry } from "./surfaces.mjs";
67
- import { runningCopies, coreCommand, channelChoices, pinFor } from "./convergence.mjs";
67
+ import { runningCopies, coreCommand, channelChoices, pinFor, terminalRun } from "./convergence.mjs";
68
68
  import { runVerb, isInteractive, gateText, channelText, publicItem, planOptions } from "./install-harness.mjs";
69
69
  import { resolveBinding } from "./worktree.mjs";
70
70
  import * as term from "./term.mjs";
@@ -328,7 +328,9 @@ function factsOf(project, { env, home, root, version, interactive }) {
328
328
  sessionCopy = selectCopy(home, project, { harness: channelHarness });
329
329
  }
330
330
  const slotHarness = manifests.find((m) => statuslineOf(m)) || null;
331
- const slot = slotHarness ? analyseJsonEntry(project, statuslineOf(slotHarness), { root, home }).state : null;
331
+ const slot = slotHarness
332
+ ? analyseJsonEntry(project, statuslineOf(slotHarness), { root, home, harness: slotHarness }).state
333
+ : null;
332
334
  // A worktree's main checkout (worktree.mjs): only where it can be offered —
333
335
  // at a terminal, unbound, in a linked worktree. Read for the project
334
336
  // answered, never from the git top, which --project leaves unread.
@@ -692,12 +694,12 @@ function doneLines(r, { project, runEnv, home, version, channel, vaultPlan, unde
692
694
  // Rule 13: `--channel npm` when that was the answer, and only for the
693
695
  // harness whose channel it is — setup refuses --channel in a run without
694
696
  // that harness (exit 2). A keep is not repeated: once recorded, it is the
695
- // default already (rule 6).
696
- const again = channel === "npm" && hostRegistrationOf(m) ? "--channel npm" : "";
697
+ // default already (rule 6). The deferred row's reason names the same
698
+ // command, from the same builder.
697
699
  if (left.length)
698
700
  blocks.push({
699
701
  lead: `Left for a terminal outside this ${m.display_name} session: ${left.join(", ")}. From a terminal in this project, run:`,
700
- commands: [coreCommand("setup", { version, args: [`--harness ${id}`, again].filter(Boolean).join(" ") })],
702
+ commands: [terminalRun({ verb: "setup", harness: m, version, channel })],
701
703
  after: `then restart ${m.display_name}.`,
702
704
  });
703
705
  }
@@ -19,7 +19,8 @@
19
19
  //
20
20
  // Normative: the install spec — contract 0 (exclusive vs shared), 3–4 (the
21
21
  // four states and the ladder, rungs 1′ and 1″ included), 6 (per-entry states
22
- // for shared files: ours-current, ours-stale, ours-absent, unparseable),
22
+ // for shared files: ours-current, ours-stale, ours-newer — 2026-10-14 —, ours-absent,
23
+ // unparseable),
23
24
  // 12 (current, last written by), 14 (host-managed surfaces have no state to
24
25
  // derive), 16 (a harness is in use when detected or when it has files of
25
26
  // ours). Pure node, no external deps.
@@ -37,6 +38,8 @@ import {
37
38
  desiredStatusLineCommand,
38
39
  findAgentsBlock,
39
40
  agentsBlockVersion,
41
+ newerBlockWords,
42
+ isNewerBlock,
40
43
  agentsBlockTemplatePath,
41
44
  renderAgentsBlock,
42
45
  loadLayout,
@@ -185,6 +188,15 @@ export function analyseBlock(
185
188
  a.current = withBlock.find((e) => e.file === preferred.file) || withBlock[0] || null;
186
189
  a.duplicates = withBlock.filter((e) => e !== a.current);
187
190
  if (!a.current) return { ...a, state: "ours-absent" };
191
+ // A marker above this copy's template is never rendered down (the install
192
+ // spec, contracts 4 and 6, as amended 2026-10-14). Placement still decides
193
+ // first (rungs B4–B6), and a block that moves, migrates or is deduplicated
194
+ // carries the newest block's own text; one that stays is `ours-newer`
195
+ // (B7), before the render is compared, so its roster is never re-rendered.
196
+ const newest = withBlock.reduce((n, e) => (e.block.v > n.block.v ? e : n), a.current);
197
+ a.newer = isNewerBlock(newest.block.v, a.version) ? newest : null;
198
+ const words = a.newer ? newerBlockWords(a.newer.block.v, a.version) : null;
199
+ const carried = a.newer ? ` with its own text (${words})` : "";
188
200
  // A block this harness CANNOT READ moves, and its target is created if it has
189
201
  // to be: that is the whole difference between a second harness arriving and a
190
202
  // preference. A block it can read moves only when the preferred file already
@@ -197,17 +209,25 @@ export function analyseBlock(
197
209
  return {
198
210
  ...a,
199
211
  state: "ours-stale",
200
- reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}`,
212
+ reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}${carried}`,
201
213
  };
202
214
  }
203
215
  if (a.current.file !== preferred.file && preferred.present)
204
- return { ...a, state: "ours-stale", reason: `in ${a.current.file}; install migrates it to ${preferred.file}` };
216
+ return {
217
+ ...a,
218
+ state: "ours-stale",
219
+ reason: `in ${a.current.file}; install migrates it to ${preferred.file}${carried}`,
220
+ };
205
221
  if (a.duplicates.length)
206
222
  return {
207
223
  ...a,
208
224
  state: "ours-stale",
209
- reason: `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}`,
225
+ reason:
226
+ a.newer && a.newer !== a.current
227
+ ? `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}, and ${a.newer.file}'s carries v${a.newer.block.v}, newer than this copy's v${a.version}: its text replaces the block in ${a.current.file}`
228
+ : `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}${carried}`,
210
229
  };
230
+ if (a.newer) return { ...a, state: "ours-newer", reason: `${words}: left as it is` };
211
231
  if (a.current.block.v === a.version && a.current.block.block === a.desired) return { ...a, state: "ours-current" };
212
232
  return {
213
233
  ...a,
@@ -223,8 +243,15 @@ export function analyseBlock(
223
243
 
224
244
  // `renderRoot` (contract 4′, two phases): the root the surface is planned
225
245
  // AGAINST — the host's install path a registration produces — when it differs
226
- // from the root the templates are read from. Defaults to `root`.
227
- export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = homedir(), renderRoot = root } = {}) {
246
+ // from the root the templates are read from. Defaults to `root`. `harness` is
247
+ // the one the entry is FOR: its launcher lives under its own state directory,
248
+ // whichever harness launched this process (the install spec, contract 6, as
249
+ // amended 2026-10-14).
250
+ export function analyseJsonEntry(
251
+ projectDir,
252
+ s,
253
+ { root = pluginRoot(), home = homedir(), renderRoot = root, harness = null } = {},
254
+ ) {
228
255
  const path = join(projectDir, s.file);
229
256
  const pointer = s.marker?.pointer || "statusLine.command";
230
257
  const entryKey = pointer.split(".")[0];
@@ -263,7 +290,7 @@ export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = ho
263
290
  a.ours =
264
291
  statusLineIsOurWiring(a.curCmd, projectDir, home, root) ||
265
292
  statusLineIsOurWiring(a.curCmd, projectDir, home, renderRoot);
266
- a.desired = desiredStatusLineCommand(projectDir, renderRoot, home).command;
293
+ a.desired = desiredStatusLineCommand(projectDir, renderRoot, home, harness?.id).command;
267
294
  if (!a.curEntry) return { ...a, state: "ours-absent" };
268
295
  if (!a.ours) return { ...a, state: "theirs", reason: "a status line we did not write owns the slot" };
269
296
  if (a.curCmd === a.desired) return { ...a, state: "ours-current" };
@@ -317,7 +344,7 @@ export function analyseStampedFile(
317
344
  const tpl = readText(src);
318
345
  if (!tpl.present || tpl.text === null)
319
346
  return { ...a, state: "unparseable", refusal: `the source ${s.source} is missing from the plugin at ${root}` };
320
- const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir);
347
+ const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir, process.env, harness);
321
348
  if (rendered === null)
322
349
  return { ...a, state: "unparseable", refusal: "the launcher template is not one this installer knows how to fill" };
323
350
  const pkg = pluginVersionAt(root, harness) || "0.0.0";
@@ -791,7 +818,7 @@ export function surfaceStates(
791
818
  const hasOurs = states.some(
792
819
  (x) =>
793
820
  (x.kind === "exclusive" && ["current", "stale"].includes(x.state)) ||
794
- (x.kind === "shared" && ["ours-current", "ours-stale"].includes(x.state)),
821
+ (x.kind === "shared" && ["ours-current", "ours-stale", "ours-newer"].includes(x.state)),
795
822
  );
796
823
  if (detected.includes(m.id) || hasOurs) {
797
824
  out.used.push(m.id);
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-claude",
3
- "version": "0.30.2",
3
+ "version": "0.30.4",
4
4
  "description": "projectstore for Claude Code: the core pinned and bundled, the harness fixed. A release artifact; the way in is npx projectstore setup",
5
5
  "keywords": [
6
6
  "projectstore",
@@ -37,7 +37,7 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "projectstore": "=0.30.2"
40
+ "projectstore": "=0.30.4"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"