toolroll 0.9.56 → 0.9.58

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 (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/action-ledger.js +0 -4
  3. package/dist/actor.d.ts +0 -10
  4. package/dist/actor.js +1 -18
  5. package/dist/api-tokens.d.ts +33 -2
  6. package/dist/api-tokens.js +39 -11
  7. package/dist/assignment-adapters.d.ts +5 -0
  8. package/dist/assignment-delivery.js +1 -1
  9. package/dist/assignment-status.js +1 -1
  10. package/dist/assignment.d.ts +24 -3
  11. package/dist/assignment.js +39 -12
  12. package/dist/browser-workspace.js +1 -1
  13. package/dist/chat-delivery.js +3 -3
  14. package/dist/chat-voice.js +1 -1
  15. package/dist/check-levels.d.ts +3 -0
  16. package/dist/check-levels.js +5 -0
  17. package/dist/cli-http.d.ts +7 -0
  18. package/dist/cli-http.js +30 -4
  19. package/dist/cli.js +2 -1
  20. package/dist/coordinator.d.ts +2 -1
  21. package/dist/coordinator.js +22 -31
  22. package/dist/desktop-update.d.ts +1 -1
  23. package/dist/dispose.js +0 -5
  24. package/dist/export.js +2 -2
  25. package/dist/lead-status.d.ts +1 -1
  26. package/dist/lead-status.js +2 -2
  27. package/dist/mcp-http.js +5 -5
  28. package/dist/operate.js +19 -9
  29. package/dist/password-bearer.d.ts +27 -0
  30. package/dist/password-bearer.js +59 -0
  31. package/dist/proof.d.ts +9 -0
  32. package/dist/proof.js +12 -0
  33. package/dist/release-coverage.d.ts +16 -0
  34. package/dist/release-coverage.js +92 -0
  35. package/dist/remote-command.js +0 -2
  36. package/dist/remote-exec.d.ts +19 -0
  37. package/dist/remote-exec.js +9 -8
  38. package/dist/request-budget.d.ts +1 -1
  39. package/dist/scope.js +2 -1
  40. package/dist/serve.js +15 -0
  41. package/dist/server/chat.js +8 -6
  42. package/dist/server/edge-refusal.d.ts +2 -0
  43. package/dist/server/edge-refusal.js +8 -6
  44. package/dist/server/guards.js +5 -4
  45. package/dist/server/http.d.ts +2 -0
  46. package/dist/server/remote.js +6 -13
  47. package/dist/server/render-results.d.ts +4 -1
  48. package/dist/server/render-results.js +23 -9
  49. package/dist/server/route-table.js +0 -3
  50. package/dist/server/tasks.js +28 -25
  51. package/dist/session-cli.d.ts +38 -9
  52. package/dist/session-cli.js +141 -154
  53. package/dist/session-contract.js +2 -2
  54. package/dist/session-remote.d.ts +19 -0
  55. package/dist/session-remote.js +86 -0
  56. package/dist/store.d.ts +31 -11
  57. package/dist/store.js +276 -95
  58. package/dist/task-outcome-cli.js +3 -1
  59. package/dist/task-status.d.ts +59 -7
  60. package/dist/task-status.js +132 -16
  61. package/dist/team-cli.d.ts +3 -0
  62. package/dist/team-cli.js +23 -3
  63. package/dist/team-runtime.js +1 -1
  64. package/dist/telegram-status.d.ts +11 -4
  65. package/dist/telegram-status.js +17 -10
  66. package/dist/telegram.js +4 -4
  67. package/dist/toolroll-update.js +3 -1
  68. package/dist/work-index.d.ts +6 -1
  69. package/dist/work-index.js +50 -8
  70. package/dist/workspace-ui.js +2 -1
  71. package/package.json +1 -1
  72. package/dist/session-http.d.ts +0 -24
  73. package/dist/session-http.js +0 -170
  74. package/dist/session-server.d.ts +0 -17
  75. package/dist/session-server.js +0 -39
package/dist/cli.js CHANGED
@@ -424,7 +424,8 @@ async function dispatch(argv, write, mainOptions) {
424
424
  if (first === "session") {
425
425
  // D5: native coding sessions are deprecated. They still run this release; each use says so once, on stderr.
426
426
  (mainOptions.session?.stderr ?? (line => process.stderr.write(`${line}\n`)))(deprecationWarning(`session ${rest.find(arg => !arg.startsWith("-")) ?? ""}`.trim(), "session"));
427
- return runSessionCommand(rest, write, mainOptions.session);
427
+ // D7: it runs on the saved connection over /api/cli, signed in with that profile's API token.
428
+ return runSessionCommand(rest, write, { ...(mainOptions.remote ?? mainOptions.team), ...mainOptions.session });
428
429
  }
429
430
  if (first === "demo")
430
431
  return runDemoCommand(rest, write);
@@ -2,7 +2,8 @@
2
2
  * The coordinator principal (MCP gateway spec v6; DESIGN.md §9b).
3
3
  *
4
4
  * A coordinator is the ONE credential the MCP surface accepts: minted by
5
- * an operator password ceremony, hashed at rest, repo-scoped at the mint,
5
+ * an operator password ceremony, hashed at rest (a `credential` row of kind
6
+ * 'coordinator', v118: the shared codec and table), repo-scoped at the mint,
6
7
  * rate-limited, revocable — and able to do exactly one thing: file a
7
8
  * proposal through the canonical door. What it files is an ordinary
8
9
  * UNAPPROVED task; admission is the operator's existing scope ceremony,
@@ -2,7 +2,8 @@
2
2
  * The coordinator principal (MCP gateway spec v6; DESIGN.md §9b).
3
3
  *
4
4
  * A coordinator is the ONE credential the MCP surface accepts: minted by
5
- * an operator password ceremony, hashed at rest, repo-scoped at the mint,
5
+ * an operator password ceremony, hashed at rest (a `credential` row of kind
6
+ * 'coordinator', v118: the shared codec and table), repo-scoped at the mint,
6
7
  * rate-limited, revocable — and able to do exactly one thing: file a
7
8
  * proposal through the canonical door. What it files is an ordinary
8
9
  * UNAPPROVED task; admission is the operator's existing scope ceremony,
@@ -14,7 +15,8 @@
14
15
  * the exported-cast mistake is named in the spec and not repeated here.
15
16
  * Steering and every operator-speech surface cannot accept this type.
16
17
  */
17
- import { createHash, randomBytes } from "node:crypto";
18
+ import { createHash } from "node:crypto";
19
+ import { hashSecret, mintCredential } from "./api-tokens.js";
18
20
  import { canonicalRepos } from "./runner.js";
19
21
  import { fileTaskProposal } from "./proposal.js";
20
22
  import { describeScope } from "./scope.js";
@@ -27,18 +29,7 @@ function verified(row) {
27
29
  return row;
28
30
  }
29
31
  const NAME = /^[a-z0-9-]{1,32}$/;
30
- const CID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
31
32
  const KEY = /^[\x21-\x7e]{8,64}$/;
32
- function hashSecret(token) {
33
- return createHash("sha256").update(token, "utf8").digest("hex");
34
- }
35
- function mintCid() {
36
- const bytes = randomBytes(12);
37
- let cid = "";
38
- for (const b of bytes)
39
- cid += CID_ALPHABET[b % CID_ALPHABET.length];
40
- return cid;
41
- }
42
33
  export function mintCoordinator(store, input) {
43
34
  const days = input.days ?? 90;
44
35
  if (!Number.isInteger(days) || days < 1 || days > 365)
@@ -53,15 +44,16 @@ export function mintCoordinator(store, input) {
53
44
  if (input.repos.length === 0)
54
45
  return { ok: false, reason: "no-repos" };
55
46
  const repos = canonicalRepos(input.repos);
56
- const token = (input.newToken ?? (() => randomBytes(32).toString("base64url")))();
47
+ const token = (input.newToken ?? (() => mintCredential("coordinator").token))();
57
48
  return store.transact(() => {
58
49
  for (let attempt = 0; attempt < 5; attempt += 1) {
59
- const cid = (input.newCid ?? mintCid)();
50
+ const cid = (input.newCid ?? (() => mintCredential("coordinator").id))();
60
51
  try {
52
+ // Its generation: which coordinator of this name it is (revoke-and-remint keeps the earlier one's history apart).
61
53
  store.handle
62
- .prepare(`INSERT INTO coordinator_credential (cid, name, credential_hash, repos, per_hour, created_by, created_at, expires_at)
63
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
64
- .run(cid, name, hashSecret(token), JSON.stringify(repos), perHour, input.by, input.now.toISOString(), expiresAt);
54
+ .prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, projects_json, per_hour, created_by, created_at, expires_at, generation)
55
+ VALUES (?, 'coordinator', ?, ?, ?, ?, 'act', ?, ?, ?, ?, ?, 1 + (SELECT count(*) FROM credential WHERE kind = 'coordinator' AND name = ?))`)
56
+ .run(cid, cid, input.by, name, hashSecret(token), JSON.stringify(repos), perHour, input.by, input.now.toISOString(), expiresAt, name);
65
57
  return { ok: true, cid, token, repos };
66
58
  }
67
59
  catch (error) {
@@ -81,9 +73,8 @@ export function mintCoordinator(store, input) {
81
73
  * the stored hash; the caller re-runs this INSIDE any mutating
82
74
  * transaction (the startup check is a courtesy; the txn check is law). */
83
75
  export function authenticateCoordinator(store, token) {
84
- const row = store.handle
85
- .prepare("SELECT * FROM coordinator_credential WHERE credential_hash = ?")
86
- .get(hashSecret(token));
76
+ const found = store.presentedCredential(token);
77
+ const row = found?.kind === "coordinator" ? store.handle.prepare("SELECT * FROM credential WHERE id = ? AND kind = 'coordinator'").get(found.id) : undefined;
87
78
  if (row === undefined)
88
79
  return { ok: false, reason: "unknown" };
89
80
  if (row["revoked_at"] !== null)
@@ -94,23 +85,23 @@ export function authenticateCoordinator(store, token) {
94
85
  return {
95
86
  ok: true,
96
87
  who: verified({
97
- cid: String(row["cid"]),
88
+ cid: String(row["id"]),
98
89
  name: String(row["name"]),
99
- repos: JSON.parse(String(row["repos"])),
90
+ repos: JSON.parse(String(row["projects_json"])),
100
91
  perHour: Number(row["per_hour"]),
101
92
  }),
102
93
  };
103
94
  }
104
95
  export function revokeCoordinator(store, cid, by, now) {
105
96
  return store.transact(() => {
106
- const row = store.handle.prepare("SELECT revoked_at FROM coordinator_credential WHERE cid = ?").get(cid);
97
+ const row = store.handle.prepare("SELECT revoked_at FROM credential WHERE id = ? AND kind = 'coordinator'").get(cid);
107
98
  if (row === undefined)
108
99
  return { ok: false, reason: "unknown" };
109
100
  if (row["revoked_at"] !== null)
110
101
  return { ok: false, reason: "already-revoked" };
111
102
  store.handle
112
- .prepare("UPDATE coordinator_credential SET revoked_at = ? WHERE cid = ?")
113
- .run(now.toISOString(), cid);
103
+ .prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'coordinator'")
104
+ .run(now.toISOString(), by, cid);
114
105
  // The revocation event rides the SAME transaction as the state change,
115
106
  // and the credential's pending proposals expire with it (mate arc v3).
116
107
  store.handle
@@ -122,13 +113,13 @@ export function revokeCoordinator(store, cid, by, now) {
122
113
  }
123
114
  export function listCoordinators(store) {
124
115
  return store.handle
125
- .prepare(`SELECT c.*, (SELECT MAX(e.created_at) FROM coordinator_event e WHERE e.cid = c.cid AND e.kind = 'filed') AS last_filed
126
- FROM coordinator_credential c ORDER BY c.created_at, c.cid`)
116
+ .prepare(`SELECT c.*, (SELECT MAX(e.created_at) FROM coordinator_event e WHERE e.cid = c.id AND e.kind = 'filed') AS last_filed
117
+ FROM credential c WHERE c.kind = 'coordinator' ORDER BY c.created_at, c.id`)
127
118
  .all()
128
119
  .map(row => ({
129
- cid: String(row["cid"]),
120
+ cid: String(row["id"]),
130
121
  name: String(row["name"]),
131
- repos: JSON.parse(String(row["repos"])),
122
+ repos: JSON.parse(String(row["projects_json"])),
132
123
  perHour: Number(row["per_hour"]),
133
124
  createdBy: String(row["created_by"]),
134
125
  createdAt: String(row["created_at"]),
@@ -253,7 +244,7 @@ export function fileCoordinatorProposal(store, token, input, now) {
253
244
  ],
254
245
  }),
255
246
  ...(input.deliverable === undefined ? {} : { deliverable: input.deliverable }),
256
- filedVia: `mcp:${who.name}`, filedBy: { name: store.handle.prepare("SELECT created_by FROM coordinator_credential WHERE cid = ?").get(who.cid)?.["created_by"] ?? null, kind: "coordinator" },
247
+ filedVia: `mcp:${who.name}`, filedBy: { name: store.handle.prepare("SELECT created_by FROM credential WHERE id = ? AND kind = 'coordinator'").get(who.cid)?.["created_by"] ?? null, kind: "coordinator" },
257
248
  admittedRepos: who.repos,
258
249
  }, now);
259
250
  if (!made.ok)
@@ -94,6 +94,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
95
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
96
  export declare function desktopUpdateStatus(stateDir: string): {
97
+ version?: never;
97
98
  active: boolean;
98
99
  phase: string;
99
100
  detail: string;
@@ -107,7 +108,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
107
108
  wasRunning?: never;
108
109
  backupPath?: never;
109
110
  updateId?: never;
110
- version?: never;
111
111
  buildId?: never;
112
112
  workDir?: never;
113
113
  } | {
package/dist/dispose.js CHANGED
@@ -707,10 +707,6 @@ export function maybeTriggerRepair(store, repo, evidenceRoot, sourceRunId, verdi
707
707
  };
708
708
  const briefBytes = Buffer.from(JSON.stringify(draftBrief, null, 2), "utf8");
709
709
  const key = writeEvidenceFile(evidenceRoot, sourceRunId, `repair-brief-${sourceRunId}-${attempt}.json`, briefBytes);
710
- // Suffixes survive truncation (the CI-repair rule, verbatim): the prefix
711
- // gives way, the identity-bearing tail never does.
712
- const suffix = observation ? `-evidence-${attempt}` : `-fix-${attempt}`;
713
- const draftId = `${rootTask.slice(0, 64 - suffix.length)}${suffix}`;
714
710
  // The draft files through the ONE revision boundary: the source's goal,
715
711
  // exclusions, touches, rubric, risk, quality, posture, budget, overrides
716
712
  // and pins are read from the SOURCE ROWS inside the seal — the scope
@@ -719,7 +715,6 @@ export function maybeTriggerRepair(store, repo, evidenceRoot, sourceRunId, verdi
719
715
  source: { task: ref.externalId, run: sourceRunId, scopeDigest: scope.digest },
720
716
  brief: { evidenceRoot, key, sha256: createHash("sha256").update(briefBytes).digest("hex"), bytes: briefBytes.length, capture: "machine-authored repair brief (exit 0)" },
721
717
  child: {
722
- id: draftId,
723
718
  title: observation ? `Collect missing evidence for ${ref.externalId}` : verification === undefined ? `repair ${ref.externalId}: ${unresolved.length} criteri${unresolved.length === 1 ? "on" : "a"} unmet` : `Fix failed checks for ${ref.externalId}`,
724
719
  repair: observation ? `Collect only the missing observations for ${unresolved.join(", ")}. Keep the saved candidate unchanged. The machine runs supported focused test observations and reuses its intact passing gate; a fresh review assesses the added evidence.` : verification === undefined ? `repair exactly the unmet criteria named below; a comment cannot widen the scope. Unmet: ${unresolved.join(", ")}.` : "Diagnose the saved failed project check and repair within the original scope. Preserve the acceptance criteria and verification command; the revision brief binds the exact failure evidence.",
725
720
  },
package/dist/export.js CHANGED
@@ -33,7 +33,7 @@ const SECRET_TABLES = new Set(["web_session", "ceremony_nonce", "chat_pair", "oa
33
33
  const DERIVED_TABLE = /^(sqlite_|memory_search)/;
34
34
  /** Columns that hold a secret (or what stands in for one): never exported. */
35
35
  const SECRET_COLUMNS = new Set([
36
- "api_token.secret_hash", "lead_credential.secret_hash", "approver.credential_hash", "coordinator_credential.credential_hash", "runner.credential_hash", "invite.token_hash",
36
+ "credential.secret_hash", "approver.credential_hash", "runner.credential_hash", "invite.token_hash",
37
37
  "flow_trigger.hook_hash", "push_subscription.endpoint", "push_subscription.p256dh", "push_subscription.auth",
38
38
  "chat_turn.credential_key", "lead_session.credential_key", "mate_turn.credential_key", "chat_runtime.push_url", "workflow_preview.token",
39
39
  "quota.credential_fp", "oauth_grant.renew_hash", "oauth_client.source_hash",
@@ -56,7 +56,7 @@ export function categoryOf(table) {
56
56
  [/^subagent/, "subagents"],
57
57
  [/^(flow|workflow_)/, "flows"],
58
58
  [/^(chat_|lead_|team_|notification|push_)/, "chats"],
59
- [/^(approver|api_token|lead_credential|invite|sso_identity|coordinator_|oauth_)/, "people"],
59
+ [/^(approver|credential$|invite|sso_identity|coordinator_|oauth_)/, "people"],
60
60
  [/^(run|artifact|claim|worktree$|execution_slot|criterion_review|diff_comment|proof_|review_request|repair_chain|incident|publication$|merge_|side_spend|knowledge_snapshot|learning_snapshot|skill_snapshot)/, "runs"],
61
61
  [/^(task|hold$|plan_|scope_|tool_seal|decision|external_|skill_test)/, "tasks"],
62
62
  [/^(project|approval_policy|capability|verify_command|worktree_setup|backend_grant|intake_grant|publication_grant|operating_mode|knowledge_|learning_|memory_|skill_)/, "projects"],
@@ -105,7 +105,7 @@ export declare function repairStaleStatuses(store: Store, now: Date): {
105
105
  * `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
106
106
  * A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
107
107
  * the release check, plan windows and the lead line are empty, never another project's. */
108
- export declare function installationStatus(store: Store, now: Date, viewer?: string | null, repos?: readonly string[] | null): InstallationStatus;
108
+ export declare function installationStatus(store: Store, now: Date, viewer?: string | null, repos?: readonly string[] | null, root?: string): InstallationStatus;
109
109
  /** `limited`: a report over some projects (installationStatus's `repos`), which has no release check or plan windows to state. */
110
110
  export declare function renderInstallationStatus(status: InstallationStatus, limited?: boolean): string[];
111
111
  export declare function renderTaskWait(snapshot: TaskWaitSnapshot, outcome?: string): string;
@@ -183,7 +183,7 @@ export function repairStaleStatuses(store, now) {
183
183
  * `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
184
184
  * A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
185
185
  * the release check, plan windows and the lead line are empty, never another project's. */
186
- export function installationStatus(store, now, viewer = currentActor()?.account ?? null, repos = null) {
186
+ export function installationStatus(store, now, viewer = currentActor()?.account ?? null, repos = null, root) {
187
187
  const admitted = (row) => repos === null || typeof row["repo"] === "string" && repos.includes(row["repo"]);
188
188
  const access = { principal: "operator", repos, includeUnplaced: repos === null };
189
189
  const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.id AS task_ref, ref.external_id AS task, ref.repo
@@ -262,7 +262,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
262
262
  observedAt: String(row["observed_at"]),
263
263
  })),
264
264
  signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
265
- tasks: workIndexPage(store, now, { ...access, viewer }, { limit: 8 }).items
265
+ tasks: workIndexPage(store, now, { ...access, viewer }, { limit: 8, ...(root === undefined ? {} : { root }) }).items
266
266
  .map(one => ({ task: one.rootId, title: one.title, headline: one.status.label, sentence: one.status.detail })),
267
267
  lead: repos !== null ? null : (() => {
268
268
  // Only the asker's own lead: another person's lead is never "Your lead".
package/dist/mcp-http.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { adapterPolicy } from "./server/route-policy.js";
2
2
  import { PACKAGE_VERSION } from "./version.js";
3
- import { parseApiToken, secretMatches, tokenLive, tokenProjects } from "./api-tokens.js";
3
+ import { tokenLive, tokenProjects } from "./api-tokens.js";
4
4
  import { oauthProjects, oauthTokenAllowed, RESOURCE_PATH } from "./mcp-oauth.js";
5
5
  import { authenticateCoordinator } from "./coordinator.js";
6
6
  import { LEGACY, MAX_REQUEST, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
@@ -26,10 +26,10 @@ export function createMcpHttp(options) {
26
26
  if (presented.startsWith("so_")) {
27
27
  if (!options.signedIn(request))
28
28
  return null;
29
- const parsed = parseApiToken(presented);
30
- const kept = parsed === null ? null : store.apiTokenSecret(parsed.id);
31
- // The secret is checked here too, so this door never rests on how signedIn reads the request.
32
- if (parsed === null || kept === null || !secretMatches(parsed.secret, kept.secretHash))
29
+ // The secret is checked here too (the one credential check), so this door never rests on how signedIn reads the request.
30
+ const found = store.presentedCredential(presented);
31
+ const kept = found?.kind === "person" ? store.apiTokenSecret(found.id) : null;
32
+ if (kept === null)
33
33
  return null;
34
34
  if (!tokenLive(kept.row, options.clock().getTime()))
35
35
  return null;
package/dist/operate.js CHANGED
@@ -96,7 +96,8 @@ import { deleteProject, holdingsWords, projectHoldings, projectRunning } from ".
96
96
  import { pushPass } from "./push.js";
97
97
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync } from "node:fs";
98
98
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, taskBranches } from "./names.js";
99
- import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
99
+ import { claimActor, currentActor, withActor } from "./actor.js";
100
+ import { parseLeadToken } from "./api-tokens.js";
100
101
  import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
101
102
  import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
102
103
  import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
@@ -179,7 +180,7 @@ import { CHAT_APPROVE_ALL, presetTerms, modeTermsJson, modeDigestOf, modeTermsFr
179
180
  import { WorktreePool } from "./worktree.js";
180
181
  import { requestTaskStop, resumeTaskStop, taskControlOf } from "./task-control.js";
181
182
  import { worktreeAdoptionNotice } from "./worktree-notices.js";
182
- import { workIndexPage, WorkIndexCursorError } from "./work-index.js";
183
+ import { workIndexPage, workIndexTask, WorkIndexCursorError } from "./work-index.js";
183
184
  import { readExecutorOf } from "./read-executor.js";
184
185
  import { parseWorkView } from "./workspace-ui.js";
185
186
  import { taskWorkSummaryOf } from "./work-summary.js";
@@ -8307,10 +8308,10 @@ async function statusCommand(positional, flags, context) {
8307
8308
  // that belongs to the installation or other projects (updates, integrations, builds by project, unsent replies).
8308
8309
  const lens = context.principal?.lens ?? null;
8309
8310
  if (lens !== null) {
8310
- const status = installationStatus(context.store, context.clock(), viewer, lens);
8311
+ const status = installationStatus(context.store, context.clock(), viewer, lens, context.evidenceRoot);
8311
8312
  return succeed(context.write, context.json, command, { ...status, projects: [] }, () => renderInstallationStatus(status, true));
8312
8313
  }
8313
- const status = installationStatus(context.store, context.clock(), viewer);
8314
+ const status = installationStatus(context.store, context.clock(), viewer, null, context.evidenceRoot);
8314
8315
  // One line, only when a newer Toolroll exists; offline or switched off says nothing.
8315
8316
  const current = PACKAGE_VERSION;
8316
8317
  const method = installMethod(context.installBin);
@@ -8927,6 +8928,7 @@ function listTasks(flags, context) {
8927
8928
  view: parseWorkView(rawView ?? null), limit, cursor: text(flags, 'cursor') ?? null,
8928
8929
  ...(wanted === undefined ? {} : { state: wanted }),
8929
8930
  ...(text(flags, 'repo') === undefined ? {} : { project: text(flags, 'repo') }),
8931
+ root: context.evidenceRoot,
8930
8932
  };
8931
8933
  const refused = (error) => {
8932
8934
  if (!(error instanceof WorkIndexCursorError))
@@ -8990,10 +8992,18 @@ function showTask(positional, context) {
8990
8992
  const repo = store.lookupRef(blocker)?.repo;
8991
8993
  return repo != null && lens.includes(repo);
8992
8994
  });
8995
+ const work = taskWorkSummaryOf(store, id, now, access);
8996
+ const current = assignmentOf(store, id, now, access, context.evidenceRoot);
8997
+ // Read the same saved facts and statusOf path as task list. File health remains
8998
+ // in the assignment diagnostics and action-time guards; it cannot rewrite history.
8999
+ const listed = workIndexTask(store, id, now, access, context.evidenceRoot);
9000
+ const shared = listed === null ? null : { headline: listed.status.label, sentence: listed.status.detail };
9001
+ const primaryAction = listed === null ? work?.primaryAction ?? null : listed.primaryAction;
8993
9002
  const detail = {
8994
9003
  task,
8995
- work: taskWorkSummaryOf(store, id, now, access),
8996
- assignment: assignmentBrief(assignmentOf(store, id, now, access, context.evidenceRoot)),
9004
+ work: work === null || listed === null ? work : { ...work, status: { ...work.status, label: listed.status.label, detail: listed.status.detail, tone: listed.status.tone }, primaryAction },
9005
+ status: shared === null ? null : { headline: shared.headline, sentence: shared.sentence },
9006
+ assignment: assignmentBrief(current),
8997
9007
  ref: ref.id,
8998
9008
  // One phrase for every inaccessible blocker, so even their number stays private.
8999
9009
  blockedBy: visibleBlockers.length === blockers.length ? visibleBlockers : [...visibleBlockers, "a task in another project"],
@@ -9033,8 +9043,8 @@ function showTask(positional, context) {
9033
9043
  `${task.id} ${task.state}${ref.deliverable === "report" ? " (scout — delivers a report)" : ""}`,
9034
9044
  ` ${task.title}`,
9035
9045
  ...(detail.work === null ? [] : [
9036
- ` status: ${detail.work.status.label}`,
9037
- ...(detail.work.primaryAction === null ? [] : [` next: ${detail.work.primaryAction.label}${detail.work.primaryAction.target.decisionId === null ? "" : ` — decision #${detail.work.primaryAction.target.decisionId}`}`]),
9046
+ ` status: ${shared?.headline ?? detail.work.status.label}${shared === null ? "" : ` — ${shared.sentence}`}`,
9047
+ ...(primaryAction === null ? [] : [` next: ${primaryAction.label}${primaryAction.target.decisionId === null ? "" : ` — decision #${primaryAction.target.decisionId}`}`]),
9038
9048
  ]),
9039
9049
  // The closed machine-authored verdict (Priority 2), computed once at
9040
9050
  // completion — never re-derived here. Same words `verdictWords`
@@ -9042,7 +9052,7 @@ function showTask(positional, context) {
9042
9052
  ...(task.state !== "done" || detail.proofVerdict === null
9043
9053
  ? []
9044
9054
  : [
9045
- ` proof: ${proofVerdictWords(detail.proofVerdict, detail.proofReasons).word}${detail.proofAccepted ? " (accepted)" : ""}`,
9055
+ ` proof: ${proofVerdictWords(current?.receipt?.runId === latestFinished?.id ? current?.readiness?.verdict ?? detail.proofVerdict : detail.proofVerdict, detail.proofReasons).word}${detail.proofAccepted ? " (accepted)" : ""}`,
9046
9056
  ...(detail.proofReasons.length > 0 ? [` ${detail.proofReasons.join("; ")}`] : []),
9047
9057
  ...matrixWords(detail.proofMatrix),
9048
9058
  ...(detail.semanticCoverage === null ? [] : coverageWords(detail.semanticCoverage).map(line => ` ${line}`)),
@@ -0,0 +1,27 @@
1
+ /**
2
+ * D7: a password on each request (`Authorization: Bearer <name>:<password>`) is deprecated. This release still
3
+ * accepts it everywhere it was accepted, with the same per-address tries, locks and budgets, but every answer carries
4
+ * a deprecation warning, and every refusal of such a request names the replacement: an API token. From
5
+ * PASSWORD_BEARER_REFUSED_FROM on, the server refuses it before checking the password.
6
+ */
7
+ import type { ServerResponse } from "node:http";
8
+ /** The one replacement every machine-credential refusal names. */
9
+ export declare const TOKEN_REPLACEMENT = "Create an API token with `toolroll tokens create`";
10
+ /** The first release that refuses a password on a request: one release after this one (0.9.58) deprecated it. */
11
+ export declare const PASSWORD_BEARER_REFUSED_FROM = "0.9.59";
12
+ export declare const PASSWORD_BEARER_WARNING = "Signing in with a password on each request is deprecated and stops working in Toolroll 0.9.59. Create an API token with `toolroll tokens create` and send it as the bearer instead.";
13
+ /** What the CLI says on stderr when a saved connection signs in with a password instead of an API token. */
14
+ export declare const PASSWORD_PROFILE_WARNING = "Warning: this connection signs in with your password on each request, which is deprecated and stops working in Toolroll 0.9.59. Create an API token with `toolroll tokens create`, then connect again with --token-stdin.";
15
+ export declare const PASSWORD_BEARER_REFUSED = "Toolroll no longer accepts a password on a request. Create an API token with `toolroll tokens create` and send it as the bearer instead.";
16
+ /** Whether an Authorization header is a password bearer: a name and a secret, never an API token. */
17
+ export declare function isPasswordBearer(authorization: string | undefined): boolean;
18
+ /** Whether this build still accepts a password bearer: only a release before PASSWORD_BEARER_REFUSED_FROM does. */
19
+ export declare function passwordBearerAccepted(version?: string): boolean;
20
+ /** A refusal's words for this request: unchanged, or with the replacement when it came with a password bearer. */
21
+ export declare function withReplacement(authorization: string | undefined, message: string): string;
22
+ /**
23
+ * The refusal boundary for one password-bearer request, whichever adapter answers it: the deprecation is announced in
24
+ * headers (RFC 9745 Deprecation, and a Warning naming the replacement), and any refusal's words, plain text or a JSON
25
+ * `message`, gain the replacement. Successful answers are left byte for byte.
26
+ */
27
+ export declare function announcePasswordBearer(response: ServerResponse): void;
@@ -0,0 +1,59 @@
1
+ import { PACKAGE_VERSION } from "./version.js";
2
+ /** The one replacement every machine-credential refusal names. */
3
+ export const TOKEN_REPLACEMENT = "Create an API token with `toolroll tokens create`";
4
+ /** The first release that refuses a password on a request: one release after this one (0.9.58) deprecated it. */
5
+ export const PASSWORD_BEARER_REFUSED_FROM = "0.9.59";
6
+ export const PASSWORD_BEARER_WARNING = `Signing in with a password on each request is deprecated and stops working in Toolroll ${PASSWORD_BEARER_REFUSED_FROM}. ${TOKEN_REPLACEMENT} and send it as the bearer instead.`;
7
+ /** What the CLI says on stderr when a saved connection signs in with a password instead of an API token. */
8
+ export const PASSWORD_PROFILE_WARNING = `Warning: this connection signs in with your password on each request, which is deprecated and stops working in Toolroll ${PASSWORD_BEARER_REFUSED_FROM}. ${TOKEN_REPLACEMENT}, then connect again with --token-stdin.`;
9
+ export const PASSWORD_BEARER_REFUSED = `Toolroll no longer accepts a password on a request. ${TOKEN_REPLACEMENT} and send it as the bearer instead.`;
10
+ /** Whether an Authorization header is a password bearer: a name and a secret, never an API token. */
11
+ export function isPasswordBearer(authorization) {
12
+ return authorization !== undefined && /^Bearer (.+):(.+)$/.test(authorization) && !/^Bearer so_/.test(authorization);
13
+ }
14
+ const parts = (version) => (/^(\d+)\.(\d+)\.(\d+)/.exec(version) ?? [0, 0, 0, 0]).slice(1).map(Number);
15
+ /** Whether this build still accepts a password bearer: only a release before PASSWORD_BEARER_REFUSED_FROM does. */
16
+ export function passwordBearerAccepted(version = PACKAGE_VERSION) {
17
+ const have = parts(version), limit = parts(PASSWORD_BEARER_REFUSED_FROM);
18
+ for (let index = 0; index < 3; index++)
19
+ if (have[index] !== limit[index])
20
+ return have[index] < limit[index];
21
+ return false;
22
+ }
23
+ /** A refusal's words for this request: unchanged, or with the replacement when it came with a password bearer. */
24
+ export function withReplacement(authorization, message) {
25
+ return !isPasswordBearer(authorization) || message.includes("toolroll tokens create") ? message : `${message} ${PASSWORD_BEARER_WARNING}`;
26
+ }
27
+ /**
28
+ * The refusal boundary for one password-bearer request, whichever adapter answers it: the deprecation is announced in
29
+ * headers (RFC 9745 Deprecation, and a Warning naming the replacement), and any refusal's words, plain text or a JSON
30
+ * `message`, gain the replacement. Successful answers are left byte for byte.
31
+ */
32
+ export function announcePasswordBearer(response) {
33
+ response.setHeader("Deprecation", "true");
34
+ response.setHeader("Warning", `299 toolroll "${PASSWORD_BEARER_WARNING.replace(/"/g, "'")}"`);
35
+ const end = response.end.bind(response);
36
+ response.end = ((...args) => {
37
+ const [chunk] = args;
38
+ if (response.statusCode >= 400 && typeof chunk === "string" && !chunk.includes("toolroll tokens create"))
39
+ args[0] = annotated(chunk);
40
+ return end(...args);
41
+ });
42
+ }
43
+ function annotated(body) {
44
+ if (/^\s*\{/.test(body)) {
45
+ try {
46
+ const value = JSON.parse(body);
47
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
48
+ const row = value;
49
+ if (typeof row["message"] === "string")
50
+ return JSON.stringify({ ...row, message: `${row["message"]} ${PASSWORD_BEARER_WARNING}` });
51
+ if (typeof row["error_description"] === "string")
52
+ return JSON.stringify({ ...row, error_description: `${row["error_description"]} ${PASSWORD_BEARER_WARNING}` });
53
+ }
54
+ }
55
+ catch { /* not JSON after all: plain text below */ }
56
+ return body;
57
+ }
58
+ return body.trimStart().startsWith("<") ? body : `${body.replace(/\s+$/, "")}\n${PASSWORD_BEARER_WARNING}`;
59
+ }
package/dist/proof.d.ts CHANGED
@@ -437,6 +437,15 @@ export declare function matrixWords(matrix: readonly CriterionMatrixRow[]): stri
437
437
  /** One criterion's context standing in words — shared by the CLI matrix
438
438
  * and the console badge title so the two never drift. */
439
439
  export declare function coverageStateWords(coverage: CriterionCoverage): string;
440
+ /** The verdict a result shows today, read from the stored one and the facts recorded since, never written back: a
441
+ * failed check or an unresolved HIGH review finding refutes; a pending review, or any acceptance criterion that has
442
+ * not passed, keeps it short. It only ever lowers the stored verdict. */
443
+ export declare function shownVerdictOf(stored: ProofVerdict | null, facts: {
444
+ checkFailed: boolean;
445
+ highFindings: number;
446
+ reviewPending: boolean;
447
+ criteriaPassed: boolean;
448
+ }): ProofVerdict | null;
440
449
  /** Plain words for a verdict, shared by every surface (the `summary.ts`
441
450
  * pattern) so the task page, run page, board, CLI, and chat cannot say
442
451
  * three different things about the same run. */
package/dist/proof.js CHANGED
@@ -976,6 +976,18 @@ export function coverageStateWords(coverage) {
976
976
  return `GAP — inherited context is missing: ${coverage.gaps.join("; ")}${coverage.items.length > 0 ? ` (partial: ${coverage.items.join(", ")})` : ""}${prior}`;
977
977
  }
978
978
  }
979
+ /** The verdict a result shows today, read from the stored one and the facts recorded since, never written back: a
980
+ * failed check or an unresolved HIGH review finding refutes; a pending review, or any acceptance criterion that has
981
+ * not passed, keeps it short. It only ever lowers the stored verdict. */
982
+ export function shownVerdictOf(stored, facts) {
983
+ if (facts.checkFailed || facts.highFindings > 0 || stored === "refuted")
984
+ return "refuted";
985
+ if (stored === null)
986
+ return null;
987
+ if (facts.reviewPending || !facts.criteriaPassed)
988
+ return "short";
989
+ return stored;
990
+ }
979
991
  /** Plain words for a verdict, shared by every surface (the `summary.ts`
980
992
  * pattern) so the task page, run page, board, CLI, and chat cannot say
981
993
  * three different things about the same run. */
@@ -0,0 +1,16 @@
1
+ import type { Store } from "./store.js";
2
+ export type ReleaseCoverage = {
3
+ run: number;
4
+ head: string;
5
+ };
6
+ /** How many of the newest passing release checks one read considers. */
7
+ export declare const RELEASE_SCAN_LIMIT = 20;
8
+ export type IsAncestor = (repo: string, ancestor: string, descendant: string) => boolean | null;
9
+ /** `git merge-base --is-ancestor`: true, false, or null when Git couldn't say. */
10
+ export declare const gitIsAncestor: IsAncestor;
11
+ export type ReleaseMemo = Map<string, ReleaseCoverage[]>;
12
+ /** The release check covering `head` in `repo`, or null. `memo` keeps one read's receipts. */
13
+ export declare function releaseCoverageOf(store: Store, root: string | undefined, repo: string | null, head: string | null, options?: {
14
+ memo?: ReleaseMemo;
15
+ isAncestor?: IsAncestor;
16
+ }): ReleaseCoverage | null;
@@ -0,0 +1,92 @@
1
+ /** Whether a release check already covers a result whose checks were Off: a
2
+ * passing FULL release check (run_check.release) on the same project whose
3
+ * checked commit contains the result's commit. Read at read time from the
4
+ * existing records and Git; nothing is written back, so no proof, receipt,
5
+ * scope digest or schema changes. Fails closed: a receipt that can't be read,
6
+ * a missing commit or any Git error leaves the result uncovered. */
7
+ import { execFileSync } from "node:child_process";
8
+ import { verificationEvidence } from "./verification-evidence.js";
9
+ import { runCheckLevel } from "./check-levels.js";
10
+ /** How many of the newest passing release checks one read considers. */
11
+ export const RELEASE_SCAN_LIMIT = 20;
12
+ const SHA = /^[a-f0-9]{40}$/;
13
+ /** Ancestry between two fixed commits never changes, so a settled answer is kept; errors are not. */
14
+ const ancestry = new Map();
15
+ const ANCESTRY_LIMIT = 4096;
16
+ /** `git merge-base --is-ancestor`: true, false, or null when Git couldn't say. */
17
+ export const gitIsAncestor = (repo, ancestor, descendant) => {
18
+ if (!SHA.test(ancestor) || !SHA.test(descendant))
19
+ return null;
20
+ if (ancestor === descendant)
21
+ return true;
22
+ const key = `${repo}\0${ancestor}\0${descendant}`;
23
+ const known = ancestry.get(key);
24
+ if (known !== undefined)
25
+ return known;
26
+ let answer;
27
+ try {
28
+ execFileSync("git", ["-C", repo, "merge-base", "--is-ancestor", ancestor, descendant], { stdio: "ignore", timeout: 5_000 });
29
+ answer = true;
30
+ }
31
+ catch (error) {
32
+ // Exit 1 is Git's "no"; anything else (a missing object, no repository, a timeout) is no answer.
33
+ answer = error.status === 1 ? false : null;
34
+ }
35
+ if (answer !== null) {
36
+ if (ancestry.size >= ANCESTRY_LIMIT)
37
+ ancestry.clear();
38
+ ancestry.set(key, answer);
39
+ }
40
+ return answer;
41
+ };
42
+ /** The newest passing full release checks of one project, each with its sealed, verified commit. Read once per
43
+ * caller-held memo, so a list of results reads the receipts once. */
44
+ function passingReleases(store, root, repo) {
45
+ let rows;
46
+ try {
47
+ rows = store.handle.prepare(`SELECT rc.run FROM run_check AS rc INDEXED BY run_check_release
48
+ JOIN run ON run.id = rc.run JOIN task_ref AS ref ON ref.id = run.task_ref
49
+ WHERE rc.release = 1 AND rc.status = 'passed' AND rc.exit_code = 0 AND ref.repo = ?
50
+ ORDER BY rc.run DESC LIMIT ${RELEASE_SCAN_LIMIT}`).all(repo);
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ return rows.flatMap(row => {
56
+ const run = Number(row["run"]);
57
+ try {
58
+ // Full only: a quick or Off release check covers nothing.
59
+ const level = runCheckLevel(store, run);
60
+ if (level !== null && level !== "full")
61
+ return [];
62
+ const gate = verificationEvidence(store, root, run);
63
+ if (!gate.ok || gate.bytes === null)
64
+ return [];
65
+ const receipt = JSON.parse(gate.bytes);
66
+ const head = store.getRun(run)?.headRevision ?? null;
67
+ if (receipt.run !== run || receipt.result?.ran !== true || receipt.result.exitCode !== 0)
68
+ return [];
69
+ if (typeof receipt.command?.repo === "string" && receipt.command.repo.startsWith("quick:"))
70
+ return [];
71
+ if (typeof receipt.head !== "string" || !SHA.test(receipt.head) || receipt.head !== head)
72
+ return [];
73
+ return [{ run, head: receipt.head }];
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ });
79
+ }
80
+ /** The release check covering `head` in `repo`, or null. `memo` keeps one read's receipts. */
81
+ export function releaseCoverageOf(store, root, repo, head, options = {}) {
82
+ if (root === undefined || repo === null || head === null || !SHA.test(head))
83
+ return null;
84
+ const memo = options.memo ?? new Map();
85
+ let releases = memo.get(repo);
86
+ if (releases === undefined) {
87
+ releases = passingReleases(store, root, repo);
88
+ memo.set(repo, releases);
89
+ }
90
+ const isAncestor = options.isAncestor ?? gitIsAncestor;
91
+ return releases.find(one => isAncestor(repo, head, one.head) === true) ?? null;
92
+ }
@@ -34,8 +34,6 @@ export const REMOTE_PATH_ARGUMENTS = [
34
34
  ...['link', 'unlink'].map(invocation => ({ invocation, argument: { flag: '--to' }, policy: 'refuse' })),
35
35
  ...['repos add', 'repos remove', 'pulls', 'graph', ''].map(invocation => ({ invocation, argument: { position: 0, name: 'path', rest: true }, policy: 'refuse' })),
36
36
  { invocation: 'project use', argument: { position: 0, name: 'checkout path' }, policy: 'refuse' },
37
- // These local HTTP clients read prompts themselves; they do not consume runOperateAs's files map.
38
- ...['session start', 'session send'].map(invocation => ({ invocation, argument: { flag: '--file' }, policy: 'refuse' })),
39
37
  ];
40
38
  /**
41
39
  * Parse once using declared arity, so a flag's value cannot be mistaken for another flag or a
@@ -1,4 +1,5 @@
1
1
  import { type TeamCliOptions } from './team-cli.js';
2
+ import { type CliRequest } from './cli-http.js';
2
3
  import { type RemoteCommandLookup } from './remote-command.js';
3
4
  export { contractRow, STEP_UP_MESSAGE, type RemoteMode } from './remote-command.js';
4
5
  export type RemoteExecOptions = TeamCliOptions & {
@@ -9,6 +10,24 @@ export type RemoteExecOptions = TeamCliOptions & {
9
10
  /** Injected by tests: the contract row lookup (default: COMMAND_GUIDE's `remote` field). */
10
11
  modeOf?: RemoteCommandLookup;
11
12
  };
13
+ type Answer = {
14
+ exitCode: number;
15
+ stdout: string;
16
+ stderr: string;
17
+ };
18
+ export type Outcome = {
19
+ kind: 'answer';
20
+ answer: Answer;
21
+ } | {
22
+ kind: 'refused';
23
+ status: number;
24
+ code: string;
25
+ message: string;
26
+ } | {
27
+ kind: 'unconfirmed';
28
+ };
29
+ /** One request, never retried. */
30
+ export declare function postCli(origin: string, token: string, request: CliRequest, options: RemoteExecOptions): Promise<Outcome>;
12
31
  /**
13
32
  * null: run locally with argv unchanged. `{ local }`: run locally with this argv (`--local` removed).
14
33
  * A number: the command ran remotely (or was refused before sending) and this is its exit code.