@sleepy_panda_srl/portulan 0.2.0 → 0.4.0
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/README.md +15 -4
- package/cli/README.md +61 -6
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +781 -130
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +248 -31
- package/cli/drills.mjs +56 -9
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +230 -40
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +1 -1
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/review-meter.mjs +14 -9
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +66 -17
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +401 -18
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +75 -0
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/core/templates/handoff.md +6 -2
- package/evals/README.md +23 -7
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +7 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +176 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +37 -3
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +272 -123
- package/spec/workspace.schema.json +67 -11
package/cli/doctor.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `doctor` — the Workspace Definition validator.
|
|
3
3
|
//
|
|
4
|
-
// node cli/doctor.mjs [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir>...
|
|
4
|
+
// node cli/doctor.mjs [--agent] [--static | --count-tokens <model>] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir>...
|
|
5
5
|
//
|
|
6
6
|
// Exit 0 every workspace validates · 1 at least one does not · 2 could not run.
|
|
7
7
|
|
|
8
|
+
import { readContextCap, contextCaps, contextCapLines } from "./ritual-contexts.mjs";
|
|
9
|
+
import { readStopPolicy } from "./stop-policy.mjs";
|
|
8
10
|
import fs from "node:fs";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
9
12
|
import path from "node:path";
|
|
10
13
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
14
|
|
|
@@ -18,14 +21,22 @@ import {
|
|
|
18
21
|
composeFragments,
|
|
19
22
|
packDifferences,
|
|
20
23
|
shadowedCopy,
|
|
24
|
+
personaDeclarations,
|
|
25
|
+
bindingDirectory,
|
|
26
|
+
readOwnedText,
|
|
21
27
|
} from "./compile.mjs";
|
|
22
28
|
import { isInside, recordType } from "./index.mjs";
|
|
23
|
-
import { parseFrontmatter
|
|
29
|
+
import { parseFrontmatter } from "./frontmatter.mjs";
|
|
24
30
|
import { resolveGovernor, AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
|
|
25
31
|
import { composedId } from "./recipe-set.mjs";
|
|
26
|
-
import { alwaysLine } from "./context.mjs";
|
|
32
|
+
import { alwaysLine, alwaysTier, declaredContext, treeOf, ESTIMATED_BYTES_PER_TOKEN, countAlways, countLine, countRoot, ContextError, handoffReport } from "./context.mjs";
|
|
33
|
+
import { handoffPolicy } from "./handoff-policy.mjs";
|
|
34
|
+
import { movableSections } from "./instructions.mjs";
|
|
27
35
|
import { formLine } from "./form.mjs";
|
|
28
36
|
import { sessionsLine } from "./sessions.mjs";
|
|
37
|
+
import { cacheRiskLines } from "./cache-risk.mjs";
|
|
38
|
+
import { spendAnalysis } from "./spend-analysis.mjs";
|
|
39
|
+
import { hostPaths, ledger, print, readSpend, restartSaving, worktrees, linesChanged, GENERAL_READ, HORIZON } from "./ledger.mjs";
|
|
29
40
|
|
|
30
41
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
31
42
|
const DEFAULT_SCHEMA = path.resolve(HERE, "..", "spec", "workspace.schema.json");
|
|
@@ -473,7 +484,7 @@ export function validateContributions(packDir, contributes, { fail, report, pack
|
|
|
473
484
|
}
|
|
474
485
|
let text;
|
|
475
486
|
try {
|
|
476
|
-
text =
|
|
487
|
+
text = readOwnedText(contained.real, packDir);
|
|
477
488
|
} catch (cause) {
|
|
478
489
|
fail("packs", `\`${pack}\`'s persona \`${rel}\` could not be read — ${cause.code ?? cause.message}. Only a missing file means absent`);
|
|
479
490
|
continue;
|
|
@@ -506,6 +517,9 @@ export function validatePersona(text, { fail, pack, rel }) {
|
|
|
506
517
|
fail("packs", `\`${pack}\`'s persona \`${rel}\` is missing ${part} — the five-part contract core/personas/README.md fixes`);
|
|
507
518
|
}
|
|
508
519
|
}
|
|
520
|
+
for (const problem of personaDeclarations(text).problems) {
|
|
521
|
+
fail("packs", `\`${pack}\`'s persona \`${rel}\` ${problem} — core/personas/README.md gives the fields a persona may declare, and none is defaulted`);
|
|
522
|
+
}
|
|
509
523
|
const reach = text.split(/^##\s+Autonomy reach\b.*$/im)[1]?.split(/^##\s/m)[0];
|
|
510
524
|
if (reach && claimsProhibited(reach)) {
|
|
511
525
|
fail(
|
|
@@ -777,7 +791,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
777
791
|
const manifestPath = path.join(dir, "workspace.json");
|
|
778
792
|
let workspace;
|
|
779
793
|
try {
|
|
780
|
-
workspace = JSON.parse(
|
|
794
|
+
workspace = JSON.parse(readOwnedText(manifestPath, dir));
|
|
781
795
|
} catch (cause) {
|
|
782
796
|
// A verdict about the workspace, not an environment failure: exit 1, not 2.
|
|
783
797
|
fail("schema", `no readable manifest at ${path.relative(process.cwd(), manifestPath)} — ${cause.message}`);
|
|
@@ -824,6 +838,9 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
824
838
|
["sessions", 11, workspace.sessions],
|
|
825
839
|
["spend", 12, workspace.spend],
|
|
826
840
|
["spend.restart", 13, workspace.spend?.restart],
|
|
841
|
+
["spend.resume", 16, workspace.spend?.resume],
|
|
842
|
+
["handoffs.series", 15, workspace.handoffs?.series],
|
|
843
|
+
["handoffs.index.budget", 15, workspace.handoffs?.index?.budget],
|
|
827
844
|
]) {
|
|
828
845
|
if (major === 2 && minor < since && value !== undefined) {
|
|
829
846
|
fail(
|
|
@@ -969,6 +986,8 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
969
986
|
});
|
|
970
987
|
|
|
971
988
|
// ---------------------------------------------------------------- cross-field
|
|
989
|
+
try { readStopPolicy(workspace); } catch (error) { fail("cross", error.message); }
|
|
990
|
+
|
|
972
991
|
// Own recipes only: a composed recipe may never be the workspace's `verify.default`.
|
|
973
992
|
const ids = (workspace.verify?.recipes ?? []).map((r) => r.id);
|
|
974
993
|
if (!ids.includes(workspace.verify?.default)) {
|
|
@@ -1122,6 +1141,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1122
1141
|
}
|
|
1123
1142
|
|
|
1124
1143
|
const composedPersonas = [];
|
|
1144
|
+
const contextContributions = [];
|
|
1125
1145
|
|
|
1126
1146
|
let resolvedPackRoots = null;
|
|
1127
1147
|
let packsUnresolved = 0;
|
|
@@ -1143,7 +1163,13 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1143
1163
|
const claimed = roots.filter((root) => originOf.get(root) !== "discovered");
|
|
1144
1164
|
report("packs", `resolution root ${plan.source} — ${plan.why}`);
|
|
1145
1165
|
for (const name of workspace.packs) {
|
|
1146
|
-
|
|
1166
|
+
let found;
|
|
1167
|
+
try { found = resolvePack(name, roots); }
|
|
1168
|
+
catch (cause) {
|
|
1169
|
+
fail("packs", `\`${name}\` could not be resolved — ${cause.message}`);
|
|
1170
|
+
packsUnresolved += 1;
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1147
1173
|
if (!found.dir) {
|
|
1148
1174
|
if (claimed.length === 0) {
|
|
1149
1175
|
stats.unverifiable += 1;
|
|
@@ -1159,7 +1185,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1159
1185
|
}
|
|
1160
1186
|
let manifest;
|
|
1161
1187
|
try {
|
|
1162
|
-
manifest = JSON.parse(
|
|
1188
|
+
manifest = JSON.parse(readOwnedText(found.manifest, found.dir));
|
|
1163
1189
|
} catch (cause) {
|
|
1164
1190
|
fail("packs", `\`${name}\` resolves to ${path.relative(dir, found.manifest)} but its manifest is unreadable — ${cause.message}`);
|
|
1165
1191
|
continue;
|
|
@@ -1186,6 +1212,10 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1186
1212
|
}
|
|
1187
1213
|
continue;
|
|
1188
1214
|
}
|
|
1215
|
+
let contextCap;
|
|
1216
|
+
try { contextCap = readContextCap(manifest, name); }
|
|
1217
|
+
catch (e) { fail("packs", e.message); continue; }
|
|
1218
|
+
contextContributions.push({ pack: name, contextCap });
|
|
1189
1219
|
stats.packs += 1;
|
|
1190
1220
|
const c = manifest.contributes ?? {};
|
|
1191
1221
|
|
|
@@ -1218,11 +1248,15 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1218
1248
|
|
|
1219
1249
|
// First match wins and discovered roots come first, so an installed copy can shadow the tree's.
|
|
1220
1250
|
{
|
|
1221
|
-
|
|
1251
|
+
let shadowed = { dir: null };
|
|
1252
|
+
try { shadowed = shadowedCopy(name, origin, roots, (r) => originOf.get(r)) ?? shadowed; }
|
|
1253
|
+
catch (cause) {
|
|
1254
|
+
report("packs", `\`${name}\` is SHADOWED — the other copy could not be read (${cause.message}), so what differs could not be compared`);
|
|
1255
|
+
}
|
|
1222
1256
|
if (shadowed.dir) {
|
|
1223
1257
|
let other = null;
|
|
1224
1258
|
try {
|
|
1225
|
-
other = JSON.parse(
|
|
1259
|
+
other = JSON.parse(readOwnedText(shadowed.manifest, shadowed.dir));
|
|
1226
1260
|
} catch (cause) {
|
|
1227
1261
|
report(
|
|
1228
1262
|
"packs",
|
|
@@ -1243,6 +1277,11 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1243
1277
|
}
|
|
1244
1278
|
}
|
|
1245
1279
|
|
|
1280
|
+
try {
|
|
1281
|
+
const caps = contextCaps(contextContributions, Array(packsUnresolved).fill(null));
|
|
1282
|
+
for (const line of contextCapLines(caps)) report("contexts", line);
|
|
1283
|
+
} catch (e) { fail("contexts", e.message); }
|
|
1284
|
+
|
|
1246
1285
|
if (workspace.librarian && !workspace.slots?.memory) {
|
|
1247
1286
|
fail(
|
|
1248
1287
|
"cross",
|
|
@@ -1274,6 +1313,8 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1274
1313
|
}
|
|
1275
1314
|
|
|
1276
1315
|
const alwaysTokens = workspace.context?.always?.budget?.tokens;
|
|
1316
|
+
try { handoffPolicy(workspace); }
|
|
1317
|
+
catch (error) { fail("schema", error.message); }
|
|
1277
1318
|
if (alwaysTokens !== undefined && !positive(alwaysTokens)) {
|
|
1278
1319
|
fail(
|
|
1279
1320
|
"schema",
|
|
@@ -1349,6 +1390,10 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1349
1390
|
}
|
|
1350
1391
|
}
|
|
1351
1392
|
const horizonRequests = workspace.spend?.horizon?.requests;
|
|
1393
|
+
const resumeTokens = workspace.spend?.resume?.tokens;
|
|
1394
|
+
if (resumeTokens !== undefined && !(Number.isFinite(resumeTokens) && resumeTokens >= 0)) {
|
|
1395
|
+
fail("schema", "spend.resume.tokens must be a finite nonnegative number of input-equivalent tokens; absent means unknown, not zero");
|
|
1396
|
+
}
|
|
1352
1397
|
if (horizonRequests !== undefined && !positive(horizonRequests)) {
|
|
1353
1398
|
fail(
|
|
1354
1399
|
"schema",
|
|
@@ -1448,21 +1493,32 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1448
1493
|
`so there is no repository to look in. Unverifiable, not unbound`,
|
|
1449
1494
|
);
|
|
1450
1495
|
}
|
|
1451
|
-
//
|
|
1496
|
+
// Compare canonical paths for aliased trees.
|
|
1452
1497
|
let realTree = treeRoot;
|
|
1453
1498
|
try {
|
|
1454
1499
|
if (treeRoot) realTree = fs.realpathSync(treeRoot);
|
|
1455
1500
|
} catch {
|
|
1456
|
-
//
|
|
1501
|
+
// Path validation reports the tree failure.
|
|
1502
|
+
}
|
|
1503
|
+
let bindingDir = null;
|
|
1504
|
+
if (treeRoot) {
|
|
1505
|
+
try { bindingDir = bindingDirectory(treeRoot); }
|
|
1506
|
+
catch (error) {
|
|
1507
|
+
(error.unread ? report : fail)("bindings", `host binding directory could not be determined: ${error.message}`);
|
|
1508
|
+
stats.unverifiable += composedPersonas.length;
|
|
1509
|
+
}
|
|
1457
1510
|
}
|
|
1458
|
-
for (const persona of treeRoot ? composedPersonas : []) {
|
|
1459
|
-
|
|
1511
|
+
for (const persona of treeRoot && bindingDir ? composedPersonas : []) {
|
|
1512
|
+
if (!SLUG_PATTERN.test(persona.key)) {
|
|
1513
|
+
fail("bindings", `\`${persona.pack}\`'s \`${persona.rel}\` keys its host binding to \`${persona.key}\`, which is not a single host name; an escaping name leaves this workspace's tree`);
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
const rel = path.join(bindingDir, `${persona.key}.md`);
|
|
1460
1517
|
const where = `\`${persona.pack}\`'s \`${persona.rel}\``;
|
|
1461
1518
|
const keyed = persona.keyedBy === "declaration" ? "" : " (keyed by filename — the persona declares no `name`)";
|
|
1462
1519
|
const file = path.join(treeRoot, rel);
|
|
1463
1520
|
|
|
1464
|
-
//
|
|
1465
|
-
// realpath test below is skipped where nothing exists.
|
|
1521
|
+
// Pack keys must stay inside the tree before any read.
|
|
1466
1522
|
if (!isInside(treeRoot, path.resolve(treeRoot, rel))) {
|
|
1467
1523
|
fail(
|
|
1468
1524
|
"bindings",
|
|
@@ -1472,6 +1528,14 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1472
1528
|
continue;
|
|
1473
1529
|
}
|
|
1474
1530
|
|
|
1531
|
+
let stat = null;
|
|
1532
|
+
try { stat = fs.lstatSync(file); }
|
|
1533
|
+
catch (cause) {
|
|
1534
|
+
if (cause.code !== "ENOENT") {
|
|
1535
|
+
report("bindings", `${where}'s binding at \`${rel}\` could not be examined — ${cause.code ?? cause.message}. Unread, not absent`);
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1475
1539
|
let real = null;
|
|
1476
1540
|
try {
|
|
1477
1541
|
real = fs.realpathSync(file);
|
|
@@ -1480,7 +1544,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1480
1544
|
report("bindings", `${where}'s binding at \`${rel}\` could not be resolved — ${cause.code ?? cause.message}. Unread, not absent`);
|
|
1481
1545
|
continue;
|
|
1482
1546
|
}
|
|
1483
|
-
//
|
|
1547
|
+
// Missing files remain unbound.
|
|
1484
1548
|
}
|
|
1485
1549
|
if (real !== null && !isInside(realTree, real)) {
|
|
1486
1550
|
fail(
|
|
@@ -1491,15 +1555,19 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1491
1555
|
);
|
|
1492
1556
|
continue;
|
|
1493
1557
|
}
|
|
1558
|
+
if (stat !== null && !stat.isFile()) {
|
|
1559
|
+
fail("bindings", `${where}'s binding at \`${rel}\` is not a regular file; it was not read`);
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1494
1562
|
let text;
|
|
1495
1563
|
try {
|
|
1496
|
-
text = fs.readFileSync(file
|
|
1564
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(file));
|
|
1497
1565
|
} catch (cause) {
|
|
1498
1566
|
if (cause.code === "ENOENT") {
|
|
1499
1567
|
report(
|
|
1500
1568
|
"bindings",
|
|
1501
1569
|
`${where} has no host binding at \`${rel}\`${keyed} — reported, not failed: a persona without one is unbound rather than wrong, ` +
|
|
1502
|
-
"
|
|
1570
|
+
`the selected ${bindingDir === "agents" ? "plugin" : "project"} location is where this tree's Claude Code agents load`,
|
|
1503
1571
|
);
|
|
1504
1572
|
} else {
|
|
1505
1573
|
report("bindings", `${where}'s binding at \`${rel}\` could not be read — ${cause.code ?? cause.message}. Unread, not absent`);
|
|
@@ -1532,7 +1600,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1532
1600
|
);
|
|
1533
1601
|
continue;
|
|
1534
1602
|
}
|
|
1535
|
-
//
|
|
1603
|
+
// Tool grants are declared, not compared to charter prose.
|
|
1536
1604
|
report("bindings", `${where} is bound by \`${rel}\`${keyed} — ${BINDING_OK}`);
|
|
1537
1605
|
}
|
|
1538
1606
|
}
|
|
@@ -1762,7 +1830,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1762
1830
|
: "";
|
|
1763
1831
|
report(
|
|
1764
1832
|
"enforcement",
|
|
1765
|
-
`this workspace composes ${fragments} gate rule(s) from the packs that resolved and declares no \`gates\` policy for them to join, so nothing here compiles
|
|
1833
|
+
`this workspace composes ${fragments} gate rule(s) from the packs that resolved and declares no \`gates\` policy for them to join, so nothing here compiles these gates; restart advice, session settings, guidance, personas or a ritual cap can still compile independently${incomplete}`,
|
|
1766
1834
|
);
|
|
1767
1835
|
}
|
|
1768
1836
|
} catch (cause) {
|
|
@@ -1850,7 +1918,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1850
1918
|
(stats.unassessed
|
|
1851
1919
|
? `; ${stats.unassessed} unreadable and never assessed`
|
|
1852
1920
|
: "") +
|
|
1853
|
-
". Size and count only: ages
|
|
1921
|
+
". Size and count only: memory ages are not measured here, so staleness belongs to `cli/librarian.mjs` — the scheduled pass, which may ask git and does"
|
|
1854
1922
|
: "no memory records — nothing measured, nothing awaiting retirement",
|
|
1855
1923
|
);
|
|
1856
1924
|
|
|
@@ -1867,7 +1935,14 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1867
1935
|
);
|
|
1868
1936
|
|
|
1869
1937
|
const always = alwaysLine(dir, workspace);
|
|
1870
|
-
|
|
1938
|
+
const agentsAlways = alwaysLine(dir, workspace, { host: "agents-md" });
|
|
1939
|
+
for (const measured of [always, agentsAlways]) {
|
|
1940
|
+
(measured.verdict === "over" || measured.verdict === "unjudged" ? fail : report)("context", measured.line);
|
|
1941
|
+
}
|
|
1942
|
+
try {
|
|
1943
|
+
const handoffs = handoffReport(dir, workspace);
|
|
1944
|
+
for (const line of handoffs.lines) (handoffs.over && line.startsWith("handoffs cap:") ? fail : report)("handoffs", line);
|
|
1945
|
+
} catch (error) { fail("handoffs", `could not report: ${error.message}`); }
|
|
1871
1946
|
|
|
1872
1947
|
// Never a verdict: failing it would turn red every workspace drafted before the new form.
|
|
1873
1948
|
let form;
|
|
@@ -1876,6 +1951,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1876
1951
|
} catch (error) {
|
|
1877
1952
|
form = `not read — ${error.message}`;
|
|
1878
1953
|
}
|
|
1954
|
+
if ((agentsAlways.verdict === "over" || agentsAlways.verdict === "unjudged") && form.startsWith("today's form")) form += "; resolve the AGENTS.md budget verdict before applying this migration";
|
|
1879
1955
|
report("form", form);
|
|
1880
1956
|
|
|
1881
1957
|
let sessions;
|
|
@@ -1885,10 +1961,100 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1885
1961
|
sessions = `not read — ${error.message}`;
|
|
1886
1962
|
}
|
|
1887
1963
|
report("sessions", sessions);
|
|
1964
|
+
try {
|
|
1965
|
+
const root = treeOf(dir, workspace);
|
|
1966
|
+
if (root !== null) for (const line of cacheRiskLines(root)) report("cache-risk", line);
|
|
1967
|
+
} catch { report("cache-risk", "project configuration unassessed — the declared tree could not be read."); }
|
|
1968
|
+
report("lever", `Claude Code project domain: ${biggestLeverLine(dir, workspace)}`);
|
|
1888
1969
|
|
|
1889
1970
|
return { dir, workspace, findings, stats };
|
|
1890
1971
|
}
|
|
1891
1972
|
|
|
1973
|
+
export function demotionCandidates(workspaceDir, workspace) {
|
|
1974
|
+
const root = treeOf(workspaceDir, workspace);
|
|
1975
|
+
if (root === null) return [];
|
|
1976
|
+
const always = alwaysTier(root);
|
|
1977
|
+
if (always.missing.length || always.outside.length || always.tooDeep.length) {
|
|
1978
|
+
throw new DoctorError("the always-tier measurement is incomplete");
|
|
1979
|
+
}
|
|
1980
|
+
const candidates = [];
|
|
1981
|
+
const add = (label, bytes) => {
|
|
1982
|
+
const routing = Buffer.byteLength(`Read ${JSON.stringify(label)} on demand.\n`, "utf8");
|
|
1983
|
+
if (bytes > routing) candidates.push({ label, bytes: bytes - routing, routing });
|
|
1984
|
+
};
|
|
1985
|
+
for (const entry of always.entries) {
|
|
1986
|
+
const rel = path.relative(root, entry.file).split(path.sep).join("/");
|
|
1987
|
+
if (entry.label === "instructions") {
|
|
1988
|
+
for (const section of movableSections(fs.readFileSync(entry.file, "utf8"), entry.file)) {
|
|
1989
|
+
add(`${rel}: ${section.title}`, section.bytes);
|
|
1990
|
+
}
|
|
1991
|
+
} else if (!entry.label.endsWith("description") && entry.file !== always.card && rel !== "core/engine.md") {
|
|
1992
|
+
add(rel, entry.bytes);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
return candidates.sort((a, b) => b.bytes - a.bytes || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
export function biggestLeverLine(workspaceDir, workspace, evidence = null) {
|
|
1999
|
+
const limits = "model and cache-lifetime changes unpriced";
|
|
2000
|
+
try {
|
|
2001
|
+
if (workspace.kind === "pointer" || treeOf(workspaceDir, workspace) === null) return `unmeasured — no repository tree; ${limits}`;
|
|
2002
|
+
const ratio = declaredContext(workspace).ratio ?? ESTIMATED_BYTES_PER_TOKEN;
|
|
2003
|
+
const spend = readSpend(workspace.spend, "this workspace");
|
|
2004
|
+
const session = evidence?.restart;
|
|
2005
|
+
const measured = session && [session.fresh, session.context, session.horizon, session.multipliers?.read, session.multipliers?.write].every((v) => Number.isFinite(v) && v > 0);
|
|
2006
|
+
const horizon = measured ? session.horizon : spend.horizon ?? HORIZON;
|
|
2007
|
+
const read = measured ? session.multipliers.read : spend.multipliers?.read ?? GENERAL_READ;
|
|
2008
|
+
const candidates = demotionCandidates(workspaceDir, workspace).map((c) => ({
|
|
2009
|
+
action: `review demoting ${JSON.stringify(c.label)} on demand`,
|
|
2010
|
+
saving: c.bytes / ratio * horizon * read,
|
|
2011
|
+
basis: `${c.bytes} B after ${c.routing} B routing allowance / ${ratio} B per token; assumes unused throughout the horizon`,
|
|
2012
|
+
}));
|
|
2013
|
+
if (measured && session.resume != null && session.horizonDeclared) candidates.push({
|
|
2014
|
+
action: "review restarting the recorded session",
|
|
2015
|
+
saving: restartSaving({ fresh: session.fresh, context: session.context, horizon, read, write: session.multipliers.write, resume: session.resume }),
|
|
2016
|
+
basis: `${horizon} × ${read} × (${session.context} − ${session.fresh}) − ${session.multipliers.write} × ${session.fresh} − ${session.resume}; resume expense priced in input-equivalent tokens`,
|
|
2017
|
+
});
|
|
2018
|
+
const winner = candidates.filter((c) => Number.isFinite(c.saving) && c.saving > 0).sort((a, b) => b.saving - a.saving || (a.action < b.action ? -1 : 1))[0];
|
|
2019
|
+
const assumptions = `${horizon} requests at ${read}× read (${spend.multipliers === null ? "general, undeclared" : "declared"}); ` +
|
|
2020
|
+
`${session?.unavailable === "usage" ? "restart not compared: input usage counters are invalid or incomplete" : session?.unavailable === "branch-scope" ? "restart not compared: latest session tail is outside branch-owned scope" : measured && session.resume != null && session.horizonDeclared ? "recorded restart compared" : "session-dependent levers not compared: explicit horizon and resume expense required"}${evidence?.malformed ? "; partial usage data" : ""}; ${limits}`;
|
|
2021
|
+
return winner
|
|
2022
|
+
? `biggest lever: ${winner.action} — up to ~${Number(winner.saving.toPrecision(6)).toLocaleString("en-US", { maximumSignificantDigits: 6 })} estimated input-equivalent tokens; ${winner.basis}; ${assumptions}; quantified demotion/restart candidates only, not an overall winner or guaranteed session savings`
|
|
2023
|
+
: `no positive measured lever; ${assumptions}`;
|
|
2024
|
+
} catch {
|
|
2025
|
+
return `unmeasured — context or spend inputs are invalid, unreadable or incomplete; session-dependent levers not compared; ${limits}`;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/** Optional usage accounting: a data failure never changes the workspace-validation verdict. */
|
|
2030
|
+
export async function workspaceUsageReport(workspaceDir, workspace, options = {}) {
|
|
2031
|
+
const unmeasured = (why) => [` note spend unmeasured — ${why}`];
|
|
2032
|
+
if (typeof workspace?.tree !== "string" || workspace.tree.trim() === "") return unmeasured("the workspace declares no tree to attribute usage to");
|
|
2033
|
+
try {
|
|
2034
|
+
const repo = path.resolve(workspaceDir, workspace.tree);
|
|
2035
|
+
const roots = worktrees(repo);
|
|
2036
|
+
if (roots === null || roots.length === 0) return unmeasured("the declared tree has no Git worktree ownership");
|
|
2037
|
+
const branch = execFileSync("git", ["-C", repo, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim();
|
|
2038
|
+
if (!branch || branch === "HEAD") return unmeasured("the declared tree has no current named Git branch");
|
|
2039
|
+
const host = hostPaths(options.env ?? process.env, options.home);
|
|
2040
|
+
if (host.why !== undefined) return unmeasured("the configured usage-record location is ambiguous");
|
|
2041
|
+
const report = ledger({ ...host, roots, branch, lines: linesChanged(repo, branch), spend: readSpend(workspace.spend, "this workspace") });
|
|
2042
|
+
options.onLedger?.({ restart: report.restart, malformed: report.collected.malformed });
|
|
2043
|
+
const lines = [];
|
|
2044
|
+
const analysis = spendAnalysis(report).map(line => ` note spend ${line}`);
|
|
2045
|
+
if (report.collected.malformed) lines.push(` note spend partial data — ${report.collected.malformed} malformed usage line(s) were ignored`);
|
|
2046
|
+
if (report.figures.main.requests + report.figures.subagents.requests === 0 && report.local.receipts === 0) {
|
|
2047
|
+
return [...lines, ...unmeasured("no host requests or local receipts on this branch are recorded here"), ...analysis];
|
|
2048
|
+
}
|
|
2049
|
+
print(report, (line) => lines.push(line));
|
|
2050
|
+
lines.push(...analysis);
|
|
2051
|
+
return lines;
|
|
2052
|
+
} catch {
|
|
2053
|
+
// Parser messages can quote a private record. Never print them or a raw usage line.
|
|
2054
|
+
return unmeasured("optional local usage data could not be read or parsed; no totals were reported");
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1892
2058
|
// ---------------------------------------------------------------- the command
|
|
1893
2059
|
|
|
1894
2060
|
const ICON = { fail: "FAIL ", report: "note " };
|
|
@@ -1903,8 +2069,13 @@ function usage() {
|
|
|
1903
2069
|
return [
|
|
1904
2070
|
"portulan doctor — validate a workspace against the Workspace Definition",
|
|
1905
2071
|
"",
|
|
1906
|
-
" portulan doctor [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]",
|
|
2072
|
+
" portulan doctor [--agent] [--static | --count-tokens <model>] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]",
|
|
1907
2073
|
"",
|
|
2074
|
+
" --static skip local usage records; validation and plugin discovery remain unchanged.",
|
|
2075
|
+
" Always tiers: Claude Code and vendored AGENTS.md are measured and budgeted independently.",
|
|
2076
|
+
" --count-tokens <model> validate both budgets, then send only the Claude Code project corpus to Anthropic.",
|
|
2077
|
+
" Requires ANTHROPIC_API_KEY; unavailable with --static or in CI. No manifest is changed.",
|
|
2078
|
+
" --agent one counted line on green; complete reports on failure (also PORTULAN_AGENT=1).",
|
|
1908
2079
|
" --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
|
|
1909
2080
|
" A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
|
|
1910
2081
|
" --repo-root where the repositories this workspace's cards NAME are checked out, so their",
|
|
@@ -1920,13 +2091,25 @@ function usage() {
|
|
|
1920
2091
|
|
|
1921
2092
|
/** Runs against each workspace directory and returns the exit code; never throws. */
|
|
1922
2093
|
export async function run(argv, options = {}) {
|
|
1923
|
-
const
|
|
2094
|
+
const agent = argv.includes("--agent") || (options.env ?? process.env).PORTULAN_AGENT === "1";
|
|
2095
|
+
const buffered = [];
|
|
2096
|
+
const total = { workspaces: 0, notes: 0, claims: 0, providers: [] };
|
|
2097
|
+
const say = options.quiet ? () => {} : agent ? (line = "") => buffered.push(line) : (line = "") => process.stdout.write(`${line}\n`);
|
|
2098
|
+
const complete = (code, help = false) => {
|
|
2099
|
+
if (agent && !options.quiet) {
|
|
2100
|
+
if (code === 0 && !help) process.stdout.write(`GREEN — doctor: ${total.workspaces} workspace(s), ${total.notes} note(s), ${total.claims} claim(s) checked${total.providers.length ? "; " + total.providers.join("; ") : ""}\n`);
|
|
2101
|
+
else if (buffered.length) process.stdout.write(buffered.join("\n") + "\n");
|
|
2102
|
+
}
|
|
2103
|
+
return code;
|
|
2104
|
+
};
|
|
1924
2105
|
// Before any other argument is judged, so help is never outranked by a complaint about the rest.
|
|
1925
2106
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
1926
2107
|
say(usage());
|
|
1927
|
-
return 0;
|
|
2108
|
+
return complete(0, true);
|
|
1928
2109
|
}
|
|
1929
2110
|
try {
|
|
2111
|
+
let staticMode = false;
|
|
2112
|
+
let countModel = null;
|
|
1930
2113
|
const namedRoots = [];
|
|
1931
2114
|
let discoverPacks = false;
|
|
1932
2115
|
const repoRoots = [];
|
|
@@ -1953,7 +2136,14 @@ export async function run(argv, options = {}) {
|
|
|
1953
2136
|
};
|
|
1954
2137
|
const REPO_ROOT = { unresolvable: "a repository ungoverned", holds: "repositories" };
|
|
1955
2138
|
for (let i = 0; i < argv.length; i += 1) {
|
|
1956
|
-
if (argv[i] === "--
|
|
2139
|
+
if (argv[i] === "--agent") {
|
|
2140
|
+
continue;
|
|
2141
|
+
} else if (argv[i] === "--static") {
|
|
2142
|
+
staticMode = true;
|
|
2143
|
+
} else if (argv[i] === "--count-tokens") {
|
|
2144
|
+
if (countModel !== null || !argv[i + 1] || argv[i + 1].startsWith("-")) throw new DoctorError("--count-tokens needs one explicit provider model ID");
|
|
2145
|
+
countModel = argv[++i];
|
|
2146
|
+
} else if (argv[i] === "--repo-root") {
|
|
1957
2147
|
repoRoots.push(directoryRoot("--repo-root", argv[i + 1], REPO_ROOT));
|
|
1958
2148
|
i += 1;
|
|
1959
2149
|
} else if (argv[i] === "--pack-root") {
|
|
@@ -1969,34 +2159,61 @@ export async function run(argv, options = {}) {
|
|
|
1969
2159
|
}
|
|
1970
2160
|
const bothAsked = namedWithAuto(namedRoots, discoverPacks);
|
|
1971
2161
|
if (bothAsked) throw new DoctorError(bothAsked);
|
|
2162
|
+
if (countModel !== null && staticMode) throw new DoctorError("--count-tokens is unavailable with --static; verify recipes stay offline");
|
|
1972
2163
|
if (dirs.length === 0) {
|
|
1973
2164
|
if (!options.quiet) {
|
|
1974
|
-
process.stderr.write("usage: node cli/doctor.mjs [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]\n");
|
|
2165
|
+
process.stderr.write("usage: node cli/doctor.mjs [--agent] [--static | --count-tokens <model>] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]\n");
|
|
1975
2166
|
}
|
|
1976
|
-
return 2;
|
|
2167
|
+
return complete(2);
|
|
1977
2168
|
}
|
|
1978
2169
|
|
|
1979
2170
|
let failed = 0;
|
|
1980
2171
|
for (const dir of dirs) {
|
|
2172
|
+
if (countModel !== null) {
|
|
2173
|
+
try { countRoot(path.resolve(dir)); }
|
|
2174
|
+
catch (error) { throw new DoctorError(error instanceof ContextError ? error.message : "provider source validation could not run"); }
|
|
2175
|
+
}
|
|
1981
2176
|
const roots = {
|
|
1982
2177
|
...(namedRoots.length ? { packRoots: namedRoots } : {}),
|
|
1983
2178
|
...(discoverPacks ? { discoverPacks } : {}),
|
|
1984
2179
|
...(repoRoots.length ? { repoRoots } : {}),
|
|
1985
2180
|
};
|
|
1986
|
-
const { findings, stats } = await inspect(dir, { ...options, ...roots });
|
|
2181
|
+
const { findings, stats, workspace } = await inspect(dir, { ...options, ...roots });
|
|
1987
2182
|
const bad = findings.filter((f) => f.severity === "fail");
|
|
2183
|
+
let evidence = null;
|
|
2184
|
+
const usageLines = !staticMode && !options.quiet && !bad.length && workspace
|
|
2185
|
+
? await workspaceUsageReport(path.resolve(dir), workspace, { ...options, onLedger: (report) => { evidence = report; } }) : [];
|
|
2186
|
+
total.workspaces += 1;
|
|
2187
|
+
total.notes += findings.length - bad.length;
|
|
2188
|
+
total.claims += stats.claims;
|
|
1988
2189
|
say(display(dir));
|
|
1989
|
-
|
|
2190
|
+
if (countModel !== null && bad.length) {
|
|
2191
|
+
say(` RED — ${bad.length} validation failure(s); provider count skipped. Run doctor --static separately for validation details.`);
|
|
2192
|
+
say();
|
|
2193
|
+
failed += 1;
|
|
2194
|
+
continue;
|
|
2195
|
+
}
|
|
2196
|
+
for (const f of findings) say(` ${ICON[f.severity]} ${f.check.padEnd(10)} ${f.check === "lever" && evidence !== null ? `Claude Code project domain: ${biggestLeverLine(path.resolve(dir), workspace, evidence)}` : f.message}`);
|
|
1990
2197
|
say(
|
|
1991
2198
|
` ${bad.length ? "RED" : "GREEN"} — ${bad.length} failure(s), ` +
|
|
1992
2199
|
`${findings.length - bad.length} note(s), ${stats.claims} claim(s) checked` +
|
|
1993
2200
|
(stats.unverifiable ? `, ${stats.unverifiable} unverifiable` : ""),
|
|
1994
2201
|
);
|
|
2202
|
+
for (const line of usageLines) say(line);
|
|
2203
|
+
if (countModel !== null && !bad.length && workspace) {
|
|
2204
|
+
try {
|
|
2205
|
+
const provider = countLine(await countAlways(path.resolve(dir), { ...options.countOptions, model: countModel }));
|
|
2206
|
+
total.providers.push(provider);
|
|
2207
|
+
say(provider);
|
|
2208
|
+
}
|
|
2209
|
+
catch (error) { throw new DoctorError(error instanceof ContextError ? error.message : "provider token count could not run"); }
|
|
2210
|
+
}
|
|
1995
2211
|
say();
|
|
1996
2212
|
if (bad.length) failed += 1;
|
|
1997
2213
|
}
|
|
1998
|
-
return failed ? 1 : 0;
|
|
2214
|
+
return complete(failed ? 1 : 0);
|
|
1999
2215
|
} catch (error) {
|
|
2216
|
+
complete(2);
|
|
2000
2217
|
// Anything reaching here could not be judged, a defect in doctor included: 2, never a verdict.
|
|
2001
2218
|
if (!options.quiet) {
|
|
2002
2219
|
process.stderr.write(`doctor: ${error instanceof DoctorError ? error.message : `unanticipated failure — ${error.stack ?? error}`}\n`);
|