@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.
- package/README.md +15 -4
- package/cli/README.md +55 -3
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- 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 +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- 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/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 +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +368 -60
- 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 +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- 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.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- 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 +262 -117
- 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
|
|
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 {
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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] === "--
|
|
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
|
-
|
|
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
|
-
|
|
2032
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
67
|
+
/** Index-only rails need `stage` for a created file. */
|
|
67
68
|
export const DRILLS = [
|
|
68
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|