@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.
Files changed (100) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +61 -6
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +781 -130
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +248 -31
  9. package/cli/drills.mjs +56 -9
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +230 -40
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/portulan.mjs +1 -1
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/review-meter.mjs +14 -9
  34. package/cli/ritual-contexts.mjs +103 -0
  35. package/cli/sessions.mjs +2 -1
  36. package/cli/spend-analysis.mjs +111 -0
  37. package/cli/spend-offer.md +53 -0
  38. package/cli/spend-offer.mjs +93 -0
  39. package/cli/stop-diagnostics.mjs +114 -0
  40. package/cli/stop-gate.mjs +133 -90
  41. package/cli/stop-policy.mjs +14 -0
  42. package/cli/symbols.mjs +55 -8
  43. package/cli/telemetry.mjs +66 -17
  44. package/cli/upgrade.mjs +62 -44
  45. package/cli/vendor.mjs +207 -125
  46. package/core/operating/context.md +401 -18
  47. package/core/operating/evolution.md +3 -2
  48. package/core/operating/loop.md +9 -4
  49. package/core/operating/sessions.md +6 -15
  50. package/core/personas/README.md +75 -0
  51. package/core/personas/implementer.md +3 -0
  52. package/core/personas/librarian.md +3 -0
  53. package/core/personas/reviewer.md +3 -0
  54. package/core/skills/clarify/SKILL.md +1 -1
  55. package/core/skills/consolidate/SKILL.md +1 -1
  56. package/core/templates/handoff.md +6 -2
  57. package/evals/README.md +23 -7
  58. package/evals/ab/personas/README.md +111 -0
  59. package/evals/ab/personas/acquisition.json +330 -0
  60. package/evals/ab/personas/bindings/implementer.txt +43 -0
  61. package/evals/ab/personas/bindings/librarian.txt +40 -0
  62. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  63. package/evals/ab/personas/corpus.json +69 -0
  64. package/evals/ab/personas/grader.mjs +171 -0
  65. package/evals/ab/personas/implementer.json +774 -0
  66. package/evals/ab/personas/implementer.md +22 -0
  67. package/evals/ab/personas/librarian.json +758 -0
  68. package/evals/ab/personas/librarian.md +22 -0
  69. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  70. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  71. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  72. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  73. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  74. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  75. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  76. package/evals/ab/personas/reviewer.json +758 -0
  77. package/evals/ab/personas/reviewer.md +22 -0
  78. package/evals/releases/0.3.0.json +150 -0
  79. package/evals/releases/0.3.0.md +68 -0
  80. package/evals/releases/0.4.0.json +154 -0
  81. package/evals/releases/0.4.0.md +69 -0
  82. package/package.json +7 -2
  83. package/packs/.claude-plugin/plugin.json +2 -1
  84. package/packs/README.md +7 -4
  85. package/packs/rituals/checkpoints/README.md +35 -0
  86. package/packs/rituals/checkpoints/pack.json +3 -2
  87. package/packs/tools/README.md +8 -8
  88. package/packs/tools/local-llm/README.md +176 -0
  89. package/packs/tools/local-llm/pack.json +32 -0
  90. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  91. package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
  92. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
  93. package/packs/tools/local-llm/verify/README.md +18 -0
  94. package/spec/README.md +37 -3
  95. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  96. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  97. package/spec/migrations/README.md +6 -0
  98. package/spec/pack.schema.json +12 -3
  99. package/spec/slots.md +272 -123
  100. 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, AGENT_DIR } from "./plugin-lint.mjs";
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 = fs.readFileSync(contained.real, "utf8");
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(fs.readFileSync(manifestPath, "utf8"));
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
- const found = resolvePack(name, roots);
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(fs.readFileSync(found.manifest, "utf8"));
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
- const shadowed = shadowedCopy(name, origin, roots, (r) => originOf.get(r)) ?? { dir: null };
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(fs.readFileSync(shadowed.manifest, "utf8"));
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
- // Both sides realpathed: a tree under a link (macOS `/var`) would otherwise refuse ordinary bindings.
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
- // Already reported by the claims checks; the declared path keeps the containment test running.
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
- const rel = path.join(AGENT_DIR, `${persona.key}.md`);
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
- // The key is a pack's free text, so it is contained before it is opened: lexically here, since the
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
- // ENOENT falls through to the read below, which reports the persona unbound.
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, "utf8");
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
- "and this is the one location a Claude Code host loads agents from",
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
- // Declared, not compared: a binding may grant less, or other, than its persona describes.
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 them — \`compile\` exits 2 on this shape rather than emitting a partial policy${incomplete}`,
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 live in git, which doctor does not read, so staleness belongs to `cli/librarian.mjs` — the scheduled pass, which may ask git and does"
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
- (always.verdict === "over" || always.verdict === "unjudged" ? fail : report)("context", always.line);
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 say = options.quiet ? () => {} : (line = "") => process.stdout.write(`${line}\n`);
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] === "--repo-root") {
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
- for (const f of findings) say(` ${ICON[f.severity]} ${f.check.padEnd(10)} ${f.message}`);
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`);