projectstore-claude 0.30.1 → 0.30.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/AGENTS.md +7 -5
- package/node_modules/projectstore/README.md +5 -5
- package/node_modules/projectstore/agents/clerk.md +2 -2
- package/node_modules/projectstore/commands/agents.md +38 -26
- package/node_modules/projectstore/commands/bind.md +1 -1
- package/node_modules/projectstore/commands/doctor.md +15 -3
- package/node_modules/projectstore/commands/reconcile.md +2 -2
- package/node_modules/projectstore/commands/review.md +1 -1
- package/node_modules/projectstore/commands/story.md +2 -1
- package/node_modules/projectstore/docs/getting-started.md +1 -1
- package/node_modules/projectstore/docs/harnesses.md +3 -2
- package/node_modules/projectstore/harnesses/claude-code.json +92 -2
- package/node_modules/projectstore/harnesses/codex.json +81 -14
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/agents-change.mjs +64 -10
- package/node_modules/projectstore/scripts/build-adapters.mjs +28 -13
- package/node_modules/projectstore/scripts/cli.mjs +32 -9
- package/node_modules/projectstore/scripts/configure-menu.mjs +716 -0
- package/node_modules/projectstore/scripts/configure.mjs +239 -91
- package/node_modules/projectstore/scripts/doctor.mjs +95 -31
- package/node_modules/projectstore/scripts/harness.mjs +13 -3
- package/node_modules/projectstore/scripts/install-harness.mjs +29 -0
- package/node_modules/projectstore/scripts/lib.mjs +32 -9
- package/node_modules/projectstore/scripts/menu.mjs +746 -0
- package/node_modules/projectstore/scripts/models.mjs +649 -0
- package/node_modules/projectstore/scripts/term.mjs +167 -0
- package/node_modules/projectstore/templates/claude-md-block.md.tmpl +7 -5
- 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
|
|
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:
|
|
957
|
-
// applies. A warn, not an issue — a newer package's agent
|
|
958
|
-
// reason for a committed overlay to name one this copy does
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1288
|
-
//
|
|
1289
|
-
//
|
|
1290
|
-
//
|
|
1291
|
-
// of
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1599
|
+
if (lines.includes(".claude/") || lines.includes(".claude")) return [];
|
|
1600
|
+
return [relative(proj, hostSettingsPath(proj, env))].filter((w) => !lines.includes(w));
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Whether git ignores the host's machine-local settings file (the layout spec,
|
|
1604
|
+
// contract 9 as amended 2026-10-14). Our own files are self-ignored inside
|
|
1605
|
+
// .projectstore/; what is left is the host's. Git's answer covers every rule
|
|
1606
|
+
// it applies — nested .gitignore files, .git/info/exclude, core.excludesFile
|
|
1607
|
+
// and its XDG default — where the line read saw only the project's exact
|
|
1608
|
+
// lines. --no-index keeps this about rules: a tracked file with a matching rule
|
|
1609
|
+
// is quiet here (tracking is checkTrackedRuntime's). Exit 0 is ignored, 1 is
|
|
1610
|
+
// not; anything else — no git on PATH, an error, a timeout — falls back to the
|
|
1611
|
+
// line read. Full report only: a subprocess, like checkTrackedRuntime.
|
|
1612
|
+
export function checkHostIgnored(proj, { env = process.env } = {}) {
|
|
1613
|
+
if (!existsSync(join(proj, ".git"))) return [];
|
|
1614
|
+
const file = relative(proj, hostSettingsPath(proj, env));
|
|
1615
|
+
const r = spawnSync("git", ["check-ignore", "-q", "--no-index", "--", file], { cwd: proj, env, timeout: 5000 });
|
|
1616
|
+
const missing = r.status === 0 ? [] : r.status === 1 ? [file] : hostIgnoreLineMissing(proj, env);
|
|
1617
|
+
if (!missing.length) return [];
|
|
1618
|
+
return [
|
|
1555
1619
|
finding(
|
|
1556
1620
|
"install",
|
|
1557
1621
|
"warn",
|
|
1558
1622
|
"gitignore",
|
|
1559
1623
|
`Machine-specific files not gitignored: ${missing.join(", ")} (or ignore ".claude/" wholesale).`,
|
|
1560
1624
|
),
|
|
1561
|
-
|
|
1562
|
-
return out;
|
|
1625
|
+
];
|
|
1563
1626
|
}
|
|
1564
1627
|
|
|
1565
1628
|
// An ignore line never untracks a file already in the index, so the project
|
|
@@ -2956,8 +3019,9 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
|
|
|
2956
3019
|
...checkAgentsBlock(proj, { env: opts.env, root: opts.root }),
|
|
2957
3020
|
...checkOverrideCopies(proj),
|
|
2958
3021
|
...checkEnvModel(),
|
|
2959
|
-
...checkEnvEffort(),
|
|
3022
|
+
...checkEnvEffort(proj),
|
|
2960
3023
|
...checkGitignore(proj),
|
|
3024
|
+
...checkHostIgnored(proj, { env: opts.env }),
|
|
2961
3025
|
...checkTrackedRuntime(proj),
|
|
2962
3026
|
...checkVaultGit(cfg),
|
|
2963
3027
|
...checkAutoUpdate(),
|
|
@@ -3042,7 +3106,7 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
|
|
|
3042
3106
|
restamp: findings.some((f) => f.level === "info" && f.check === "upgrade"),
|
|
3043
3107
|
}),
|
|
3044
3108
|
() => checkEnvModel(),
|
|
3045
|
-
() => checkEnvEffort(),
|
|
3109
|
+
() => checkEnvEffort(proj),
|
|
3046
3110
|
];
|
|
3047
3111
|
for (const step of steps) {
|
|
3048
3112
|
if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings: foldIntoMove(findings) };
|
|
@@ -302,9 +302,7 @@ export function hostPresence(manifest, { env = process.env, home = homedir() } =
|
|
|
302
302
|
x.cli.bin,
|
|
303
303
|
)?.[1] || null;
|
|
304
304
|
const bin = s ? s.cli.bin : null;
|
|
305
|
-
const
|
|
306
|
-
const fromEnv = r.home_env && env ? env[r.home_env] : null;
|
|
307
|
-
const homeDir = fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
|
|
305
|
+
const homeDir = harnessHome(manifest, { env, home });
|
|
308
306
|
let present = false;
|
|
309
307
|
if (homeDir) {
|
|
310
308
|
try {
|
|
@@ -314,6 +312,15 @@ export function hostPresence(manifest, { env = process.env, home = homedir() } =
|
|
|
314
312
|
return { cli: bin ? whichOnPath(bin, env || {}) : null, home: present ? homeDir : null, bin, homeDir };
|
|
315
313
|
}
|
|
316
314
|
|
|
315
|
+
// A harness's own home directory, as the harness resolves it: its home
|
|
316
|
+
// variable (runtime.home_env) when set, else home_default under the home
|
|
317
|
+
// directory, else null. Whether it exists is the caller's question.
|
|
318
|
+
export function harnessHome(manifest, { env = process.env, home = homedir() } = {}) {
|
|
319
|
+
const r = manifest?.runtime || {};
|
|
320
|
+
const fromEnv = r.home_env && env ? env[r.home_env] : null;
|
|
321
|
+
return fromEnv || (home && r.home_default ? join(home, r.home_default) : null);
|
|
322
|
+
}
|
|
323
|
+
|
|
317
324
|
// The agents block of a RUN (the story "One run plans the agents block once,
|
|
318
325
|
// and a bare uninstall selects every harness the project uses", rules 1–2):
|
|
319
326
|
// the files every manifest of the run names for it, in the FIRST manifest's
|
|
@@ -703,6 +710,9 @@ export function cachePaths({ env = process.env, home = homedir() } = {}) {
|
|
|
703
710
|
fetch: root ? join(root, "fetch") : null,
|
|
704
711
|
fetchRun: (id) => (root ? join(root, "fetch", id) : null),
|
|
705
712
|
registry: root ? join(root, "registry.json") : null,
|
|
713
|
+
// A harness's model list, as its last live read answered (the ADR "Agent
|
|
714
|
+
// model lists are read from each harness's own catalog…", decision 4).
|
|
715
|
+
models: (id) => (root && id ? join(root, "models", `${id}.json`) : null),
|
|
706
716
|
from: fromEnv ? variable : "the home directory",
|
|
707
717
|
variable,
|
|
708
718
|
};
|
|
@@ -2630,11 +2630,40 @@ export function plan(
|
|
|
2630
2630
|
placeBlock();
|
|
2631
2631
|
for (const item of moved.last) out.items.push({ harness: layoutHarness.id, ...item });
|
|
2632
2632
|
out.items.push(...marks);
|
|
2633
|
+
oneAddPerFile(out.items);
|
|
2633
2634
|
if (out.items.some((i) => i.action === "refuse")) out.ok = false;
|
|
2634
2635
|
if (out.refusals.length) out.ok = false;
|
|
2635
2636
|
return out;
|
|
2636
2637
|
}
|
|
2637
2638
|
|
|
2639
|
+
// One add per file (the presentation spec, contract 7 as amended 2026-10-14):
|
|
2640
|
+
// within one plan, a file an earlier item creates is added once. A later
|
|
2641
|
+
// `create` or `add` on that path changes the file the run has just made, so it
|
|
2642
|
+
// reads `update`; its state, steps and detail stay. Each item is planned
|
|
2643
|
+
// against the tree before the run, so the binding the vault item writes was
|
|
2644
|
+
// "absent" to the status-line flag and the channel mark, and all three read
|
|
2645
|
+
// "add". A creator is an earlier `create` or `add` item that is neither a
|
|
2646
|
+
// registration (its host-owned files are the host CLI's) nor a binding row (it
|
|
2647
|
+
// never makes the binding: apply skips it on an unbound project, and the vault
|
|
2648
|
+
// item or the layout move makes the file), counted for the paths absent at
|
|
2649
|
+
// plan time: its own and each step's `path` — never a move step's `to`, so a
|
|
2650
|
+
// migrating project's binding rows keep `add`. An entry row on a file that was
|
|
2651
|
+
// there keeps `add`.
|
|
2652
|
+
function oneAddPerFile(items) {
|
|
2653
|
+
const created = new Set();
|
|
2654
|
+
const writes = (i) => i.action === "create" || i.action === "add";
|
|
2655
|
+
for (const item of items) {
|
|
2656
|
+
if (!writes(item) || item.kind === "registration") continue;
|
|
2657
|
+
if (item.path && created.has(resolve(item.path))) {
|
|
2658
|
+
item.action = "update";
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
if (item.kind === "binding") continue;
|
|
2662
|
+
for (const p of [item.path, ...(item.steps || []).map((s) => s.path)])
|
|
2663
|
+
if (p && !existsSync(p)) created.add(resolve(p));
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2638
2667
|
// ─── the layout migration (the layout ADR; layout spec contract 6) ──────
|
|
2639
2668
|
//
|
|
2640
2669
|
// Two items, kind `layout`. The FIRST moves what only we read — the legacy
|
|
@@ -196,9 +196,11 @@ export { adoptHookInput, resetHookInput };
|
|
|
196
196
|
// ─── Harness overlays (the layout ADR decision 3; layout spec contracts 2–4) ──
|
|
197
197
|
//
|
|
198
198
|
// <project>/.projectstore/harness/<id>.json carries ONE thing: the agents block
|
|
199
|
-
// for that harness — agents.default.model
|
|
200
|
-
//
|
|
201
|
-
//
|
|
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))
|
|
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))
|
|
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
|
-
|
|
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 =
|
|
279
|
-
const names = Object.keys(agents.per_agent || {}).sort();
|
|
280
|
-
if (names.length)
|
|
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;
|