@sleepy_panda_srl/portulan 0.3.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 (87) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +55 -3
  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 +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  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 +123 -37
  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/recipe-output.mjs +91 -0
  29. package/cli/recipe-runner.md +68 -0
  30. package/cli/recipe-runner.mjs +131 -0
  31. package/cli/recipe-set.mjs +12 -3
  32. package/cli/ritual-contexts.mjs +103 -0
  33. package/cli/sessions.mjs +2 -1
  34. package/cli/spend-analysis.mjs +111 -0
  35. package/cli/spend-offer.md +53 -0
  36. package/cli/spend-offer.mjs +93 -0
  37. package/cli/stop-diagnostics.mjs +114 -0
  38. package/cli/stop-gate.mjs +133 -90
  39. package/cli/stop-policy.mjs +14 -0
  40. package/cli/symbols.mjs +55 -8
  41. package/cli/telemetry.mjs +19 -12
  42. package/cli/upgrade.mjs +62 -44
  43. package/cli/vendor.mjs +207 -125
  44. package/core/operating/context.md +368 -60
  45. package/core/operating/evolution.md +3 -2
  46. package/core/operating/loop.md +9 -4
  47. package/core/operating/sessions.md +6 -15
  48. package/core/personas/README.md +52 -10
  49. package/core/personas/implementer.md +3 -0
  50. package/core/personas/librarian.md +3 -0
  51. package/core/personas/reviewer.md +3 -0
  52. package/core/templates/handoff.md +6 -2
  53. package/evals/ab/personas/README.md +111 -0
  54. package/evals/ab/personas/acquisition.json +330 -0
  55. package/evals/ab/personas/bindings/implementer.txt +43 -0
  56. package/evals/ab/personas/bindings/librarian.txt +40 -0
  57. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  58. package/evals/ab/personas/corpus.json +69 -0
  59. package/evals/ab/personas/grader.mjs +171 -0
  60. package/evals/ab/personas/implementer.json +774 -0
  61. package/evals/ab/personas/implementer.md +22 -0
  62. package/evals/ab/personas/librarian.json +758 -0
  63. package/evals/ab/personas/librarian.md +22 -0
  64. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  65. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  66. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  67. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  68. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  69. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  70. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  71. package/evals/ab/personas/reviewer.json +758 -0
  72. package/evals/ab/personas/reviewer.md +22 -0
  73. package/evals/releases/0.4.0.json +154 -0
  74. package/evals/releases/0.4.0.md +69 -0
  75. package/package.json +6 -2
  76. package/packs/rituals/checkpoints/README.md +35 -0
  77. package/packs/rituals/checkpoints/pack.json +3 -2
  78. package/packs/tools/local-llm/README.md +46 -6
  79. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  80. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  81. package/spec/README.md +37 -4
  82. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  83. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  84. package/spec/migrations/README.md +6 -0
  85. package/spec/pack.schema.json +12 -3
  86. package/spec/slots.md +262 -117
  87. package/spec/workspace.schema.json +67 -11
package/cli/doctor.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // `doctor` — the Workspace Definition validator.
3
3
  //
4
- // node cli/doctor.mjs [--static] [--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";
9
11
  import { execFileSync } from "node:child_process";
10
12
  import path from "node:path";
@@ -20,15 +22,21 @@ import {
20
22
  packDifferences,
21
23
  shadowedCopy,
22
24
  personaDeclarations,
25
+ bindingDirectory,
26
+ readOwnedText,
23
27
  } from "./compile.mjs";
24
28
  import { isInside, recordType } from "./index.mjs";
25
- import { parseFrontmatter, AGENT_DIR } from "./plugin-lint.mjs";
29
+ import { parseFrontmatter } from "./frontmatter.mjs";
26
30
  import { resolveGovernor, AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
27
31
  import { composedId } from "./recipe-set.mjs";
28
- 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";
29
35
  import { formLine } from "./form.mjs";
30
36
  import { sessionsLine } from "./sessions.mjs";
31
- import { hostPaths, ledger, print, readSpend, worktrees, linesChanged } from "./ledger.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";
32
40
 
33
41
  const HERE = path.dirname(fileURLToPath(import.meta.url));
34
42
  const DEFAULT_SCHEMA = path.resolve(HERE, "..", "spec", "workspace.schema.json");
@@ -476,7 +484,7 @@ export function validateContributions(packDir, contributes, { fail, report, pack
476
484
  }
477
485
  let text;
478
486
  try {
479
- text = fs.readFileSync(contained.real, "utf8");
487
+ text = readOwnedText(contained.real, packDir);
480
488
  } catch (cause) {
481
489
  fail("packs", `\`${pack}\`'s persona \`${rel}\` could not be read — ${cause.code ?? cause.message}. Only a missing file means absent`);
482
490
  continue;
@@ -783,7 +791,7 @@ export async function inspect(workspaceDir, options = {}) {
783
791
  const manifestPath = path.join(dir, "workspace.json");
784
792
  let workspace;
785
793
  try {
786
- workspace = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
794
+ workspace = JSON.parse(readOwnedText(manifestPath, dir));
787
795
  } catch (cause) {
788
796
  // A verdict about the workspace, not an environment failure: exit 1, not 2.
789
797
  fail("schema", `no readable manifest at ${path.relative(process.cwd(), manifestPath)} — ${cause.message}`);
@@ -830,6 +838,9 @@ export async function inspect(workspaceDir, options = {}) {
830
838
  ["sessions", 11, workspace.sessions],
831
839
  ["spend", 12, workspace.spend],
832
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],
833
844
  ]) {
834
845
  if (major === 2 && minor < since && value !== undefined) {
835
846
  fail(
@@ -975,6 +986,8 @@ export async function inspect(workspaceDir, options = {}) {
975
986
  });
976
987
 
977
988
  // ---------------------------------------------------------------- cross-field
989
+ try { readStopPolicy(workspace); } catch (error) { fail("cross", error.message); }
990
+
978
991
  // Own recipes only: a composed recipe may never be the workspace's `verify.default`.
979
992
  const ids = (workspace.verify?.recipes ?? []).map((r) => r.id);
980
993
  if (!ids.includes(workspace.verify?.default)) {
@@ -1128,6 +1141,7 @@ export async function inspect(workspaceDir, options = {}) {
1128
1141
  }
1129
1142
 
1130
1143
  const composedPersonas = [];
1144
+ const contextContributions = [];
1131
1145
 
1132
1146
  let resolvedPackRoots = null;
1133
1147
  let packsUnresolved = 0;
@@ -1149,7 +1163,13 @@ export async function inspect(workspaceDir, options = {}) {
1149
1163
  const claimed = roots.filter((root) => originOf.get(root) !== "discovered");
1150
1164
  report("packs", `resolution root ${plan.source} — ${plan.why}`);
1151
1165
  for (const name of workspace.packs) {
1152
- 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
+ }
1153
1173
  if (!found.dir) {
1154
1174
  if (claimed.length === 0) {
1155
1175
  stats.unverifiable += 1;
@@ -1165,7 +1185,7 @@ export async function inspect(workspaceDir, options = {}) {
1165
1185
  }
1166
1186
  let manifest;
1167
1187
  try {
1168
- manifest = JSON.parse(fs.readFileSync(found.manifest, "utf8"));
1188
+ manifest = JSON.parse(readOwnedText(found.manifest, found.dir));
1169
1189
  } catch (cause) {
1170
1190
  fail("packs", `\`${name}\` resolves to ${path.relative(dir, found.manifest)} but its manifest is unreadable — ${cause.message}`);
1171
1191
  continue;
@@ -1192,6 +1212,10 @@ export async function inspect(workspaceDir, options = {}) {
1192
1212
  }
1193
1213
  continue;
1194
1214
  }
1215
+ let contextCap;
1216
+ try { contextCap = readContextCap(manifest, name); }
1217
+ catch (e) { fail("packs", e.message); continue; }
1218
+ contextContributions.push({ pack: name, contextCap });
1195
1219
  stats.packs += 1;
1196
1220
  const c = manifest.contributes ?? {};
1197
1221
 
@@ -1224,11 +1248,15 @@ export async function inspect(workspaceDir, options = {}) {
1224
1248
 
1225
1249
  // First match wins and discovered roots come first, so an installed copy can shadow the tree's.
1226
1250
  {
1227
- 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
+ }
1228
1256
  if (shadowed.dir) {
1229
1257
  let other = null;
1230
1258
  try {
1231
- other = JSON.parse(fs.readFileSync(shadowed.manifest, "utf8"));
1259
+ other = JSON.parse(readOwnedText(shadowed.manifest, shadowed.dir));
1232
1260
  } catch (cause) {
1233
1261
  report(
1234
1262
  "packs",
@@ -1249,6 +1277,11 @@ export async function inspect(workspaceDir, options = {}) {
1249
1277
  }
1250
1278
  }
1251
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
+
1252
1285
  if (workspace.librarian && !workspace.slots?.memory) {
1253
1286
  fail(
1254
1287
  "cross",
@@ -1280,6 +1313,8 @@ export async function inspect(workspaceDir, options = {}) {
1280
1313
  }
1281
1314
 
1282
1315
  const alwaysTokens = workspace.context?.always?.budget?.tokens;
1316
+ try { handoffPolicy(workspace); }
1317
+ catch (error) { fail("schema", error.message); }
1283
1318
  if (alwaysTokens !== undefined && !positive(alwaysTokens)) {
1284
1319
  fail(
1285
1320
  "schema",
@@ -1355,6 +1390,10 @@ export async function inspect(workspaceDir, options = {}) {
1355
1390
  }
1356
1391
  }
1357
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
+ }
1358
1397
  if (horizonRequests !== undefined && !positive(horizonRequests)) {
1359
1398
  fail(
1360
1399
  "schema",
@@ -1454,21 +1493,32 @@ export async function inspect(workspaceDir, options = {}) {
1454
1493
  `so there is no repository to look in. Unverifiable, not unbound`,
1455
1494
  );
1456
1495
  }
1457
- // Both sides realpathed: a tree under a link (macOS `/var`) would otherwise refuse ordinary bindings.
1496
+ // Compare canonical paths for aliased trees.
1458
1497
  let realTree = treeRoot;
1459
1498
  try {
1460
1499
  if (treeRoot) realTree = fs.realpathSync(treeRoot);
1461
1500
  } catch {
1462
- // 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
+ }
1463
1510
  }
1464
- for (const persona of treeRoot ? composedPersonas : []) {
1465
- 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`);
1466
1517
  const where = `\`${persona.pack}\`'s \`${persona.rel}\``;
1467
1518
  const keyed = persona.keyedBy === "declaration" ? "" : " (keyed by filename — the persona declares no `name`)";
1468
1519
  const file = path.join(treeRoot, rel);
1469
1520
 
1470
- // The key is a pack's free text, so it is contained before it is opened: lexically here, since the
1471
- // realpath test below is skipped where nothing exists.
1521
+ // Pack keys must stay inside the tree before any read.
1472
1522
  if (!isInside(treeRoot, path.resolve(treeRoot, rel))) {
1473
1523
  fail(
1474
1524
  "bindings",
@@ -1478,6 +1528,14 @@ export async function inspect(workspaceDir, options = {}) {
1478
1528
  continue;
1479
1529
  }
1480
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
+ }
1481
1539
  let real = null;
1482
1540
  try {
1483
1541
  real = fs.realpathSync(file);
@@ -1486,7 +1544,7 @@ export async function inspect(workspaceDir, options = {}) {
1486
1544
  report("bindings", `${where}'s binding at \`${rel}\` could not be resolved — ${cause.code ?? cause.message}. Unread, not absent`);
1487
1545
  continue;
1488
1546
  }
1489
- // ENOENT falls through to the read below, which reports the persona unbound.
1547
+ // Missing files remain unbound.
1490
1548
  }
1491
1549
  if (real !== null && !isInside(realTree, real)) {
1492
1550
  fail(
@@ -1497,15 +1555,19 @@ export async function inspect(workspaceDir, options = {}) {
1497
1555
  );
1498
1556
  continue;
1499
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
+ }
1500
1562
  let text;
1501
1563
  try {
1502
- text = fs.readFileSync(file, "utf8");
1564
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(file));
1503
1565
  } catch (cause) {
1504
1566
  if (cause.code === "ENOENT") {
1505
1567
  report(
1506
1568
  "bindings",
1507
1569
  `${where} has no host binding at \`${rel}\`${keyed} — reported, not failed: a persona without one is unbound rather than wrong, ` +
1508
- "and this is where a Claude Code plugin's agents load from — a repository that is not a plugin loads its agents from `.claude/agents/`",
1570
+ `the selected ${bindingDir === "agents" ? "plugin" : "project"} location is where this tree's Claude Code agents load`,
1509
1571
  );
1510
1572
  } else {
1511
1573
  report("bindings", `${where}'s binding at \`${rel}\` could not be read — ${cause.code ?? cause.message}. Unread, not absent`);
@@ -1538,7 +1600,7 @@ export async function inspect(workspaceDir, options = {}) {
1538
1600
  );
1539
1601
  continue;
1540
1602
  }
1541
- // Declared, not compared: a binding may grant less, or other, than its persona describes.
1603
+ // Tool grants are declared, not compared to charter prose.
1542
1604
  report("bindings", `${where} is bound by \`${rel}\`${keyed} — ${BINDING_OK}`);
1543
1605
  }
1544
1606
  }
@@ -1768,7 +1830,7 @@ export async function inspect(workspaceDir, options = {}) {
1768
1830
  : "";
1769
1831
  report(
1770
1832
  "enforcement",
1771
- `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}`,
1772
1834
  );
1773
1835
  }
1774
1836
  } catch (cause) {
@@ -1873,7 +1935,14 @@ export async function inspect(workspaceDir, options = {}) {
1873
1935
  );
1874
1936
 
1875
1937
  const always = alwaysLine(dir, workspace);
1876
- (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}`); }
1877
1946
 
1878
1947
  // Never a verdict: failing it would turn red every workspace drafted before the new form.
1879
1948
  let form;
@@ -1882,6 +1951,7 @@ export async function inspect(workspaceDir, options = {}) {
1882
1951
  } catch (error) {
1883
1952
  form = `not read — ${error.message}`;
1884
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";
1885
1955
  report("form", form);
1886
1956
 
1887
1957
  let sessions;
@@ -1891,10 +1961,71 @@ export async function inspect(workspaceDir, options = {}) {
1891
1961
  sessions = `not read — ${error.message}`;
1892
1962
  }
1893
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)}`);
1894
1969
 
1895
1970
  return { dir, workspace, findings, stats };
1896
1971
  }
1897
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
+
1898
2029
  /** Optional usage accounting: a data failure never changes the workspace-validation verdict. */
1899
2030
  export async function workspaceUsageReport(workspaceDir, workspace, options = {}) {
1900
2031
  const unmeasured = (why) => [` note spend unmeasured — ${why}`];
@@ -1908,12 +2039,15 @@ export async function workspaceUsageReport(workspaceDir, workspace, options = {}
1908
2039
  const host = hostPaths(options.env ?? process.env, options.home);
1909
2040
  if (host.why !== undefined) return unmeasured("the configured usage-record location is ambiguous");
1910
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 });
1911
2043
  const lines = [];
2044
+ const analysis = spendAnalysis(report).map(line => ` note spend ${line}`);
1912
2045
  if (report.collected.malformed) lines.push(` note spend partial data — ${report.collected.malformed} malformed usage line(s) were ignored`);
1913
2046
  if (report.figures.main.requests + report.figures.subagents.requests === 0 && report.local.receipts === 0) {
1914
- return [...lines, ...unmeasured("no host requests or local receipts on this branch are recorded here")];
2047
+ return [...lines, ...unmeasured("no host requests or local receipts on this branch are recorded here"), ...analysis];
1915
2048
  }
1916
2049
  print(report, (line) => lines.push(line));
2050
+ lines.push(...analysis);
1917
2051
  return lines;
1918
2052
  } catch {
1919
2053
  // Parser messages can quote a private record. Never print them or a raw usage line.
@@ -1935,9 +2069,13 @@ function usage() {
1935
2069
  return [
1936
2070
  "portulan doctor — validate a workspace against the Workspace Definition",
1937
2071
  "",
1938
- " portulan doctor [--static] [--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> ...]",
1939
2073
  "",
1940
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).",
1941
2079
  " --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
1942
2080
  " A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
1943
2081
  " --repo-root where the repositories this workspace's cards NAME are checked out, so their",
@@ -1953,14 +2091,25 @@ function usage() {
1953
2091
 
1954
2092
  /** Runs against each workspace directory and returns the exit code; never throws. */
1955
2093
  export async function run(argv, options = {}) {
1956
- 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
+ };
1957
2105
  // Before any other argument is judged, so help is never outranked by a complaint about the rest.
1958
2106
  if (argv.includes("--help") || argv.includes("-h")) {
1959
2107
  say(usage());
1960
- return 0;
2108
+ return complete(0, true);
1961
2109
  }
1962
2110
  try {
1963
2111
  let staticMode = false;
2112
+ let countModel = null;
1964
2113
  const namedRoots = [];
1965
2114
  let discoverPacks = false;
1966
2115
  const repoRoots = [];
@@ -1987,8 +2136,13 @@ export async function run(argv, options = {}) {
1987
2136
  };
1988
2137
  const REPO_ROOT = { unresolvable: "a repository ungoverned", holds: "repositories" };
1989
2138
  for (let i = 0; i < argv.length; i += 1) {
1990
- if (argv[i] === "--static") {
2139
+ if (argv[i] === "--agent") {
2140
+ continue;
2141
+ } else if (argv[i] === "--static") {
1991
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];
1992
2146
  } else if (argv[i] === "--repo-root") {
1993
2147
  repoRoots.push(directoryRoot("--repo-root", argv[i + 1], REPO_ROOT));
1994
2148
  i += 1;
@@ -2005,15 +2159,20 @@ export async function run(argv, options = {}) {
2005
2159
  }
2006
2160
  const bothAsked = namedWithAuto(namedRoots, discoverPacks);
2007
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");
2008
2163
  if (dirs.length === 0) {
2009
2164
  if (!options.quiet) {
2010
- process.stderr.write("usage: node cli/doctor.mjs [--static] [--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");
2011
2166
  }
2012
- return 2;
2167
+ return complete(2);
2013
2168
  }
2014
2169
 
2015
2170
  let failed = 0;
2016
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
+ }
2017
2176
  const roots = {
2018
2177
  ...(namedRoots.length ? { packRoots: namedRoots } : {}),
2019
2178
  ...(discoverPacks ? { discoverPacks } : {}),
@@ -2021,21 +2180,40 @@ export async function run(argv, options = {}) {
2021
2180
  };
2022
2181
  const { findings, stats, workspace } = await inspect(dir, { ...options, ...roots });
2023
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;
2024
2189
  say(display(dir));
2025
- 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}`);
2026
2197
  say(
2027
2198
  ` ${bad.length ? "RED" : "GREEN"} — ${bad.length} failure(s), ` +
2028
2199
  `${findings.length - bad.length} note(s), ${stats.claims} claim(s) checked` +
2029
2200
  (stats.unverifiable ? `, ${stats.unverifiable} unverifiable` : ""),
2030
2201
  );
2031
- if (!staticMode && !options.quiet && !bad.length && workspace) {
2032
- for (const line of await workspaceUsageReport(path.resolve(dir), workspace, options)) say(line);
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"); }
2033
2210
  }
2034
2211
  say();
2035
2212
  if (bad.length) failed += 1;
2036
2213
  }
2037
- return failed ? 1 : 0;
2214
+ return complete(failed ? 1 : 0);
2038
2215
  } catch (error) {
2216
+ complete(2);
2039
2217
  // Anything reaching here could not be judged, a defect in doctor included: 2, never a verdict.
2040
2218
  if (!options.quiet) {
2041
2219
  process.stderr.write(`doctor: ${error instanceof DoctorError ? error.message : `unanticipated failure — ${error.stack ?? error}`}\n`);
package/cli/drills.mjs CHANGED
@@ -13,10 +13,11 @@ import { isInside } from "./inside.mjs";
13
13
  import { recipeSet, resolverFor } from "./recipe-set.mjs";
14
14
 
15
15
  const RAIL_TIMEOUT_MS = 10 * 60 * 1000;
16
+ const RAIL_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
16
17
 
17
18
  export const DRILL_SESSION_PREFIX = "portulan-drill";
18
19
 
19
- /** Hooks answer in their stdout JSON and exit 0 either way, so their drills judge by a tell read from stdout. */
20
+ /** Hook drills judge stdout JSON: exit 0 carries either verdict. */
20
21
  export const NON_RECIPE_RAILS = [
21
22
  {
22
23
  id: "stop-gate",
@@ -63,9 +64,20 @@ export const NOT_DRILLED = [
63
64
  },
64
65
  ];
65
66
 
66
- /** A rail that reads only the index misses a created file unless the drill sets `stage`. */
67
+ /** Index-only rails need `stage` for a created file. */
67
68
  export const DRILLS = [
68
- // ------------------------------------------------------------------------- the yielded recipes
69
+ {
70
+ rail: "cache-stability",
71
+ perturb: {
72
+ file: ".claude/rules/portulan/boot.md",
73
+ find: "# Portulan boot card",
74
+ replace: "# Portulan boot card\n\nCurrent date: 2099-12-31",
75
+ },
76
+ exit: 1,
77
+ tell: "differ from the committed golden",
78
+ why: "A volatile prefix change must require review of the committed baseline (#516), even when two renders agree.",
79
+ },
80
+ // The yielded recipes
69
81
  {
70
82
  rail: "docs",
71
83
  perturb: {
@@ -259,7 +271,7 @@ export const DRILLS = [
259
271
  rail: "pack-identity",
260
272
  perturb: {
261
273
  file: "README.md",
262
- find: "**Current release: `0.3.0`**",
274
+ find: "**Current release: `0.4.0`**",
263
275
  replace: "**Current release: `0.1.3`** <!-- the drill edited this and did not stage it -->",
264
276
  },
265
277
  stage: false,
@@ -343,7 +355,7 @@ export const DRILLS = [
343
355
  rail: "version-carriers",
344
356
  perturb: {
345
357
  file: "README.md",
346
- find: "**Current release: `0.3.0`**",
358
+ find: "**Current release: `0.4.0`**",
347
359
  replace: "**Current release: `0.1.3`**",
348
360
  },
349
361
  // Staged: this rail reads the index (`git show :<path>`), so an unstaged edit is invisible to it.
@@ -385,6 +397,17 @@ export const DRILLS = [
385
397
  tell: "invokes cli/review-meter.mjs --fetch",
386
398
  why: "A verify recipe that can reach the network goes red about the world rather than about the tree, and this repository prohibits it in three carriers while nothing checked it. The drill proves the checker sees a real invocation — spread across continued lines, the spelling a recipe actually uses — rather than only the literal string a test would hand it.",
387
399
  },
400
+ {
401
+ rail: "telemetry",
402
+ perturb: {
403
+ file: ".portulan/verify/doctor.sh",
404
+ find: "node cli/doctor.mjs --static --pack-root packs",
405
+ replace: "node cli/doctor.mjs --pack-root packs",
406
+ },
407
+ exit: 1,
408
+ tell: "invokes cli/doctor.mjs without an immediate --static",
409
+ why: "Doctor reads local usage records by default. Removing static mode from its recipe must fail the source audit before verification can depend on this machine's records. The drill runs telemetry, never the altered doctor recipe; filesystem-denial tests separately prove the static runtime path does not consult usage files.",
410
+ },
388
411
  {
389
412
  rail: "ab",
390
413
  perturb: {
@@ -481,7 +504,7 @@ export const DRILLS = [
481
504
  why: "Loopback is the rail that keeps a local worker's context on the machine, and the runner holds it in code rather than in any permission rule the compiler emits, so a guard that stopped refusing would leave every other rail green. The drill removes the refusal and requires the pack's own recipe to see it.",
482
505
  },
483
506
 
484
- // ------------------------------------------------------- the coverage rail, which is itself a rail
507
+ // The coverage rail, which is itself a rail
485
508
  {
486
509
  rail: "drills",
487
510
  perturb: {
@@ -497,7 +520,7 @@ export const DRILLS = [
497
520
  why: "The allow-list is what stops a misspelled recipe id being silently reclassified as a rail of its own, leaving the real recipe undrilled while the coverage check reports green. A drill naming a rail nothing yields is the shortest path to that state.",
498
521
  },
499
522
 
500
- // ------------------------------------------------------------------------- the two non-recipe rails
523
+ // The two non-recipe rails
501
524
  {
502
525
  rail: "stop-gate",
503
526
  perturb: {
@@ -839,6 +862,7 @@ function runRail({ rail, worktree, stdin, workspaceRel }) {
839
862
  // After the spread, so a drill's own `cwd` cannot point the hook at another tree.
840
863
  input: `${JSON.stringify({ ...payload, cwd: worktree })}\n`,
841
864
  timeout: RAIL_TIMEOUT_MS,
865
+ maxBuffer: RAIL_MAX_BUFFER_BYTES,
842
866
  // Set, never inherited: the hooks read their tree and workspace from these two.
843
867
  env: { ...process.env, CLAUDE_PROJECT_DIR: worktree, PORTULAN_WORKSPACE: workspaceRel },
844
868
  });
@@ -850,6 +874,7 @@ function runRail({ rail, worktree, stdin, workspaceRel }) {
850
874
  cwd: worktree,
851
875
  encoding: "utf8",
852
876
  timeout: RAIL_TIMEOUT_MS,
877
+ maxBuffer: RAIL_MAX_BUFFER_BYTES,
853
878
  });
854
879
  if (result.error) throw new CouldNotRun(`rail \`${rail.id}\` could not run — ${result.error.message}`);
855
880
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
package/cli/finish.md ADDED
@@ -0,0 +1,34 @@
1
+ # Finish verification boundaries
2
+
3
+ Finish resolves the recipe set before staging tracked changes or creating its
4
+ provisional commit. An empty result from `treeRoots` asks for normal recipe-set
5
+ resolution; it does not mean verification succeeded without any recipes.
6
+
7
+ | Workspace and roots | Behavior |
8
+ | --- | --- |
9
+ | No composed packs | The tree's pack directories are not probed. Declared workspace recipes still run. |
10
+ | Tree roots contain every composed pack | Finish names those roots, avoiding an installed copy shadowing the tree. |
11
+ | Tree roots are absent, empty or incomplete | Normal recipe-set resolution and its shadow checks apply. An installed pack can supply the missing recipes; an unresolved pack refuses the operation before commit. |
12
+ | Explicit `--pack-root <directory>` | Only the named roots are used. Missing directories are refused, and missing packs do not fall back to an installed copy. |
13
+
14
+ The controls in `finish.test.mjs` cover these cases. For an absent tree directory,
15
+ the installed-pack fixture compares recipe-set's yielded identities against actual
16
+ execution markers. The unavailable-pack fixture checks that no commit hook ran and
17
+ that HEAD, the index, the worktree and the remote branch remain unchanged.
18
+
19
+ Bash parse failures are **could not run**, with exit 2 and the diagnostic included
20
+ in finish's report. A provisional commit made before the recipes ran is undone and
21
+ its changes stay staged; nothing is pushed. Both tested recipe forms parse after
22
+ the wrapper's `exec 2>&1` redirection. These controls do not demonstrate coverage
23
+ of Bash startup failures.
24
+
25
+ The manifest recipe format rejects newlines, carriage returns and tabs before
26
+ Bash runs. The CLI controls therefore use a single-line missing `fi` and a leading
27
+ `)`. Line numbers can differ between Bash versions. These are Bash's wrapper-script
28
+ diagnostics, not a general promise to translate recipe line numbers.
29
+ The historical two-line example in the review follow-up can be passed directly to
30
+ `runRecipe`, but is not accepted as a current manifest recipe.
31
+
32
+ This reference records concrete boundaries investigated for the historical review
33
+ follow-up. That review posted no findings identifying a different empty-root case;
34
+ these controls do not establish what its unspecified concern meant.