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.
- 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 +3 -3
- 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 +40 -1
- package/node_modules/projectstore/harnesses/codex.json +50 -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 +133 -6
- package/node_modules/projectstore/scripts/configure.mjs +156 -19
- package/node_modules/projectstore/scripts/doctor.mjs +95 -31
- 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 +9 -5
- package/node_modules/projectstore/scripts/models.mjs +150 -6
- 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) };
|
|
@@ -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;
|
|
@@ -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
|
|
71
|
-
//
|
|
72
|
-
|
|
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(({
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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
|