toolroll 0.9.33 → 0.9.36
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/CHANGELOG.md +57 -0
- package/README.md +7 -0
- package/dist/assignment.d.ts +1 -0
- package/dist/assignment.js +2 -2
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +17 -17
- package/dist/browser-crew.js +1 -0
- package/dist/browser-workspace.d.ts +5 -3
- package/dist/builder.js +4 -7
- package/dist/chat-acceptance.d.ts +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +124 -63
- package/dist/contracts/contract-test.d.ts +45 -0
- package/dist/contracts/contract-test.js +69 -0
- package/dist/contracts/contract.d.ts +87 -0
- package/dist/contracts/contract.js +308 -0
- package/dist/contracts/flow-propose.d.ts +325 -0
- package/dist/contracts/flow-propose.js +41 -0
- package/dist/contracts/flow.d.ts +19400 -0
- package/dist/contracts/flow.js +359 -0
- package/dist/contracts/handoff.d.ts +91 -0
- package/dist/contracts/handoff.js +82 -0
- package/dist/contracts/plan.d.ts +58 -0
- package/dist/contracts/plan.js +67 -0
- package/dist/contracts/proof.d.ts +136 -0
- package/dist/contracts/proof.js +110 -0
- package/dist/contracts/verification-receipt.d.ts +148 -0
- package/dist/contracts/verification-receipt.js +87 -0
- package/dist/demo.js +28 -11
- package/dist/desktop-update.d.ts +1 -1
- package/dist/evidence.d.ts +3 -40
- package/dist/evidence.js +7 -3
- package/dist/flow-gallery.d.ts +3 -2
- package/dist/flow-share.d.ts +10 -32
- package/dist/flow-share.js +65 -83
- package/dist/flow-starters.d.ts +2 -1
- package/dist/flow-triggers.d.ts +15 -72
- package/dist/flow-triggers.js +94 -83
- package/dist/flows.d.ts +37 -218
- package/dist/flows.js +350 -385
- package/dist/mate-tools.js +14 -44
- package/dist/mcp-connect.d.ts +3 -0
- package/dist/mcp-connect.js +16 -4
- package/dist/operate.js +83 -12
- package/dist/plan.d.ts +12 -12
- package/dist/plan.js +109 -72
- package/dist/planner-source.js +2 -1
- package/dist/planner.js +55 -25
- package/dist/process-recovery-eligibility.d.ts +2 -2
- package/dist/process-recovery-eligibility.js +13 -8
- package/dist/project-admission.d.ts +93 -0
- package/dist/project-admission.js +134 -0
- package/dist/project-delete-ui.js +5 -2
- package/dist/proof.d.ts +15 -91
- package/dist/proof.js +90 -249
- package/dist/result-evidence-readers.d.ts +42 -0
- package/dist/result-evidence-readers.js +91 -0
- package/dist/runner.d.ts +10 -0
- package/dist/runner.js +16 -0
- package/dist/scope.js +4 -3
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +112 -160
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +2 -0
- package/dist/store.js +4 -0
- package/dist/surface.js +16 -3
- package/dist/task-review-brief.d.ts +75 -0
- package/dist/task-review-brief.js +179 -0
- package/dist/text-limits.d.ts +87 -0
- package/dist/text-limits.js +86 -0
- package/dist/verification-evidence.js +24 -12
- package/dist/when-html.d.ts +2 -0
- package/dist/when-html.js +13 -0
- package/package.json +3 -2
package/dist/browser-crew.js
CHANGED
|
@@ -47,6 +47,7 @@ export function browserCrewFromIndex(page, conversationId) {
|
|
|
47
47
|
id: summary.rootId, title: summary.title, project: summary.repo,
|
|
48
48
|
state: summary.assignmentState, label: status.label, tone: status.tone, href: link(href), resultHref: resultHref === null ? null : link(resultHref),
|
|
49
49
|
action: action === null || actionHref === null ? null : { label: action.label, href: link(actionHref) },
|
|
50
|
+
updatedAt: summary.updatedAt,
|
|
50
51
|
...(summary.lead === undefined ? {} : { lead: summary.lead }),
|
|
51
52
|
};
|
|
52
53
|
return { item, rank: status.rank };
|
|
@@ -47,6 +47,8 @@ export type BrowserCrewItem = {
|
|
|
47
47
|
label: string;
|
|
48
48
|
href: string;
|
|
49
49
|
} | null;
|
|
50
|
+
/** When the task family last changed (the index's own sort time), shown as the row's age. */
|
|
51
|
+
updatedAt: string;
|
|
50
52
|
/** "<name> is on it.": the person's own lead took it on, by the name they gave it. */
|
|
51
53
|
lead?: string;
|
|
52
54
|
};
|
|
@@ -800,9 +802,9 @@ export type BrowserFlowStage = {
|
|
|
800
802
|
wait?: {
|
|
801
803
|
for: "reply" | "time" | "hours";
|
|
802
804
|
minutes: number;
|
|
803
|
-
from?: string;
|
|
804
|
-
to?: string;
|
|
805
|
-
timeZone?: string;
|
|
805
|
+
from?: string | undefined;
|
|
806
|
+
to?: string | undefined;
|
|
807
|
+
timeZone?: string | undefined;
|
|
806
808
|
};
|
|
807
809
|
/** A Pull request zone merges once checks pass (after a person approved), this way. */
|
|
808
810
|
merge?: "squash" | "merge" | "rebase" | undefined;
|
package/dist/builder.js
CHANGED
|
@@ -63,6 +63,7 @@ import { openLiveLog } from "./live.js";
|
|
|
63
63
|
import { CheckProgressTracker } from "./check-progress.js";
|
|
64
64
|
import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, isImagePath, WORKTREE_EVIDENCE_DIR, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
|
|
65
65
|
import { PROOF_LIMITS, parseProof, serializeProof, adjudicate, artifactManifestOnly, sameDiffStatFacts } from "./proof.js";
|
|
66
|
+
import { parseHandoffArtifact } from "./contracts/handoff.js";
|
|
66
67
|
import { captureReviewContext } from "./review-context.js";
|
|
67
68
|
import { authoritySnapshotDigest, classifyRevisionAuthority, isMilestoneRegression, milestonesOf, parseExecutionPlanDocument, parsePlanRevisionProposal, parseProgressSnapshot, renderExecutionPlanDocument, PROGRESS_LIMITS, REVISION_LIMITS, } from "./plan.js";
|
|
68
69
|
import { maybeSettleRepairChain } from "./dispose.js";
|
|
@@ -1115,11 +1116,9 @@ export async function build(store, request) {
|
|
|
1115
1116
|
try {
|
|
1116
1117
|
const verified = readVerifiedArtifact(root, handoffArtifact);
|
|
1117
1118
|
if (verified.ok) {
|
|
1118
|
-
const parsed =
|
|
1119
|
-
if (parsed.branch === branch &&
|
|
1120
|
-
|
|
1121
|
-
parsed.freshness?.currentAsOf === baseRevision) {
|
|
1122
|
-
previousHandoff = `A previous attempt (${String(parsed.outcome ?? "finished")}) left the branch exactly where it now stands and concluded: ${parsed.conclusion}`;
|
|
1119
|
+
const parsed = parseHandoffArtifact(verified.content.toString("utf8"));
|
|
1120
|
+
if (parsed.ok && parsed.value.branch === branch && parsed.value.freshness.currentAsOf === baseRevision) {
|
|
1121
|
+
previousHandoff = `A previous attempt (${parsed.value.outcome}) left the branch exactly where it now stands and concluded: ${parsed.value.conclusion}`;
|
|
1123
1122
|
}
|
|
1124
1123
|
}
|
|
1125
1124
|
}
|
|
@@ -1740,7 +1739,6 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1740
1739
|
store.setRunPhase(request.runId, "capturing-evidence");
|
|
1741
1740
|
const diffEvidence = await captureTerminalDiff(store, git, worktree, pinnedBase, baseRevision, root, request.runId, clock());
|
|
1742
1741
|
storeHandoffArtifact(store, root, {
|
|
1743
|
-
schema: 1,
|
|
1744
1742
|
taskId,
|
|
1745
1743
|
runId: request.runId,
|
|
1746
1744
|
provider,
|
|
@@ -1835,7 +1833,6 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1835
1833
|
store.setRunPhase(request.runId, "capturing-evidence");
|
|
1836
1834
|
const diffEvidence = await captureTerminalDiff(store, git, worktree, pinnedBase, head, root, request.runId, clock());
|
|
1837
1835
|
storeHandoffArtifact(store, root, {
|
|
1838
|
-
schema: 1,
|
|
1839
1836
|
taskId,
|
|
1840
1837
|
runId: request.runId,
|
|
1841
1838
|
provider,
|
|
@@ -45,7 +45,7 @@ export declare function readAcceptanceEvidence(store: Store, who: VerifiedApprov
|
|
|
45
45
|
note: string | null;
|
|
46
46
|
} | null;
|
|
47
47
|
evidence: {
|
|
48
|
-
kind:
|
|
48
|
+
kind: "changed-path" | "check" | "manual-review" | "screenshot";
|
|
49
49
|
reference: string | null;
|
|
50
50
|
}[];
|
|
51
51
|
coverageGaps: (string | null)[];
|
package/dist/cli.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type ParseResult = {
|
|
|
39
39
|
error: string;
|
|
40
40
|
};
|
|
41
41
|
type Write = (line: string) => void;
|
|
42
|
-
export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path>
|
|
42
|
+
export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> add a project; it is in the console at once (no path: the repo you are in)\n toolroll repos remove <path>\n take it out of the console; its tasks and results stay\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll lead say \"<text>\" (the lead) one short message in its person's chat\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
|
|
43
43
|
export declare function parseArgs(argv: readonly string[]): ParseResult;
|
|
44
44
|
/** The commands that operate the queue rather than report on the world. */
|
|
45
45
|
/** Every verb the CLI routes OUTSIDE runOperate. Exported (with
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,9 @@ import { existsSync, lstatSync, realpathSync, unlinkSync, writeFileSync } from "
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
-
import { configPath, loadRepos
|
|
17
|
+
import { configPath, loadRepos } from "./repos.js";
|
|
18
|
+
import { addedWords, admitProject, commandLineActor, consoleRunning, proveProjectRoot, registryBeside, releaseProject } from "./project-admission.js";
|
|
19
|
+
import { envValue } from "./names.js";
|
|
18
20
|
import { CAPABILITIES, ENVELOPE_VERSION, capturedEnvelope, envelopeJson, onEnvelopeCaptured, resetCapturedEnvelope } from "./envelope.js";
|
|
19
21
|
import { applyClaudeCodeInstall, applyInstall, contextBlock, defaultClaudeCodeSkillDir, planClaudeCodeInstall, planInstall, } from "./skills.js";
|
|
20
22
|
import { GUIDES, guideNamed } from "./guides.js";
|
|
@@ -42,8 +44,9 @@ Usage
|
|
|
42
44
|
toolroll pulls report what is waiting on a person
|
|
43
45
|
toolroll graph report which work graph is already here
|
|
44
46
|
toolroll repos list connected repositories, and how to adjust
|
|
45
|
-
toolroll repos add <path>
|
|
47
|
+
toolroll repos add <path> add a project; it is in the console at once (no path: the repo you are in)
|
|
46
48
|
toolroll repos remove <path>
|
|
49
|
+
take it out of the console; its tasks and results stay
|
|
47
50
|
toolroll repos add-from-github <owner/name> --root <dir>
|
|
48
51
|
preview, then clone and connect (--yes)
|
|
49
52
|
toolroll link put \`toolroll\` on your PATH
|
|
@@ -545,15 +548,26 @@ async function runReposCommand(argv, write, onboard) {
|
|
|
545
548
|
// `repos` honors the machine contract like everything else (audit TG-3):
|
|
546
549
|
// --json answers with one envelope, whatever the outcome.
|
|
547
550
|
const json = argv.includes("--json");
|
|
548
|
-
|
|
551
|
+
// The lead adds with its token (--token or TOOLROLL_LEAD_TOKEN); the owner needs none.
|
|
552
|
+
const tokenAt = argv.indexOf("--token");
|
|
553
|
+
const tokenFlag = tokenAt === -1 ? undefined : argv[tokenAt + 1];
|
|
554
|
+
const bare = argv.filter((argument, index) => argument !== "--json" && (tokenAt === -1 || (index !== tokenAt && index !== tokenAt + 1)));
|
|
549
555
|
const [action, ...paths] = bare;
|
|
550
|
-
const
|
|
556
|
+
const databaseFile = databasePath(process.env, homedir());
|
|
557
|
+
const file = registryBeside(databaseFile);
|
|
558
|
+
if (tokenAt !== -1 && (tokenFlag === undefined || tokenFlag.startsWith("-"))) {
|
|
559
|
+
const message = "--token needs the lead token";
|
|
560
|
+
write(json ? envelopeJson({ ok: false, command: `repos ${action ?? ""}`.trim(), reason: "usage", message }) : message);
|
|
561
|
+
return USAGE_EXIT;
|
|
562
|
+
}
|
|
563
|
+
const envLead = envValue(process.env, "LEAD_TOKEN");
|
|
564
|
+
const leadToken = tokenFlag ?? (envLead === undefined || envLead === "" ? undefined : envLead);
|
|
551
565
|
// Dispatched BEFORE the registry load (verification finding 3): a
|
|
552
566
|
// malformed registry must not mask this command's own parsing, its
|
|
553
567
|
// read-only preview, or its taxonomy — enrollment reads the registry
|
|
554
568
|
// through the locked primitive at the moment it writes.
|
|
555
569
|
if (action === "add-from-github") {
|
|
556
|
-
return addFromGithubCommand(bare.slice(1), json, file,
|
|
570
|
+
return addFromGithubCommand(bare.slice(1), json, write, { databaseFile, registryFile: file, ...(leadToken === undefined ? {} : { leadToken }) }, onboard);
|
|
557
571
|
}
|
|
558
572
|
const loaded = await loadRepos(file);
|
|
559
573
|
if ("error" in loaded) {
|
|
@@ -574,24 +588,45 @@ async function runReposCommand(argv, write, onboard) {
|
|
|
574
588
|
}
|
|
575
589
|
// `repos add` with no path means the repository you are standing in.
|
|
576
590
|
const targets = (paths.length > 0 ? paths : [process.cwd()]).map(path => resolve(path));
|
|
591
|
+
const lines = [];
|
|
592
|
+
const said = {};
|
|
593
|
+
const say = json ? (line) => lines.push(line) : write;
|
|
594
|
+
const code = await withAdmission({ databaseFile, registryFile: file, ...(leadToken === undefined ? {} : { leadToken }) }, say, said, admission => action === "add" ? addToRepos(admission, targets, say, said) : removeFromRepos(admission, targets, say, said));
|
|
577
595
|
if (json) {
|
|
578
|
-
const lines = [];
|
|
579
|
-
const said = {};
|
|
580
|
-
const code = action === "add"
|
|
581
|
-
? await addToRepos(loaded.repos, targets, file, line => lines.push(line), said)
|
|
582
|
-
: await removeFromRepos(loaded.repos, targets, file, line => lines.push(line), said);
|
|
583
596
|
write(envelopeJson({
|
|
584
597
|
ok: code === 0,
|
|
585
598
|
command: `repos ${action}`,
|
|
586
599
|
...(code === 0 ? {} : { reason: said.reason ?? "usage" }),
|
|
587
600
|
message: lines.join(" "),
|
|
588
601
|
targets,
|
|
602
|
+
...(said.projects === undefined ? {} : { projects: said.projects }),
|
|
589
603
|
}));
|
|
590
|
-
return code;
|
|
591
604
|
}
|
|
592
|
-
return
|
|
593
|
-
|
|
594
|
-
|
|
605
|
+
return code;
|
|
606
|
+
}
|
|
607
|
+
/** Open this installation's database as the owner (or its lead) for one addition or removal. */
|
|
608
|
+
async function withAdmission(where, write, said, body) {
|
|
609
|
+
let store;
|
|
610
|
+
try {
|
|
611
|
+
store = openStore(where.databaseFile);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
said.reason = "database";
|
|
615
|
+
write(`Toolroll's database couldn't be opened (${String(error.message ?? error)}). Nothing changed.`);
|
|
616
|
+
return 1;
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
const actor = commandLineActor(store, { databaseFile: where.databaseFile, ...(where.leadToken === undefined ? {} : { leadToken: where.leadToken }) });
|
|
620
|
+
if ("ok" in actor) {
|
|
621
|
+
said.reason = "refused";
|
|
622
|
+
write(actor.message);
|
|
623
|
+
return 3;
|
|
624
|
+
}
|
|
625
|
+
return await body({ store, registryFile: where.registryFile, actor });
|
|
626
|
+
}
|
|
627
|
+
finally {
|
|
628
|
+
store.close();
|
|
629
|
+
}
|
|
595
630
|
}
|
|
596
631
|
function listRepos(repos, file, write) {
|
|
597
632
|
if (repos.length === 0) {
|
|
@@ -606,59 +641,67 @@ function listRepos(repos, file, write) {
|
|
|
606
641
|
for (const repo of repos)
|
|
607
642
|
write(` ${repo}${existsSync(repo) ? "" : " (missing)"}`);
|
|
608
643
|
write("");
|
|
609
|
-
write(" toolroll repos add <path>
|
|
610
|
-
write(" toolroll repos remove <path>
|
|
644
|
+
write(" toolroll repos add <path> add another; it is in the console at once");
|
|
645
|
+
write(" toolroll repos remove <path> take one out of the console; its tasks stay");
|
|
611
646
|
write(" toolroll repos add-from-github <owner/name> --root <dir> clone from GitHub and connect");
|
|
612
647
|
write(" toolroll --all report everything, ignoring this list");
|
|
613
648
|
write(` ${file}`);
|
|
614
649
|
return 0;
|
|
615
650
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
// lock or a malformed registry is an operational fact, never "usage".
|
|
632
|
-
if (said !== undefined)
|
|
633
|
-
said.reason = wrote.reason;
|
|
634
|
-
write(`could not update the connected list — ${wrote.message}`);
|
|
635
|
-
return 1;
|
|
651
|
+
/** The exit code an admission failure answers with: a folder problem is the caller's to fix (usage). */
|
|
652
|
+
function admissionExit(reason) {
|
|
653
|
+
return reason === "not-git" || reason === "unavailable" ? USAGE_EXIT : 1;
|
|
654
|
+
}
|
|
655
|
+
async function addToRepos(admission, targets, write, said) {
|
|
656
|
+
// Every folder is proved before anything is written: one that is not a
|
|
657
|
+
// repository fails here, not later and further away.
|
|
658
|
+
let refused = false;
|
|
659
|
+
for (const path of targets) {
|
|
660
|
+
const proved = await proveProjectRoot(path);
|
|
661
|
+
if (!proved.ok) {
|
|
662
|
+
write(proved.message);
|
|
663
|
+
said.reason ??= proved.reason;
|
|
664
|
+
refused = true;
|
|
665
|
+
}
|
|
636
666
|
}
|
|
637
|
-
if (
|
|
638
|
-
|
|
639
|
-
|
|
667
|
+
if (refused)
|
|
668
|
+
return USAGE_EXIT;
|
|
669
|
+
const now = new Date();
|
|
670
|
+
const running = consoleRunning(admission.store, now);
|
|
671
|
+
const projects = [];
|
|
672
|
+
for (const path of targets) {
|
|
673
|
+
const admitted = await admitProject(admission.store, { registryFile: admission.registryFile, path, actor: admission.actor, now });
|
|
674
|
+
if (!admitted.ok) {
|
|
675
|
+
// The updater's own taxonomy survives (verification finding 4): a held
|
|
676
|
+
// lock or a malformed registry is an operational fact, never "usage".
|
|
677
|
+
said.reason = admitted.reason;
|
|
678
|
+
said.projects = projects;
|
|
679
|
+
write(admitted.message);
|
|
680
|
+
return admissionExit(admitted.reason);
|
|
681
|
+
}
|
|
682
|
+
projects.push({ repo: admitted.repo, name: admitted.name, added: admitted.added });
|
|
683
|
+
write(addedWords(admitted.name, admitted.added, running));
|
|
640
684
|
}
|
|
641
|
-
|
|
642
|
-
write(`Connected ${path}`);
|
|
643
|
-
write("");
|
|
644
|
-
write("`toolroll` now reports these. `toolroll --all` still shows everything.");
|
|
685
|
+
said.projects = projects;
|
|
645
686
|
return 0;
|
|
646
687
|
}
|
|
647
|
-
async function removeFromRepos(
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
688
|
+
async function removeFromRepos(admission, targets, write, said) {
|
|
689
|
+
const now = new Date();
|
|
690
|
+
const projects = [];
|
|
691
|
+
for (const path of targets) {
|
|
692
|
+
const released = await releaseProject(admission.store, { registryFile: admission.registryFile, path, actor: admission.actor, now });
|
|
693
|
+
if (!released.ok) {
|
|
694
|
+
said.reason = released.reason;
|
|
695
|
+
said.projects = projects;
|
|
696
|
+
write(released.message);
|
|
697
|
+
return admissionExit(released.reason);
|
|
698
|
+
}
|
|
699
|
+
projects.push({ repo: released.repo, name: released.name, removed: released.removed });
|
|
700
|
+
write(released.removed
|
|
701
|
+
? `Removed ${released.name}; it is no longer in the console. Its tasks and results stay saved.`
|
|
702
|
+
: `${released.name} isn't added, so nothing changed.`);
|
|
659
703
|
}
|
|
660
|
-
|
|
661
|
-
write(`Disconnected ${path}`);
|
|
704
|
+
said.projects = projects;
|
|
662
705
|
return 0;
|
|
663
706
|
}
|
|
664
707
|
/**
|
|
@@ -669,7 +712,7 @@ async function removeFromRepos(existing, targets, file, write, said) {
|
|
|
669
712
|
* primitive. Preview by default; --yes clones. Windows refuses like the
|
|
670
713
|
* console does — tree death cannot be proven there.
|
|
671
714
|
*/
|
|
672
|
-
async function addFromGithubCommand(argv, json,
|
|
715
|
+
async function addFromGithubCommand(argv, json, write, where, onboard) {
|
|
673
716
|
const command = "repos add-from-github";
|
|
674
717
|
const answer = (reason, message, code, extra = {}) => {
|
|
675
718
|
write(json ? envelopeJson({ ok: false, command, reason, message, ...extra }) : message);
|
|
@@ -761,6 +804,12 @@ async function addFromGithubCommand(argv, json, file, write, onboard) {
|
|
|
761
804
|
if (large && !largeOk) {
|
|
762
805
|
return answer("large", `${previewed.preview.nameWithOwner} is large or of unknown size (${size}) — add --large-ok to clone it anyway; nothing was written`, 3);
|
|
763
806
|
}
|
|
807
|
+
// Who may add is settled before anything is cloned.
|
|
808
|
+
const said = {};
|
|
809
|
+
const problems = [];
|
|
810
|
+
const allowed = await withAdmission(where, line => problems.push(line), said, async () => 0);
|
|
811
|
+
if (allowed !== 0)
|
|
812
|
+
return answer(said.reason ?? "refused", problems.join(" "), allowed);
|
|
764
813
|
const cloned = await (onboard?.clone ?? cloneGithubRepo)(previewed.preview.nameWithOwner, root, async (cwd) => {
|
|
765
814
|
const proof = await execRun("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 15_000 });
|
|
766
815
|
return { code: proof.code, stdout: proof.stdout };
|
|
@@ -770,15 +819,27 @@ async function addFromGithubCommand(argv, json, file, write, onboard) {
|
|
|
770
819
|
if (cloned.target !== target) {
|
|
771
820
|
return answer("malformed", "the clone answered with a different path than the preview promised — refused; nothing was connected", 1);
|
|
772
821
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
822
|
+
// The clone joins through the same admission as `repos add` and the console.
|
|
823
|
+
let words = "";
|
|
824
|
+
const code = await withAdmission(where, line => problems.push(line), said, async (admission) => {
|
|
825
|
+
const now = new Date();
|
|
826
|
+
const admitted = await admitProject(admission.store, { registryFile: admission.registryFile, path: cloned.target, actor: admission.actor, now });
|
|
827
|
+
if (!admitted.ok) {
|
|
828
|
+
said.reason = admitted.reason;
|
|
829
|
+
problems.push(`${cloned.target} is cloned but not added — ${admitted.message} Add it with \`repos add ${cloned.target}\`.`);
|
|
830
|
+
return 1;
|
|
831
|
+
}
|
|
832
|
+
words = addedWords(admitted.name, admitted.added, consoleRunning(admission.store, now));
|
|
833
|
+
return 0;
|
|
834
|
+
});
|
|
835
|
+
if (code !== 0)
|
|
836
|
+
return answer(said.reason ?? "failed", problems.join(" "), code);
|
|
777
837
|
if (json) {
|
|
778
838
|
write(envelopeJson({ ok: true, command, target: cloned.target, nameWithOwner: previewed.preview.nameWithOwner }));
|
|
779
839
|
return 0;
|
|
780
840
|
}
|
|
781
|
-
write(`Cloned ${previewed.preview.nameWithOwner} into ${cloned.target}
|
|
841
|
+
write(`Cloned ${previewed.preview.nameWithOwner} into ${cloned.target}.`);
|
|
842
|
+
write(words);
|
|
782
843
|
write("Large-file objects were not downloaded — run `git lfs pull` in the repository when you need them.");
|
|
783
844
|
return 0;
|
|
784
845
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract test every schema gets (docs/plans/zod-revamp.md, ground rule 7), framework-neutral so any test file
|
|
3
|
+
* can use it: each check returns the failures it found (empty when the contract holds), and `assertContract` throws
|
|
4
|
+
* them all at once.
|
|
5
|
+
*
|
|
6
|
+
* - The round trip toJSONSchema → fromJSONSchema → toJSONSchema loses no keyword.
|
|
7
|
+
* - Listed valid samples parse, including payloads saved by older versions.
|
|
8
|
+
* - Listed invalid samples are refused, and the refusal names each listed path (`steps[0].routes[0].goesTo: ...`).
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
/** Every keyword in a JSON Schema as `pointer keyword=value`, sorted, so two schemas compare keyword by keyword. */
|
|
12
|
+
export declare function jsonSchemaKeywords(schema: unknown, pointer?: string): string[];
|
|
13
|
+
/** The keywords the JSON Schema round trip drops or changes; empty when it loses nothing. */
|
|
14
|
+
export declare function roundTripLoss(schema: z.ZodType): string[];
|
|
15
|
+
/** What a contract's reader says about one input: accepted, or refused with path-named lines. */
|
|
16
|
+
export type SampleVerdict = {
|
|
17
|
+
ok: true;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
lines: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
export type ValidSample = {
|
|
23
|
+
name: string;
|
|
24
|
+
input: unknown;
|
|
25
|
+
};
|
|
26
|
+
/** An input the contract refuses; `paths` are the paths its refusal must name (`goal`, `acceptance[0].evidence[0]`). */
|
|
27
|
+
export type InvalidSample = {
|
|
28
|
+
name: string;
|
|
29
|
+
input: unknown;
|
|
30
|
+
paths: readonly string[];
|
|
31
|
+
};
|
|
32
|
+
export type ContractSpec = {
|
|
33
|
+
/** The model-facing schema, when the contract has one: its JSON Schema must survive the round trip. */
|
|
34
|
+
schema?: z.ZodType;
|
|
35
|
+
/** The contract's reader, the same one production uses. */
|
|
36
|
+
read: (input: unknown) => SampleVerdict;
|
|
37
|
+
valid: readonly ValidSample[];
|
|
38
|
+
invalid: readonly InvalidSample[];
|
|
39
|
+
};
|
|
40
|
+
/** Every way the samples disagree with the contract: a valid one refused, an invalid one accepted, a path unnamed. */
|
|
41
|
+
export declare function replaySamples(spec: Pick<ContractSpec, "read" | "valid" | "invalid">): string[];
|
|
42
|
+
/** Every failure of the contract test, round trip and samples together. */
|
|
43
|
+
export declare function contractFailures(spec: ContractSpec): string[];
|
|
44
|
+
/** Throws every failure at once; returns quietly when the contract holds. */
|
|
45
|
+
export declare function assertContract(spec: ContractSpec): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract test every schema gets (docs/plans/zod-revamp.md, ground rule 7), framework-neutral so any test file
|
|
3
|
+
* can use it: each check returns the failures it found (empty when the contract holds), and `assertContract` throws
|
|
4
|
+
* them all at once.
|
|
5
|
+
*
|
|
6
|
+
* - The round trip toJSONSchema → fromJSONSchema → toJSONSchema loses no keyword.
|
|
7
|
+
* - Listed valid samples parse, including payloads saved by older versions.
|
|
8
|
+
* - Listed invalid samples are refused, and the refusal names each listed path (`steps[0].routes[0].goesTo: ...`).
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { toModelSchema } from "./contract.js";
|
|
12
|
+
/** Every keyword in a JSON Schema as `pointer keyword=value`, sorted, so two schemas compare keyword by keyword. */
|
|
13
|
+
export function jsonSchemaKeywords(schema, pointer = "") {
|
|
14
|
+
if (Array.isArray(schema))
|
|
15
|
+
return schema.flatMap((item, index) => jsonSchemaKeywords(item, `${pointer}/${index}`)).sort();
|
|
16
|
+
if (schema === null || typeof schema !== "object")
|
|
17
|
+
return [];
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
20
|
+
if (value !== null && typeof value === "object") {
|
|
21
|
+
if (Array.isArray(value) && value.every(item => item === null || typeof item !== "object"))
|
|
22
|
+
out.push(`${pointer} ${key}=${JSON.stringify(value)}`);
|
|
23
|
+
else
|
|
24
|
+
out.push(...jsonSchemaKeywords(value, `${pointer}/${key}`));
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
out.push(`${pointer} ${key}=${JSON.stringify(value)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out.sort();
|
|
31
|
+
}
|
|
32
|
+
/** The keywords the JSON Schema round trip drops or changes; empty when it loses nothing. */
|
|
33
|
+
export function roundTripLoss(schema) {
|
|
34
|
+
const before = toModelSchema(schema);
|
|
35
|
+
const after = toModelSchema(z.fromJSONSchema(before));
|
|
36
|
+
const kept = new Set(jsonSchemaKeywords(after));
|
|
37
|
+
return jsonSchemaKeywords(before).filter(keyword => !kept.has(keyword)).map(keyword => `lost in the round trip: ${keyword}`);
|
|
38
|
+
}
|
|
39
|
+
/** Every way the samples disagree with the contract: a valid one refused, an invalid one accepted, a path unnamed. */
|
|
40
|
+
export function replaySamples(spec) {
|
|
41
|
+
const failures = [];
|
|
42
|
+
for (const sample of spec.valid) {
|
|
43
|
+
const verdict = spec.read(sample.input);
|
|
44
|
+
if (!verdict.ok)
|
|
45
|
+
failures.push(`${sample.name}: refused — ${verdict.lines.join("; ")}`);
|
|
46
|
+
}
|
|
47
|
+
for (const sample of spec.invalid) {
|
|
48
|
+
const verdict = spec.read(sample.input);
|
|
49
|
+
if (verdict.ok) {
|
|
50
|
+
failures.push(`${sample.name}: accepted, expected a refusal naming ${sample.paths.join(", ")}`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
for (const path of sample.paths) {
|
|
54
|
+
if (!verdict.lines.some(line => line.startsWith(`${path}:`)))
|
|
55
|
+
failures.push(`${sample.name}: no line names ${path} — ${verdict.lines.join("; ")}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return failures;
|
|
59
|
+
}
|
|
60
|
+
/** Every failure of the contract test, round trip and samples together. */
|
|
61
|
+
export function contractFailures(spec) {
|
|
62
|
+
return [...(spec.schema === undefined ? [] : roundTripLoss(spec.schema)), ...replaySamples(spec)];
|
|
63
|
+
}
|
|
64
|
+
/** Throws every failure at once; returns quietly when the contract holds. */
|
|
65
|
+
export function assertContract(spec) {
|
|
66
|
+
const failures = contractFailures(spec);
|
|
67
|
+
if (failures.length > 0)
|
|
68
|
+
throw new Error(`the contract does not hold:\n${failures.map(one => `- ${one}`).join("\n")}`);
|
|
69
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one way Toolroll describes a contract (docs/plans/zod-revamp.md): one Zod schema, and everything else — the
|
|
3
|
+
* TypeScript type, the runtime check, the JSON Schema a model gets, the text limits — derived from it.
|
|
4
|
+
*
|
|
5
|
+
* The helpers here hold the ground rules every contract shares: limits come from TEXT_LIMITS, persisted payloads are
|
|
6
|
+
* versioned, refusals name the path, and a model-facing schema is exactly what its JSON Schema says (no refinements or
|
|
7
|
+
* transforms; those checks run in plain code after parsing, with a named error).
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { type TextLimitKey } from "../text-limits.js";
|
|
11
|
+
/**
|
|
12
|
+
* A string bounded by its TEXT_LIMITS entry, never a literal. The limit rule rides along as the field's description,
|
|
13
|
+
* so a model reads the bound before it writes. Zod counts UTF-16 code units; for a limit named in bytes this is the
|
|
14
|
+
* schema's necessary bound (a string within N bytes is within N code units), and the byte count itself is checked in
|
|
15
|
+
* plain code after parsing.
|
|
16
|
+
*/
|
|
17
|
+
export declare function limited(field: string, limitKey: TextLimitKey): z.ZodString;
|
|
18
|
+
/** A versioned payload envelope: a strict object whose `version` is exactly `version`. */
|
|
19
|
+
export declare function versioned<V extends number, S extends z.ZodRawShape>(version: V, shape: S): z.ZodObject<{
|
|
20
|
+
version: z.ZodLiteral<V>;
|
|
21
|
+
} & S extends infer T ? { -readonly [P in keyof T]: T[P]; } : never, z.core.$strict>;
|
|
22
|
+
/** One path-named refusal: where, what kind, and the line a person (or the repair turn) reads. */
|
|
23
|
+
export type ContractIssue = {
|
|
24
|
+
/** `steps[0].routes[0].goesTo`; `payload` for the top level. */
|
|
25
|
+
path: string;
|
|
26
|
+
kind: "required" | "empty" | "unknown-key" | "too-long" | "too-many" | "too-few" | "wrong-type" | "bad-value" | "newer-version" | "invalid";
|
|
27
|
+
/** The line itself: `steps[0].routes[0].goesTo: required`. */
|
|
28
|
+
line: string;
|
|
29
|
+
};
|
|
30
|
+
/** `["steps", 0, "routes", 0, "goesTo"]` as `steps[0].routes[0].goesTo`. */
|
|
31
|
+
export declare function pathOf(path: readonly PropertyKey[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* How a contract names a key it doesn't know: the keys a person or model often writes for one it does (`onFail` for
|
|
34
|
+
* `ifFails`, `to` for `goesTo`), tried in order against the keys allowed at that place. Without one that fits, a key
|
|
35
|
+
* spelled close to an allowed one (a case or a letter or two off) is suggested.
|
|
36
|
+
*/
|
|
37
|
+
export type ContractOptions = {
|
|
38
|
+
aliases?: Readonly<Record<string, readonly string[]>>;
|
|
39
|
+
};
|
|
40
|
+
type Where = {
|
|
41
|
+
schema?: z.ZodType;
|
|
42
|
+
input?: unknown;
|
|
43
|
+
options?: ContractOptions;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* A Zod error as path-named issues, in the error's own order. Given the schema and input it came from, an unknown key
|
|
47
|
+
* also says which allowed key it most likely meant (`unknown key 'onFail' (did you mean ifFails?)`).
|
|
48
|
+
*/
|
|
49
|
+
export declare function contractIssues(error: z.ZodError, where?: Where): ContractIssue[];
|
|
50
|
+
/** A Zod error as path-named lines: `steps[0].routes[0].goesTo: required`, `routes[0]: unknown key 'to'`. */
|
|
51
|
+
export declare function contractError(error: z.ZodError, where?: Where): string[];
|
|
52
|
+
/** A refusal as a reader reports it: a stable reason code, and the path-named line as the message. */
|
|
53
|
+
export type ContractProblem = {
|
|
54
|
+
reason: string;
|
|
55
|
+
message: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A contract issue as a problem with a reason code built from its path — `missing-goal`, `criteria[0].how-too-long`,
|
|
59
|
+
* `checks-too-many`, `payload-unknown-key`, `bad-version`, `newer-version` — so callers that branch on a reason, and
|
|
60
|
+
* durable outcomes that recorded one, read the same as before the contract.
|
|
61
|
+
*/
|
|
62
|
+
export declare function contractProblemOf(issue: ContractIssue): ContractProblem;
|
|
63
|
+
export type ContractResult<T> = {
|
|
64
|
+
ok: true;
|
|
65
|
+
value: T;
|
|
66
|
+
} | {
|
|
67
|
+
ok: false;
|
|
68
|
+
issues: ContractIssue[];
|
|
69
|
+
};
|
|
70
|
+
/** Parse with the input reported, so a missing field reads `required` rather than a wrong type. */
|
|
71
|
+
export declare function parseContract<T>(schema: z.ZodType<T>, input: unknown, options?: ContractOptions): ContractResult<T>;
|
|
72
|
+
/**
|
|
73
|
+
* Read a versioned payload. The current version parses as itself; an older known version (0 for a payload saved
|
|
74
|
+
* before it carried `version`) is upgraded by its adapter and then parsed by the same schema; a newer version is
|
|
75
|
+
* refused plainly; anything else is an unknown version.
|
|
76
|
+
*/
|
|
77
|
+
export declare function readVersioned<T>(schema: z.ZodType<T> & {
|
|
78
|
+
shape: {
|
|
79
|
+
version: z.ZodLiteral<number>;
|
|
80
|
+
};
|
|
81
|
+
}, input: unknown, upgrades?: Readonly<Record<number, (payload: Record<string, unknown>) => unknown>>, options?: ContractOptions): ContractResult<T>;
|
|
82
|
+
/**
|
|
83
|
+
* The JSON Schema a model gets for `schema` (Claude's `--json-schema`). Throws when the schema uses refine,
|
|
84
|
+
* superRefine, transform or preprocess anywhere: what a model is told must be everything the schema checks.
|
|
85
|
+
*/
|
|
86
|
+
export declare function toModelSchema(schema: z.ZodType): Record<string, unknown>;
|
|
87
|
+
export {};
|