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.
Files changed (74) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +7 -0
  3. package/dist/assignment.d.ts +1 -0
  4. package/dist/assignment.js +2 -2
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +17 -17
  7. package/dist/browser-crew.js +1 -0
  8. package/dist/browser-workspace.d.ts +5 -3
  9. package/dist/builder.js +4 -7
  10. package/dist/chat-acceptance.d.ts +1 -1
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +124 -63
  13. package/dist/contracts/contract-test.d.ts +45 -0
  14. package/dist/contracts/contract-test.js +69 -0
  15. package/dist/contracts/contract.d.ts +87 -0
  16. package/dist/contracts/contract.js +308 -0
  17. package/dist/contracts/flow-propose.d.ts +325 -0
  18. package/dist/contracts/flow-propose.js +41 -0
  19. package/dist/contracts/flow.d.ts +19400 -0
  20. package/dist/contracts/flow.js +359 -0
  21. package/dist/contracts/handoff.d.ts +91 -0
  22. package/dist/contracts/handoff.js +82 -0
  23. package/dist/contracts/plan.d.ts +58 -0
  24. package/dist/contracts/plan.js +67 -0
  25. package/dist/contracts/proof.d.ts +136 -0
  26. package/dist/contracts/proof.js +110 -0
  27. package/dist/contracts/verification-receipt.d.ts +148 -0
  28. package/dist/contracts/verification-receipt.js +87 -0
  29. package/dist/demo.js +28 -11
  30. package/dist/desktop-update.d.ts +1 -1
  31. package/dist/evidence.d.ts +3 -40
  32. package/dist/evidence.js +7 -3
  33. package/dist/flow-gallery.d.ts +3 -2
  34. package/dist/flow-share.d.ts +10 -32
  35. package/dist/flow-share.js +65 -83
  36. package/dist/flow-starters.d.ts +2 -1
  37. package/dist/flow-triggers.d.ts +15 -72
  38. package/dist/flow-triggers.js +94 -83
  39. package/dist/flows.d.ts +37 -218
  40. package/dist/flows.js +350 -385
  41. package/dist/mate-tools.js +14 -44
  42. package/dist/mcp-connect.d.ts +3 -0
  43. package/dist/mcp-connect.js +16 -4
  44. package/dist/operate.js +83 -12
  45. package/dist/plan.d.ts +12 -12
  46. package/dist/plan.js +109 -72
  47. package/dist/planner-source.js +2 -1
  48. package/dist/planner.js +55 -25
  49. package/dist/process-recovery-eligibility.d.ts +2 -2
  50. package/dist/process-recovery-eligibility.js +13 -8
  51. package/dist/project-admission.d.ts +93 -0
  52. package/dist/project-admission.js +134 -0
  53. package/dist/project-delete-ui.js +5 -2
  54. package/dist/proof.d.ts +15 -91
  55. package/dist/proof.js +90 -249
  56. package/dist/result-evidence-readers.d.ts +42 -0
  57. package/dist/result-evidence-readers.js +91 -0
  58. package/dist/runner.d.ts +10 -0
  59. package/dist/runner.js +16 -0
  60. package/dist/scope.js +4 -3
  61. package/dist/serve.d.ts +4 -0
  62. package/dist/serve.js +112 -160
  63. package/dist/setup-guide.d.ts +2 -2
  64. package/dist/store.d.ts +2 -0
  65. package/dist/store.js +4 -0
  66. package/dist/surface.js +16 -3
  67. package/dist/task-review-brief.d.ts +75 -0
  68. package/dist/task-review-brief.js +179 -0
  69. package/dist/text-limits.d.ts +87 -0
  70. package/dist/text-limits.js +86 -0
  71. package/dist/verification-evidence.js +24 -12
  72. package/dist/when-html.d.ts +2 -0
  73. package/dist/when-html.js +13 -0
  74. package/package.json +3 -2
@@ -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 = JSON.parse(verified.content.toString("utf8"));
1119
- if (parsed.branch === branch &&
1120
- typeof parsed.conclusion === "string" &&
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: import("./scope.js").EvidenceKind;
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> connect one (no path: the repo you are in)\n toolroll repos remove <path>\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.";
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, addRepos, removeRepos, updateRepos } from "./repos.js";
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> connect one (no path: the repo you are in)
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
- const bare = argv.filter(argument => argument !== "--json");
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 file = configFile();
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, write, onboard);
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 action === "add"
593
- ? addToRepos(loaded.repos, targets, file, write)
594
- : removeFromRepos(loaded.repos, targets, file, write);
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> connect another");
610
- write(" toolroll repos remove <path> disconnect one");
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
- async function addToRepos(existing, targets, file, write, said) {
617
- // Enrolling something that is not a repository would fail later and further
618
- // away, so it fails here instead.
619
- const rejected = targets.filter(path => !existsSync(join(path, ".git")));
620
- if (rejected.length > 0) {
621
- for (const path of rejected)
622
- write(`${path} is not a git repository.`);
623
- if (said !== undefined)
624
- said.reason = "usage";
625
- return USAGE_EXIT;
626
- }
627
- const added = targets.filter(path => !existing.includes(path));
628
- const wrote = await updateRepos(file, repos => addRepos(repos, targets));
629
- if (!wrote.ok) {
630
- // The updater's own taxonomy survives (verification finding 4): a held
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 (added.length === 0) {
638
- write(`Already connected: ${targets.join(", ")}`);
639
- return 0;
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
- for (const path of added)
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(existing, targets, file, write, said) {
648
- const removed = targets.filter(path => existing.includes(path));
649
- if (removed.length === 0) {
650
- write(`Not connected: ${targets.join(", ")}`);
651
- return 0;
652
- }
653
- const wrote = await updateRepos(file, repos => removeRepos(repos, targets));
654
- if (!wrote.ok) {
655
- if (said !== undefined)
656
- said.reason = wrote.reason;
657
- write(`could not update the connected list — ${wrote.message}`);
658
- return 1;
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
- for (const path of removed)
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, file, write, onboard) {
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
- const enrolled = await updateRepos(file, repos => addRepos(repos, [cloned.target]));
774
- if (!enrolled.ok) {
775
- return answer(enrolled.reason, `${cloned.target} is cloned but not connected — ${enrolled.message}; connect it with \`repos add ${cloned.target}\``, 1);
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} and connected it.`);
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 {};