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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.58 — 2026-10-09
4
+
5
+ - **One token kind.** Personal, lead and coordinator tokens now live in one
6
+ table with one check. Existing tokens keep working.
7
+ - **Sessions join the CLI API.** The sessions API is now part of the CLI API.
8
+ - **Passwords as tokens warn.** Sending your password as a token still works
9
+ this release but warns. Create a token with `toolroll tokens create`.
10
+
11
+ ## 0.9.57 — 2026-10-09
12
+
13
+ - **Truthful status.** Finished tasks read Complete everywhere. With checks
14
+ off, criteria read "checked at release" instead of Needs you.
15
+ - **No false finish.** Completing or deploying refuses a failed or missing
16
+ check, an unmet criterion, a serious review finding, or missing evidence.
17
+ - **Clear revisions.** Revisions are named after their task: -v2, -v3.
18
+
3
19
  ## 0.9.56 — 2026-10-09
4
20
 
5
21
  - **One lead.** Your assistant in the console, on your phone, in chat and in
@@ -63,10 +63,6 @@ CREATE TRIGGER IF NOT EXISTS ledger_task_placed AFTER UPDATE OF repo ON task_ref
63
63
  WHEN OLD.repo IS NOT NEW.repo BEGIN
64
64
  ${event("strftime('%Y-%m-%dT%H:%M:%fZ','now')", "'system'", "NEW.repo", "NEW.external_id", "NULL", "task placed", "'recorded'")}
65
65
  END;
66
- CREATE TRIGGER IF NOT EXISTS ledger_coordinator_minted AFTER INSERT ON coordinator_credential BEGIN
67
- INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (NEW.created_at, NEW.created_by, NULL, NULL, NULL,
68
- 'coordinator minted: ' || NEW.name, 'minted', 'access', 'projects: ' || NEW.repos);
69
- END;
70
66
  CREATE TRIGGER IF NOT EXISTS ledger_task_registered AFTER INSERT ON task_ref BEGIN
71
67
  ${event("strftime('%Y-%m-%dT%H:%M:%fZ','now')", "'system'", "NEW.repo", "NEW.external_id", "NULL", "task registered", "'recorded'")}
72
68
  END;
package/dist/actor.d.ts CHANGED
@@ -9,13 +9,3 @@ export declare function currentActor(): Actor | null;
9
9
  export declare function claimActor(account: string): void;
10
10
  /** How the ledger names an actor. */
11
11
  export declare function actorLabel(actor: Actor): string;
12
- export declare function mintLeadToken(): {
13
- id: string;
14
- token: string;
15
- hash: string;
16
- };
17
- export declare function parseLeadToken(presented: string): {
18
- id: string;
19
- secret: string;
20
- } | null;
21
- export declare function leadSecretMatches(secret: string, keptHash: string): boolean;
package/dist/actor.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Who is acting right now, for pings that follow responsibility.
3
3
  *
4
4
  * A lead is an agent acting for one person under a lead token (`toolroll lead
5
- * token`): it signs in as that person, and its acts are recorded as "lead for
5
+ * token`, a `credential` row of kind 'lead', api-tokens.ts): it signs in as that person, and its acts are recorded as "lead for
6
6
  * <owner>". A person acting for themselves is recorded by name. Work whose
7
7
  * actor is the lead pings nobody, and nobody is pinged about their own act.
8
8
  *
@@ -10,7 +10,6 @@
10
10
  * long-running service: a worker's facts are nobody's act.
11
11
  */
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
14
13
  /** The slot is mutable so a command can name its person after it has verified their password. */
15
14
  const slot = new AsyncLocalStorage();
16
15
  /** Run `fn` with this actor (or an empty slot a verified sign-in may fill). */
@@ -30,19 +29,3 @@ export function claimActor(account) {
30
29
  export function actorLabel(actor) {
31
30
  return actor.lead ? `lead for ${actor.account}` : actor.account;
32
31
  }
33
- // ---- the lead token ---------------------------------------------------------
34
- /** `lt_<id>_<secret>`: shown once, only its hash kept. */
35
- const SHAPE = /^lt_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
36
- export function mintLeadToken() {
37
- const id = randomBytes(6).toString("hex"), secret = randomBytes(32).toString("base64url");
38
- return { id, token: `lt_${id}_${secret}`, hash: hashLeadSecret(secret) };
39
- }
40
- const hashLeadSecret = (secret) => createHash("sha256").update(secret, "utf8").digest("hex");
41
- export function parseLeadToken(presented) {
42
- const match = SHAPE.exec(presented.trim());
43
- return match === null ? null : { id: match[1], secret: match[2] };
44
- }
45
- export function leadSecretMatches(secret, keptHash) {
46
- const a = Buffer.from(hashLeadSecret(secret), "hex"), b = Buffer.from(keptHash, "hex");
47
- return a.length === b.length && timingSafeEqual(a, b);
48
- }
@@ -1,5 +1,7 @@
1
1
  export type TokenAccess = "read" | "act";
2
2
  export type TokenPurpose = "api" | "mcp";
3
+ /** Who a credential stands for: a person (their API token), a person's lead, or a coordinator. */
4
+ export type CredentialKind = "person" | "lead" | "coordinator";
3
5
  export declare const MCP_ROTATION_REFUSAL = "Reconnect the MCP client to renew this token. You can still revoke it here.";
4
6
  /** Missing purpose is an old ordinary token; an unrecognised stored purpose is never treated as ordinary. */
5
7
  export declare const tokenPurpose: (value: unknown) => TokenPurpose;
@@ -7,18 +9,47 @@ export declare const tokenRotationProblem: (token: {
7
9
  purpose: TokenPurpose;
8
10
  }) => string | null;
9
11
  export declare const TOKEN_DAYS: readonly [30, 90, 365];
10
- export declare function mintApiToken(): {
12
+ /** What a presented secret says it is, before any check. A coordinator's secret has no prefix and is hashed whole. */
13
+ export type PresentedCredential = {
14
+ kind: "person" | "lead";
15
+ id: string;
16
+ secret: string;
17
+ } | {
18
+ kind: "coordinator";
19
+ secret: string;
20
+ };
21
+ /** A new credential of `kind`: its id, the secret shown once, and the only thing kept, its hash. */
22
+ export declare function mintCredential(kind: CredentialKind): {
11
23
  id: string;
12
24
  secret: string;
13
25
  token: string;
14
26
  hash: string;
15
27
  };
16
28
  export declare const hashSecret: (secret: string) => string;
17
- /** The id and secret of something shaped like a token, or null. */
29
+ /** Read a presented credential's kind and parts; null for nothing at all. A lead token may carry surrounding space. */
30
+ export declare function parseCredential(presented: string): PresentedCredential | null;
31
+ export declare const mintApiToken: () => {
32
+ id: string;
33
+ secret: string;
34
+ token: string;
35
+ hash: string;
36
+ };
37
+ export declare const mintLeadToken: () => {
38
+ id: string;
39
+ secret: string;
40
+ token: string;
41
+ hash: string;
42
+ };
43
+ /** The id and secret of something shaped like an API token, or null. */
18
44
  export declare function parseApiToken(presented: string): {
19
45
  id: string;
20
46
  secret: string;
21
47
  } | null;
48
+ /** The id and secret of something shaped like a lead token, or null. */
49
+ export declare function parseLeadToken(presented: string): {
50
+ id: string;
51
+ secret: string;
52
+ } | null;
22
53
  /** Whether the secret is the one whose hash was kept, in constant time. */
23
54
  export declare function secretMatches(secret: string, keptHash: string): boolean;
24
55
  /** v111: how long a rotated token keeps working beside its replacement, by default and at most. */
@@ -1,8 +1,9 @@
1
1
  /**
2
- * API tokens (v101): what scripts and CI use instead of a password on each
3
- * request. A token belongs to one person, reads or acts (never more than the
4
- * person may), always expires (a year at most), is shown once, and only its
5
- * hash is kept. `Authorization: Bearer so_<id>_<secret>`.
2
+ * Machine credentials (D7, v118): one codec and one table (`credential`) for every kind. A person's API token is
3
+ * what scripts and CI use instead of a password on each request: it belongs to one person, reads or acts (never
4
+ * more than the person may), always expires (a year at most), is shown once, and only its hash is kept.
5
+ * `Authorization: Bearer so_<id>_<secret>`. A lead token (`lt_<id>_<secret>`) is an agent acting for its owner; a
6
+ * coordinator's secret (no prefix, hashed whole) files proposals over MCP. Each kind keeps its own policy.
6
7
  *
7
8
  * A token never passes a step-up: approvals and other password ceremonies
8
9
  * stay a person's act in the console.
@@ -13,16 +14,43 @@ export const MCP_ROTATION_REFUSAL = "Reconnect the MCP client to renew this toke
13
14
  export const tokenPurpose = (value) => value === undefined || value === "api" ? "api" : "mcp";
14
15
  export const tokenRotationProblem = (token) => token.purpose === "api" ? null : MCP_ROTATION_REFUSAL;
15
16
  export const TOKEN_DAYS = [30, 90, 365];
16
- const SHAPE = /^so_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
17
- export function mintApiToken() {
18
- const id = randomBytes(6).toString("hex"), secret = randomBytes(32).toString("base64url");
19
- return { id, secret, token: `so_${id}_${secret}`, hash: hashSecret(secret) };
17
+ /** The prefixed shapes: so_ (a person's API token) and lt_ (a lead token), each `<prefix>_<12 hex>_<43 base64url>`. */
18
+ const PRESENTED = /^(so|lt)_([a-f0-9]{12})_([A-Za-z0-9_-]{43})$/;
19
+ const PREFIX = { person: "so", lead: "lt" };
20
+ const CID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
21
+ /** A new credential of `kind`: its id, the secret shown once, and the only thing kept, its hash. */
22
+ export function mintCredential(kind) {
23
+ const secret = randomBytes(32).toString("base64url");
24
+ if (kind === "coordinator") {
25
+ let id = "";
26
+ for (const byte of randomBytes(12))
27
+ id += CID_ALPHABET[byte % CID_ALPHABET.length];
28
+ return { id, secret, token: secret, hash: hashSecret(secret) };
29
+ }
30
+ const id = randomBytes(6).toString("hex");
31
+ return { id, secret, token: `${PREFIX[kind]}_${id}_${secret}`, hash: hashSecret(secret) };
20
32
  }
21
33
  export const hashSecret = (secret) => createHash("sha256").update(secret, "utf8").digest("hex");
22
- /** The id and secret of something shaped like a token, or null. */
34
+ /** Read a presented credential's kind and parts; null for nothing at all. A lead token may carry surrounding space. */
35
+ export function parseCredential(presented) {
36
+ if (presented === "")
37
+ return null;
38
+ const match = PRESENTED.exec(presented) ?? (/^lt_/.test(presented.trim()) ? PRESENTED.exec(presented.trim()) : null);
39
+ if (match !== null)
40
+ return { kind: match[1] === "so" ? "person" : "lead", id: match[2], secret: match[3] };
41
+ return { kind: "coordinator", secret: presented };
42
+ }
43
+ export const mintApiToken = () => mintCredential("person");
44
+ export const mintLeadToken = () => mintCredential("lead");
45
+ /** The id and secret of something shaped like an API token, or null. */
23
46
  export function parseApiToken(presented) {
24
- const match = SHAPE.exec(presented);
25
- return match === null ? null : { id: match[1], secret: match[2] };
47
+ const parsed = parseCredential(presented);
48
+ return parsed?.kind === "person" && PRESENTED.test(presented) ? { id: parsed.id, secret: parsed.secret } : null;
49
+ }
50
+ /** The id and secret of something shaped like a lead token, or null. */
51
+ export function parseLeadToken(presented) {
52
+ const parsed = parseCredential(presented);
53
+ return parsed?.kind === "lead" ? { id: parsed.id, secret: parsed.secret } : null;
26
54
  }
27
55
  /** Whether the secret is the one whose hash was kept, in constant time. */
28
56
  export function secretMatches(secret, keptHash) {
@@ -109,6 +109,11 @@ export declare function assignmentForCoordinator(store: Store, token: string, op
109
109
  deployment: {
110
110
  status: "not-recorded";
111
111
  };
112
+ readiness: {
113
+ verdict: import("./proof.js").ProofVerdict | null;
114
+ checksOff: boolean;
115
+ blockers: import("./task-status.js").CompletionBlocker[];
116
+ } | null;
112
117
  };
113
118
  }[];
114
119
  nextCursor: number;
@@ -14,7 +14,7 @@ const ownerSql = `COALESCE((SELECT 'lead:'||o.lead FROM team_task_owner o WHERE
14
14
  function admittedRepos(store, owner) {
15
15
  if (owner.kind !== "coordinator" || typeof owner.id !== "string" || owner.id.length === 0)
16
16
  return null;
17
- const row = store.handle.prepare("SELECT repos,revoked_at FROM coordinator_credential WHERE cid = ?").get(owner.id);
17
+ const row = store.handle.prepare("SELECT projects_json AS repos,revoked_at FROM credential WHERE id = ? AND kind = 'coordinator'").get(owner.id);
18
18
  if (!row || row["revoked_at"] !== null)
19
19
  return null;
20
20
  try {
@@ -92,7 +92,7 @@ export function noteAssignmentStatus(store, snapshot, now) {
92
92
  const claimed = store.handle.prepare("SELECT actor FROM action_ledger WHERE task_id=? AND action='assignment claimed' AND source='work' ORDER BY id DESC LIMIT 1").get(snapshot.rootId);
93
93
  if ((claimed === undefined ? ref.coordinatorCid : String(claimed["actor"]).replace(/^coordinator:/, "")) !== owner.id)
94
94
  return null;
95
- const credential = store.handle.prepare("SELECT repos FROM coordinator_credential WHERE cid=? AND revoked_at IS NULL").get(owner.id);
95
+ const credential = store.handle.prepare("SELECT projects_json AS repos FROM credential WHERE id=? AND kind='coordinator' AND revoked_at IS NULL").get(owner.id);
96
96
  let repos;
97
97
  try {
98
98
  repos = JSON.parse(String(credential?.["repos"] ?? "null"));
@@ -1,10 +1,12 @@
1
1
  import { type Store, type ProofVerdictRow, type ProofAcceptanceRow } from "./store.js";
2
2
  import { type WorkAction, type WorkSummaryAccess } from "./work-summary.js";
3
3
  import { type VerifiedApprover } from "./principal.js";
4
+ import { type ProofVerdict } from "./proof.js";
4
5
  import { type CheckLevel } from "./check-levels.js";
5
6
  import { type BuildReviewView } from "./review-switch.js";
6
7
  import { type NeedKey, type WaitKey } from "./needs-you.js";
7
- import { type ChecksBatch } from "./task-status.js";
8
+ import { type ChecksBatch, type CompletionBlocker } from "./task-status.js";
9
+ import { type ReleaseCoverage, type ReleaseMemo } from "./release-coverage.js";
8
10
  import { type LeadClaim } from "./lead-voice.js";
9
11
  export type AssignmentAccess = WorkSummaryAccess;
10
12
  export type AssignmentOwner = {
@@ -24,6 +26,8 @@ export type AssignmentChecks = {
24
26
  running?: "quick" | "full" | null;
25
27
  /** A batch check (batch-checks.ts): waiting for its batch, or how it was checked. */
26
28
  batch?: ChecksBatch | null;
29
+ /** Checks Off: the passing full release check that contains this commit (release-coverage.ts), read now, never sealed. */
30
+ release?: ReleaseCoverage | null;
27
31
  };
28
32
  export type AssignmentReceipt = {
29
33
  digest: string;
@@ -118,6 +122,13 @@ export type AssignmentSnapshot = {
118
122
  earlierActive?: number;
119
123
  /** Of those, how many are running rather than only queued; present with earlierActive. */
120
124
  earlierRunning?: number;
125
+ /** The result as it reads today (task-status.ts): the verdict it shows, whether its checks were deliberately Off, and
126
+ * what refuses Complete. Derived at read time; never part of the sealed receipt. Null without a result. */
127
+ readiness?: {
128
+ verdict: ProofVerdict | null;
129
+ checksOff: boolean;
130
+ blockers: CompletionBlocker[];
131
+ } | null;
121
132
  };
122
133
  /** Status-first handoff for routine reads. Fetch get_assignment only when
123
134
  * inspecting its exact evidence; do not repeat a full proof in every poll. */
@@ -169,9 +180,14 @@ export declare function assignmentBrief(assignment: AssignmentSnapshot | null):
169
180
  deployment: {
170
181
  status: "not-recorded";
171
182
  };
183
+ readiness: {
184
+ verdict: ProofVerdict | null;
185
+ checksOff: boolean;
186
+ blockers: CompletionBlocker[];
187
+ } | null;
172
188
  } | null;
173
189
  export declare function assignmentChecksForRun(store: Store, root: string | undefined, runId: number, now: Date): AssignmentChecks;
174
- export declare function assignmentOf(store: Store, taskId: string, now: Date, access: AssignmentAccess, root?: string): AssignmentSnapshot | null;
190
+ export declare function assignmentOf(store: Store, taskId: string, now: Date, access: AssignmentAccess, root?: string, memo?: ReleaseMemo): AssignmentSnapshot | null;
175
191
  type MutationResult = {
176
192
  ok: true;
177
193
  assignment: AssignmentSnapshot;
@@ -203,7 +219,7 @@ type Finished = {
203
219
  * (no reason needed), or an exception a report that doesn't match its changes needs a reason for. Null when
204
220
  * Accept and finish is a plain completion: nothing is owed (no proof, a failed check, or a verified result),
205
221
  * or it was already accepted. */
206
- export declare function owedAcceptance(receipt: AssignmentReceipt | null): "person-check" | "exception" | null;
222
+ export declare function owedAcceptance(receipt: AssignmentReceipt | null, blockers?: readonly CompletionBlocker[]): "person-check" | "exception" | null;
207
223
  /** Accept and finish: the person's acceptance of the exact result they read and its completion, in one
208
224
  * transaction. Whether an acceptance is owed is decided here, against the receipt as it stands: only a
209
225
  * person's own check or a reasoned exception records one (with its ledger act); an exception without its
@@ -278,6 +294,11 @@ export declare function assignmentUpdates(store: Store, now: Date, access: Assig
278
294
  deployment: {
279
295
  status: "not-recorded";
280
296
  };
297
+ readiness: {
298
+ verdict: ProofVerdict | null;
299
+ checksOff: boolean;
300
+ blockers: CompletionBlocker[];
301
+ } | null;
281
302
  };
282
303
  }[];
283
304
  nextCursor: number;
@@ -14,13 +14,14 @@ import { verificationEvidence } from "./verification-evidence.js";
14
14
  import { reproveApprover } from "./principal.js";
15
15
  import { noteAssignmentStatus } from "./assignment-status.js";
16
16
  import { historicalAssessmentReason } from "./assignment-presentation.js";
17
- import { manualReviewOnly } from "./proof.js";
17
+ import { manualReviewOnly, shownVerdictOf } from "./proof.js";
18
18
  import { ACCEPT_NEEDS_REASON, cantAcceptYetOf } from "./result-acts.js";
19
- import { runCheckLevel } from "./check-levels.js";
19
+ import { requiredCheckCommandFor, runCheckLevel } from "./check-levels.js";
20
20
  import { followUpChecksOf, withFollowUps } from "./result-follow-ups.js";
21
21
  import { buildReviewOf, findingWords } from "./review-switch.js";
22
22
  import { NEEDS, WAITS, processNeedOf, resultHoldUpSentence } from "./needs-you.js";
23
- import { assignmentStageOf } from "./task-status.js";
23
+ import { assignmentStageOf, checkBackingOf, completionBlockersOf, requirementWordOf } from "./task-status.js";
24
+ import { releaseCoverageOf } from "./release-coverage.js";
24
25
  import { leadClaimOf } from "./lead-voice.js";
25
26
  /** Status-first handoff for routine reads. Fetch get_assignment only when
26
27
  * inspecting its exact evidence; do not repeat a full proof in every poll. */
@@ -35,7 +36,7 @@ export function assignmentBrief(assignment) {
35
36
  base: receipt.base, head: receipt.head, completionKind: receipt.completionKind,
36
37
  checks: receipt.checks, proofAcceptance: receipt.proofAcceptance, verdict: receipt.proof?.verdict ?? null,
37
38
  criteria: { passed: receipt.proof?.matrix.filter(row => row.state === "pass").length ?? 0, total: receipt.proof?.matrix.length ?? 0 }, evidence: receipt.evidence },
38
- publication: assignment.publication, review: assignment.review, deployment: assignment.deployment };
39
+ publication: assignment.publication, review: assignment.review, deployment: assignment.deployment, readiness: assignment.readiness ?? null };
39
40
  }
40
41
  const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
41
42
  /** A lookup added with lead-quiet, read safely: a deploy proves completion with this code over the INSTALLED runtime's
@@ -51,7 +52,7 @@ function olderStoreSafe(read, absent) {
51
52
  }
52
53
  }
53
54
  /** A check as the receipt digest seals it: presentation-only fields (check-levels.ts) left out. */
54
- const sealedChecks = ({ level: _level, running: _running, batch: _batch, ...proof }) => proof;
55
+ const sealedChecks = ({ level: _level, running: _running, batch: _batch, release: _release, ...proof }) => proof;
55
56
  const OWNER_ACTION = "assignment claimed";
56
57
  const CHECK_ACTION = COMPLETION_ACTION;
57
58
  const actorOf = (owner) => `${owner.kind}:${owner.id}`;
@@ -72,7 +73,8 @@ function ownerOf(store, rootId, repo) {
72
73
  const id = claim === undefined ? origin : String(claim["actor"]).replace(/^coordinator:/, "");
73
74
  if (!id)
74
75
  return null;
75
- const credential = store.handle.prepare("SELECT name,repos,revoked_at FROM coordinator_credential WHERE cid = ?").get(id);
76
+ // A ledger actor names a coordinator by the id it had when it acted (v118 keeps that as public_id).
77
+ const credential = store.handle.prepare("SELECT name,projects_json AS repos,revoked_at FROM credential WHERE kind = 'coordinator' AND (id = ? OR public_id = ?) ORDER BY id = ? DESC LIMIT 1").get(id, id, id);
76
78
  let repos = [];
77
79
  try {
78
80
  repos = JSON.parse(String(credential?.["repos"] ?? "[]"));
@@ -127,7 +129,7 @@ function buildChecks(store, root, runId) {
127
129
  return unavailable("Saved checks could not be read.");
128
130
  }
129
131
  }
130
- export function assignmentOf(store, taskId, now, access, root) {
132
+ export function assignmentOf(store, taskId, now, access, root, memo) {
131
133
  const family = store.taskFamilyOf(taskId, access.repos, access.principal === "operator" && access.includeUnplaced === true);
132
134
  if (family === null)
133
135
  return null;
@@ -163,7 +165,10 @@ export function assignmentOf(store, taskId, now, access, root) {
163
165
  const reports = artifacts.filter(a => a.kind === "report");
164
166
  const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !readVerifiedArtifact(root, artifact).ok)
165
167
  .map(artifact => `Saved ${artifact.kind} #${artifact.id} (run ${run.id}) is unavailable or changed.`)));
166
- const checks = result === null ? null : assignmentChecksForRun(store, root, result.id, now);
168
+ const ownChecks = result === null ? null : assignmentChecksForRun(store, root, result.id, now);
169
+ // Checks Off: a passing full release check that contains this commit covers it, read now from the existing receipts.
170
+ const checks = ownChecks === null || ownChecks.level !== "off" || ownChecks.status === "passed" || ownChecks.status === "failed" ? ownChecks
171
+ : { ...ownChecks, release: releaseCoverageOf(store, root, current.repo, result.headRevision, memo === undefined ? {} : { memo }) };
167
172
  const finishedBuild = result?.role === "builder" && (result.outcome === "built" || result.outcome === "no-change") &&
168
173
  result.finishedAt !== null && /^[a-f0-9]{40}$/.test(result.headRevision ?? "");
169
174
  // Finished work returns to its lead or user. Strict terms and previous model
@@ -339,6 +344,7 @@ export function assignmentOf(store, taskId, now, access, root) {
339
344
  attention, receipt: receipt?.digest ?? null }), acknowledged: false } : null;
340
345
  const publication = result === null ? null : store.publicationForRun(result.id);
341
346
  const lead = state === "complete" || state === "cancelled" || access.principal !== "operator" ? null : leadClaimOf(store, family.root.id, now, access.viewer);
347
+ const readiness = receipt === null ? null : readinessOf(store, receipt, current.repo, review);
342
348
  // Existing saved inputs and output, read only after family admission. Keep
343
349
  // polling briefs small; full reads disclose exactly which excerpts are shortened.
344
350
  const planId = result?.planRevision == null ? null : store.getPlanRevision(result.planRevision)?.artifact;
@@ -355,12 +361,26 @@ export function assignmentOf(store, taskId, now, access, root) {
355
361
  return { version: 1, rootId: family.root.id, activeTaskId: current.id, repo: current.repo, title: family.root.title,
356
362
  state, detail, primaryAction, attention: [...new Set(attention)], attempts, owner, receipt, savedContext, completion, handoff,
357
363
  publication: publication === null ? null : { state: publication.state, prUrl: publication.prUrl, remoteState: publication.remoteState },
358
- review, deployment: { status: "not-recorded" }, ...(need === null ? {} : { need }), ...(lead === null ? {} : { lead }), ...(earlierActive.length === 0 ? {} : { earlierActive: earlierActive.length, earlierRunning }) };
364
+ review, deployment: { status: "not-recorded" }, readiness, ...(need === null ? {} : { need }), ...(lead === null ? {} : { lead }), ...(earlierActive.length === 0 ? {} : { earlierActive: earlierActive.length, earlierRunning }) };
365
+ }
366
+ /** The result's shown verdict and completion blockers (task-status.ts), from the receipt, the project's approved check
367
+ * and the automatic review. A HIGH finding a revision already took is resolved there. */
368
+ function readinessOf(store, receipt, repo, review) {
369
+ const report = receipt.completionKind === "research-report";
370
+ const high = review === null || review.sentBackAs !== null || review.state !== "reviewed" ? 0 : review.high.length;
371
+ const checkRequired = olderStoreSafe(() => requiredCheckCommandFor(store, repo, receipt.taskId, receipt.checks.level), null) !== null;
372
+ const matrix = receipt.proof?.matrix ?? [];
373
+ const backing = checkBackingOf(receipt.checks);
374
+ const verdict = shownVerdictOf(receipt.proof?.verdict ?? null, { checkFailed: !report && receipt.checks.status === "failed", highFindings: high,
375
+ reviewPending: review?.state === "pending", criteriaPassed: matrix.every(row => requirementWordOf(row, null, backing) === "Met") });
376
+ const blockers = completionBlockersOf({ report, checks: receipt.checks, checkRequired, matrix, verdict: receipt.proof?.verdict ?? null,
377
+ accepted: receipt.proofAcceptance !== null, high });
378
+ return { verdict, checksOff: !report && receipt.checks.level === "off", blockers };
359
379
  }
360
380
  function admittedOwner(store, taskId, owner, now, root) {
361
381
  if (owner.kind !== "coordinator")
362
382
  return null; // A stable lead is identity, not a bearer credential.
363
- const row = store.handle.prepare("SELECT repos, revoked_at FROM coordinator_credential WHERE cid = ?").get(owner.id);
383
+ const row = store.handle.prepare("SELECT projects_json AS repos, revoked_at FROM credential WHERE id = ? AND kind = 'coordinator'").get(owner.id);
364
384
  if (row === undefined || row["revoked_at"] !== null)
365
385
  return null;
366
386
  let repos;
@@ -417,6 +437,10 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
417
437
  if (current.state !== "ready-to-check" && current.state !== "complete")
418
438
  return { ok: false, reason: "not-ready", message: "This assignment still has unresolved execution, scope or decisions." };
419
439
  if (current.state !== "complete") {
440
+ // The one readiness guard: a failed or missing required check, an unresolved requirement or HIGH finding refuses.
441
+ const blockers = current.readiness?.blockers ?? [];
442
+ if (blockers.length > 0)
443
+ return { ok: false, reason: "not-ready", message: blockers.map(one => one.message).join(" ") };
420
444
  // v102: declared paths are only a promise. A result whose actual diff reaches protected files, on a
421
445
  // scope one person approved, completes only by someone else — two people have then seen the work.
422
446
  const problem = store.protectedResultProblem(receipt.taskId, familyChangedFiles(store, receipt.taskId, receipt.runId, root), actor.startsWith("operator:") ? actor.slice("operator:".length) : null);
@@ -476,11 +500,14 @@ class Undone extends Error {
476
500
  * (no reason needed), or an exception a report that doesn't match its changes needs a reason for. Null when
477
501
  * Accept and finish is a plain completion: nothing is owed (no proof, a failed check, or a verified result),
478
502
  * or it was already accepted. */
479
- export function owedAcceptance(receipt) {
503
+ export function owedAcceptance(receipt, blockers = []) {
480
504
  if (receipt === null || receipt.proofAcceptance !== null)
481
505
  return null;
482
506
  if (personCheckPending(receipt))
483
507
  return "person-check";
508
+ // An unresolved requirement is resolved only by an acceptance that says why.
509
+ if (blockers.some(one => one.key === "criteria"))
510
+ return "exception";
484
511
  return cantAcceptYetOf(receipt.proof?.verdict ?? null, receipt.proof?.reasons ?? [], false) === ACCEPT_NEEDS_REASON ? "exception" : null;
485
512
  }
486
513
  /** Accept and finish: the person's acceptance of the exact result they read and its completion, in one
@@ -505,7 +532,7 @@ export function acceptAndCompleteAsOperator(store, taskId, input, who, now, root
505
532
  return { ok: false, reason: "stale", message: "This result changed. Open the current result before accepting it." };
506
533
  }
507
534
  // Already complete, or nothing owed: the plain completion of the receipt as read.
508
- const owed = before.state === "ready-to-check" ? owedAcceptance(receipt) : null;
535
+ const owed = before.state === "ready-to-check" ? owedAcceptance(receipt, before.readiness?.blockers ?? []) : null;
509
536
  // An exception is accepted only with its reason: never a completion that skips it.
510
537
  if (owed === "exception" && input.note === null)
511
538
  return { ok: false, reason: "needs-reason", message: `${ACCEPT_NEEDS_REASON}.` };
@@ -19,7 +19,7 @@ export function browserCrewOf(store, now, access, options = {}) {
19
19
  return { crew: [], crewTruncated: false };
20
20
  const limit = Number.isFinite(options.limit)
21
21
  ? Math.max(1, Math.min(BROWSER_CREW_LIMIT, Math.floor(options.limit))) : BROWSER_CREW_LIMIT;
22
- const page = workIndexPage(store, now, access, { limit, project });
22
+ const page = workIndexPage(store, now, access, { limit, project, ...(options.evidenceRoot === undefined ? {} : { root: options.evidenceRoot }) });
23
23
  return browserCrewFromIndex(page);
24
24
  }
25
25
  /** Navigation input must already be admitted by the caller. No extra project
@@ -232,9 +232,9 @@ export async function processChatEvent(options) {
232
232
  if (command.kind === "help")
233
233
  state.plan(event.id, [{ text: PHONE_HELP }], now_);
234
234
  else if (command.kind === "status")
235
- state.plan(event.id, [{ text: phoneStatus(store, repos, now_, focusedTitle) }], now_);
235
+ state.plan(event.id, [{ text: phoneStatus(store, repos, now_, focusedTitle, options.evidenceRoot) }], now_);
236
236
  else if (command.kind === "tasks")
237
- state.plan(event.id, [{ text: phoneTaskListText(phoneTaskChoices(store, repos, now_), focusedTitle) }], now_);
237
+ state.plan(event.id, [{ text: phoneTaskListText(phoneTaskChoices(store, repos, now_, null, 8, options.evidenceRoot), focusedTitle) }], now_);
238
238
  else if (command.kind === "lead") {
239
239
  store.setChatFocus(surface, binding.id, null, now_);
240
240
  state.plan(event.id, [{ text: PHONE_BACK_TO_LEAD }], now_);
@@ -243,7 +243,7 @@ export async function processChatEvent(options) {
243
243
  const pick = resolvePhoneTask(store, repos, now_, command.id);
244
244
  if (pick.kind === "one") {
245
245
  store.setChatFocus(surface, binding.id, pick.id, now_);
246
- const view = phoneTaskView(store, repos, pick.view, now_);
246
+ const view = phoneTaskView(store, repos, pick.view, now_, options.evidenceRoot);
247
247
  // The status names its task (and result): a reply to it is about that task.
248
248
  state.plan(event.id, [{ text: phoneFocusText(view.text), ...(view.link === null ? {} : { link: view.link }), task: pick.view, ...(view.run === null ? {} : { run: view.run }) }], now_);
249
249
  }
@@ -69,7 +69,7 @@ export function finishedLine(fact) {
69
69
  if (fact.checks === "passed")
70
70
  return `${ready}. Your tests passed. ${fact.pullRequest === true ? "Merge it?" : "Accept and finish it?"}`;
71
71
  if (fact.checks === "off")
72
- return `${ready}. Checks are off for this project, so look it over first.`;
72
+ return `${ready}. Its checks run at release, so look it over first.`;
73
73
  return `${ready}, but no tests ran. Look it over first.`;
74
74
  case "Failed":
75
75
  if (fact.leadOnIt === true)
@@ -52,6 +52,9 @@ export declare function checkCommandFor(store: Store, repo: string, level: Check
52
52
  level: CheckLevel;
53
53
  command: VerifyCommand | null;
54
54
  };
55
+ /** Readiness uses the result's level, falling back to the task/project for legacy
56
+ * runs. Quick's approved command (or its Full fallback) is required; Off is not. */
57
+ export declare function requiredCheckCommandFor(store: Store, repo: string | null, taskId: string, level: CheckLevel | null | undefined): VerifyCommand | null;
55
58
  /** The builder records which level a run used, so its result says it honestly. */
56
59
  export declare function recordRunCheckLevel(store: Store, run: {
57
60
  id: number;
@@ -118,6 +118,11 @@ export function checkCommandFor(store, repo, level) {
118
118
  }
119
119
  return { level: "full", command: store.liveVerifyCommand(repo) };
120
120
  }
121
+ /** Readiness uses the result's level, falling back to the task/project for legacy
122
+ * runs. Quick's approved command (or its Full fallback) is required; Off is not. */
123
+ export function requiredCheckCommandFor(store, repo, taskId, level) {
124
+ return repo === null ? null : checkCommandFor(store, repo, level ?? effectiveCheckLevel(store, repo, taskId).level).command;
125
+ }
121
126
  /** The builder records which level a run used, so its result says it honestly. */
122
127
  export function recordRunCheckLevel(store, run, level, from, now) {
123
128
  store.recordAction({ at: now.toISOString(), actor: "system", repo: run.repo, taskId: run.taskId, runId: run.id, action: RUN_LEVEL_ACTION, outcome: level, source: "work",
@@ -3,6 +3,7 @@ import type { Store } from './store.js';
3
3
  import type { Principal } from './operate.js';
4
4
  import { type RemoteCommandLookup } from './remote-command.js';
5
5
  import { type Admission, type BudgetLimit } from './request-budget.js';
6
+ import type { SessionRunner } from './session-remote.js';
6
7
  /**
7
8
  * `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
8
9
  * server runs that same command as that person. There is no per-resource API here and no second policy: the transport
@@ -11,6 +12,10 @@ import { type Admission, type BudgetLimit } from './request-budget.js';
11
12
  *
12
13
  * Only `Authorization: Bearer so_…` is accepted — never a password, cookie or the owner's saved login — and a request
13
14
  * carrying a browser Origin is refused, so a page can never drive it.
15
+ *
16
+ * `session …` (D7) is the one command the server answers through its own owner rather than runOperateAs: native
17
+ * session operations need the server's live coding workspace (session-remote.ts). They are admitted, budgeted and
18
+ * refused here exactly like every other command.
14
19
  */
15
20
  /** Who a remote command runs as, and the runner: operate.ts's own types, so a change there breaks this build instead of
16
21
  * drifting. Type-only: the value is loaded lazily through `run` (operate.ts imports the server that imports this). */
@@ -45,6 +50,8 @@ export type CliHttpOptions = {
45
50
  modeOf?: RemoteCommandLookup | undefined;
46
51
  /** The token's request budget (request-budget.ts), charged once per request before its body is read. */
47
52
  admit?: ((principal: Principal) => Admission) | undefined;
53
+ /** Native session operations (`session …`), run by the server's session owner; absent where none is ready. */
54
+ session?: SessionRunner | undefined;
48
55
  store: Store;
49
56
  };
50
57
  /** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
package/dist/cli-http.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { adapterPolicy } from "./server/route-policy.js";
2
2
  import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
3
3
  import { limitWords } from './request-budget.js';
4
+ import { TOKEN_REPLACEMENT } from './password-bearer.js';
4
5
  /** Inlined file contents, summed as UTF-8 bytes. */
5
6
  export const CLI_FILES_BYTES = 256 * 1024;
6
7
  /** The whole JSON body: the files plus argv and JSON escaping headroom. */
@@ -8,11 +9,15 @@ export const CLI_REQUEST_BYTES = 2 * CLI_FILES_BYTES;
8
9
  const ARGV_MAX = 256;
9
10
  const ARG_BYTES = 16 * 1024;
10
11
  const FILES_MAX = 16;
12
+ /** What a missing or unusable token hears: how to get one, and how to save it. */
13
+ const SIGN_IN = `Sign in with an API token: ${TOKEN_REPLACEMENT.charAt(0).toLowerCase()}${TOKEN_REPLACEMENT.slice(1)}, then toolroll connect <origin> --token-stdin.`;
11
14
  function send(response, status, value, headers = {}) {
12
15
  if (response.destroyed || response.writableEnded)
13
16
  return;
14
- response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
15
- response.end(JSON.stringify(value));
17
+ // A declared length: the client can tell a complete answer from a cut-off one.
18
+ const text = JSON.stringify(value);
19
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': String(Buffer.byteLength(text)), 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
20
+ response.end(text);
16
21
  }
17
22
  async function body(request) {
18
23
  const chunks = [];
@@ -83,7 +88,7 @@ export async function handleCliHttp(request, response, options) {
83
88
  if (names.includes('cookie'))
84
89
  return reject(400, 'cookie-refused', 'Remote commands sign in with an API token only.');
85
90
  if (!/^Bearer so_\S+$/.test(request.headers.authorization ?? ''))
86
- return reject(401, 'unauthenticated', 'Sign in with your API token: toolroll connect <origin> --token-stdin.');
91
+ return reject(401, 'unauthenticated', SIGN_IN);
87
92
  if (request.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
88
93
  return reject(415, 'invalid-content-type', 'Send application/json.');
89
94
  if (Number(request.headers['content-length'] ?? 0) > CLI_REQUEST_BYTES)
@@ -96,7 +101,7 @@ export async function handleCliHttp(request, response, options) {
96
101
  return reject(503, 'authentication-unavailable', 'Sign-in could not be checked.');
97
102
  }
98
103
  if (!principal)
99
- return reject(401, 'unauthenticated', 'This API token is not valid (expired, revoked or its person removed).');
104
+ return reject(401, 'unauthenticated', `This API token is not valid (expired, revoked or its person removed). ${TOKEN_REPLACEMENT}.`);
100
105
  // Counted once here, however long the command then holds the request (a task wait is one request).
101
106
  const admitted = options.admit === undefined ? { ok: true } : options.admit(principal);
102
107
  if (!admitted.ok && admitted.status === 503)
@@ -118,6 +123,8 @@ export async function handleCliHttp(request, response, options) {
118
123
  const parsed = cliRequest(value);
119
124
  if ('problem' in parsed)
120
125
  return reject(parsed.tooLarge ? 413 : 400, 'invalid-request', parsed.problem);
126
+ if (parsed.argv[0] === 'session')
127
+ return runSession(response, options, principal, parsed);
121
128
  const row = (options.modeOf ?? contractRow)(parsed.argv);
122
129
  if (row?.mode === 'step-up')
123
130
  return reject(403, 'step-up', STEP_UP_MESSAGE);
@@ -154,3 +161,22 @@ export async function handleCliHttp(request, response, options) {
154
161
  send(response, 200, { exitCode, stdout: out.join(''), stderr: '' });
155
162
  return true;
156
163
  }
164
+ /** `session …`: the prompt is the request's one file; the session owner answers one session envelope on stdout. */
165
+ async function runSession(response, options, principal, parsed) {
166
+ const refuse = (status, code, message) => { send(response, status, { ok: false, code, message }); return true; };
167
+ if (options.session === undefined)
168
+ return refuse(501, 'remote-unavailable', 'This server cannot run session commands. Update it, or use the console.');
169
+ const at = parsed.argv.indexOf('--file'), file = at < 0 ? undefined : parsed.argv[at + 1];
170
+ if (Object.keys(parsed.files).some(path => path !== file))
171
+ return refuse(400, 'invalid-request', 'Each file must name a declared input file argument.');
172
+ const out = [];
173
+ let exitCode;
174
+ try {
175
+ exitCode = await options.session(principal, parsed.argv, parsed.files, line => { out.push(`${line}\n`); });
176
+ }
177
+ catch {
178
+ return refuse(500, 'command-failed', 'The command stopped unexpectedly on the server. Check its state before running it again.');
179
+ }
180
+ send(response, 200, { exitCode, stdout: out.join(''), stderr: '' });
181
+ return true;
182
+ }