toolroll 0.9.57 → 0.9.59

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 (73) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/action-ledger.js +10 -5
  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-delivery.js +1 -1
  8. package/dist/assignment-status.js +1 -1
  9. package/dist/assignment.js +3 -2
  10. package/dist/browser/workspace.js +1 -1
  11. package/dist/builder.js +4 -2
  12. package/dist/chat-channel.d.ts +1 -0
  13. package/dist/chat-channel.js +3 -0
  14. package/dist/chat-delivery.js +2 -0
  15. package/dist/cli-http.d.ts +7 -0
  16. package/dist/cli-http.js +30 -4
  17. package/dist/cli.js +2 -1
  18. package/dist/contract-changes.d.ts +52 -0
  19. package/dist/contract-changes.js +61 -0
  20. package/dist/coordinator.d.ts +2 -1
  21. package/dist/coordinator.js +22 -31
  22. package/dist/decision.d.ts +4 -0
  23. package/dist/decision.js +8 -0
  24. package/dist/deploy-lock.d.ts +8 -0
  25. package/dist/deploy-lock.js +45 -0
  26. package/dist/desktop-update.d.ts +1 -1
  27. package/dist/dispatch.js +1 -1
  28. package/dist/export.js +2 -2
  29. package/dist/lead-cli.js +3 -0
  30. package/dist/lead-doors.js +3 -0
  31. package/dist/lead-tools.js +2 -1
  32. package/dist/mcp-http.js +5 -5
  33. package/dist/needs-you.js +3 -1
  34. package/dist/operate.js +23 -11
  35. package/dist/password-bearer.d.ts +27 -0
  36. package/dist/password-bearer.js +59 -0
  37. package/dist/planner-source.d.ts +23 -53
  38. package/dist/planner-source.js +43 -68
  39. package/dist/release-gate.d.ts +2 -3
  40. package/dist/release-gate.js +44 -16
  41. package/dist/remote-command.js +0 -2
  42. package/dist/remote-exec.d.ts +19 -0
  43. package/dist/remote-exec.js +9 -8
  44. package/dist/request-budget.d.ts +1 -1
  45. package/dist/scope.js +16 -1
  46. package/dist/serve.js +15 -0
  47. package/dist/server/edge-refusal.d.ts +2 -0
  48. package/dist/server/edge-refusal.js +8 -6
  49. package/dist/server/guards.js +5 -4
  50. package/dist/server/http.d.ts +2 -0
  51. package/dist/server/remote.js +6 -13
  52. package/dist/server/render-chat.js +10 -0
  53. package/dist/server/render-tasks.js +19 -9
  54. package/dist/server/route-table.js +0 -3
  55. package/dist/server/tasks.js +3 -1
  56. package/dist/session-cli.d.ts +38 -9
  57. package/dist/session-cli.js +141 -154
  58. package/dist/session-contract.js +2 -2
  59. package/dist/session-remote.d.ts +19 -0
  60. package/dist/session-remote.js +86 -0
  61. package/dist/store.d.ts +72 -12
  62. package/dist/store.js +367 -105
  63. package/dist/task-status.js +3 -1
  64. package/dist/team-cli.d.ts +3 -0
  65. package/dist/team-cli.js +23 -3
  66. package/dist/telegram-lead.js +6 -0
  67. package/dist/telegram.js +9 -7
  68. package/dist/toolroll-update.js +10 -1
  69. package/package.json +1 -1
  70. package/dist/session-http.d.ts +0 -24
  71. package/dist/session-http.js +0 -170
  72. package/dist/session-server.d.ts +0 -17
  73. package/dist/session-server.js +0 -39
package/dist/builder.js CHANGED
@@ -857,9 +857,11 @@ export async function build(store, request) {
857
857
  // still-blocked history) is left alone rather than having a
858
858
  // revision 1 invented underneath it.
859
859
  if (planDocument !== null && store.latestPlanRevision(taskRef) === null) {
860
+ // Revision 1 for a task planned once; past every earlier generation's revisions for one planned again (v119).
861
+ planRevisionNumber = store.nextPlanRevisionNumber(taskRef);
860
862
  planRevisionId = store.insertPlanRevision({
861
863
  taskRef,
862
- revision: 1,
864
+ revision: planRevisionNumber,
863
865
  artifact: planArtifact.id,
864
866
  parentHash: null,
865
867
  reason: "the plan the operator approved",
@@ -1803,7 +1805,7 @@ function settleRevisionProposal(captured, binding) {
1803
1805
  if (latest !== null && latest.status === "blocked") {
1804
1806
  return broke("the agent proposed a plan revision, but one is already awaiting your approval on this task");
1805
1807
  }
1806
- const revisionNumber = (latest?.revision ?? 0) + 1;
1808
+ const revisionNumber = store.nextPlanRevisionNumber(taskRef);
1807
1809
  // Stored re-serialized from the validated shape, never the agent's raw
1808
1810
  // bytes: what a later brief quotes and hash-verifies is exactly what this
1809
1811
  // parser admitted.
@@ -81,6 +81,7 @@ export declare function confirmedLink(store: Store, outcome: DoorOutcome, propos
81
81
  export declare function proposalPreview(store: Store, proposal: LeadProposal, repos: readonly string[], channel?: ChatChannelName | null): {
82
82
  text: string;
83
83
  buttons: boolean;
84
+ closed?: true;
84
85
  };
85
86
  /** The card after it resolves, from the recorded outcome — the same words every surface shows. */
86
87
  export declare function proposalOutcomeText(proposal: LeadProposal): string;
@@ -1,5 +1,6 @@
1
1
  /** Shared membership-chat identity, proposal previews and result links. Transport adapters never grant authority. */
2
2
  import { FLOW_HREF } from "./flow-engine.js";
3
+ import { closedQuestionWords } from "./decision.js";
3
4
  import { createHash } from "node:crypto";
4
5
  import { sharedActionPayload, sharedActionNeedsReview, sharedActionReviewPath, sharedActionAllowsChallenge, CHAT_ACTIONS, } from "./chat-actions.js";
5
6
  import { isDirectChatProvider, subscriptionCredentialKey } from "./converse.js";
@@ -366,6 +367,8 @@ export function proposalPreview(store, proposal, repos, channel = null) {
366
367
  case "answer": {
367
368
  const id = typeof payload["decision"] === "number" ? payload["decision"] : null;
368
369
  const decision = id === null ? null : store.getDecision(id);
370
+ if (decision?.supersededReason != null)
371
+ return { text: closedQuestionWords(decision.supersededReason), buttons: false, closed: true };
369
372
  const pick = t("option", 64);
370
373
  const irreversible = payload["reversible"] === false;
371
374
  const body = decision === null
@@ -600,6 +600,8 @@ export function applyChatAction(options, event, binding, repos) {
600
600
  const phase = action.phase;
601
601
  if (proposal.state !== "pending")
602
602
  content = { text: proposalOutcomeText(proposal), edit: event.ts };
603
+ else if (preview.closed)
604
+ content = { text: preview.text, edit: event.ts };
603
605
  else if (!preview.buttons ||
604
606
  preview.text.length > (options.maxProposal ?? 10_000))
605
607
  content = { ...content, text: "Review this action in Toolroll." };
@@ -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
+ }
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);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The mechanical difference between two sets of contract terms — a task's goal, exclusions, touches and acceptance
3
+ * rubric. Pure and import-light, so both the planner's source (planner-source.ts) and scope filing (scope.ts, which
4
+ * restarts planning when the terms truly change) read the same comparison.
5
+ */
6
+ import type { AcceptanceCriterion, EvidenceKind } from "./scope.js";
7
+ /** The four contract fields a planner may propose to amend. */
8
+ export type ContractTerms = {
9
+ goal: string;
10
+ outOfScope: string | null;
11
+ touches: string[];
12
+ acceptance: AcceptanceCriterion[];
13
+ };
14
+ export type ContractChange = {
15
+ field: "goal";
16
+ kind: "changed";
17
+ before: string;
18
+ after: string;
19
+ } | {
20
+ field: "outOfScope";
21
+ kind: "added" | "removed" | "changed";
22
+ before: string | null;
23
+ after: string | null;
24
+ } | {
25
+ field: "touches";
26
+ kind: "added" | "removed";
27
+ path: string;
28
+ } | {
29
+ field: "acceptance";
30
+ kind: "added" | "removed" | "changed";
31
+ id: string;
32
+ before: {
33
+ statement: string;
34
+ evidence: EvidenceKind[];
35
+ how: string | null;
36
+ } | null;
37
+ after: {
38
+ statement: string;
39
+ evidence: EvidenceKind[];
40
+ how: string | null;
41
+ } | null;
42
+ /** For `changed`: which parts moved. `how` is advisory and unsigned,
43
+ * but a planner rewriting it is still a change the operator sees. */
44
+ moved: ("statement" | "evidence" | "how")[];
45
+ };
46
+ /**
47
+ * Every addition, change, and removal between the filed contract and a
48
+ * proposed one. Whitespace at the ends and touch/evidence order do not
49
+ * count (the scope digest ignores them too); everything else does,
50
+ * including the advisory `how`, because the operator wrote it.
51
+ */
52
+ export declare function contractChangesOf(filed: ContractTerms, proposed: ContractTerms): ContractChange[];
@@ -0,0 +1,61 @@
1
+ const trimmed = (text) => {
2
+ if (text === null)
3
+ return null;
4
+ const t = text.trim();
5
+ return t === "" ? null : t;
6
+ };
7
+ /**
8
+ * Every addition, change, and removal between the filed contract and a
9
+ * proposed one. Whitespace at the ends and touch/evidence order do not
10
+ * count (the scope digest ignores them too); everything else does,
11
+ * including the advisory `how`, because the operator wrote it.
12
+ */
13
+ export function contractChangesOf(filed, proposed) {
14
+ const changes = [];
15
+ if (filed.goal.trim() !== proposed.goal.trim()) {
16
+ changes.push({ field: "goal", kind: "changed", before: filed.goal, after: proposed.goal });
17
+ }
18
+ const filedOut = trimmed(filed.outOfScope);
19
+ const proposedOut = trimmed(proposed.outOfScope);
20
+ if (filedOut !== proposedOut) {
21
+ changes.push({
22
+ field: "outOfScope",
23
+ kind: filedOut === null ? "added" : proposedOut === null ? "removed" : "changed",
24
+ before: filed.outOfScope,
25
+ after: proposed.outOfScope,
26
+ });
27
+ }
28
+ const filedTouches = new Set(filed.touches.map(one => one.trim()).filter(one => one !== ""));
29
+ const proposedTouches = new Set(proposed.touches.map(one => one.trim()).filter(one => one !== ""));
30
+ for (const path of filedTouches)
31
+ if (!proposedTouches.has(path))
32
+ changes.push({ field: "touches", kind: "removed", path });
33
+ for (const path of proposedTouches)
34
+ if (!filedTouches.has(path))
35
+ changes.push({ field: "touches", kind: "added", path });
36
+ const criterionOf = (one) => ({ statement: one.statement, evidence: [...one.evidence].sort(), how: one.how });
37
+ const filedById = new Map(filed.acceptance.map(one => [one.id, one]));
38
+ const proposedById = new Map(proposed.acceptance.map(one => [one.id, one]));
39
+ for (const [id, before] of filedById) {
40
+ const after = proposedById.get(id);
41
+ if (after === undefined) {
42
+ changes.push({ field: "acceptance", kind: "removed", id, before: criterionOf(before), after: null, moved: [] });
43
+ continue;
44
+ }
45
+ const moved = [];
46
+ if (before.statement.trim() !== after.statement.trim())
47
+ moved.push("statement");
48
+ if ([...before.evidence].sort().join(",") !== [...after.evidence].sort().join(","))
49
+ moved.push("evidence");
50
+ if (trimmed(before.how) !== trimmed(after.how))
51
+ moved.push("how");
52
+ if (moved.length > 0) {
53
+ changes.push({ field: "acceptance", kind: "changed", id, before: criterionOf(before), after: criterionOf(after), moved });
54
+ }
55
+ }
56
+ for (const [id, after] of proposedById) {
57
+ if (!filedById.has(id))
58
+ changes.push({ field: "acceptance", kind: "added", id, before: null, after: criterionOf(after), moved: [] });
59
+ }
60
+ return changes;
61
+ }
@@ -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)
@@ -43,6 +43,10 @@ export declare const LIMITS: {
43
43
  };
44
44
  /** Bytes an operator's note may occupy — the UTF-8 backstop under LIMITS.note. */
45
45
  export declare const NOTE_BYTE_CAP: 16000;
46
+ /** Why a question closed without an answer (v119), in plain words. */
47
+ export declare function closedQuestionReason(reason: "scope-changed" | "cancelled"): string;
48
+ /** The same, said once on one line wherever its answer would show. */
49
+ export declare function closedQuestionWords(reason: "scope-changed" | "cancelled"): string;
46
50
  /**
47
51
  * The one validator every note passes — before a draft persists AND inside
48
52
  * the answer CAS, which stays the final gate. Trims, then refuses empty,
package/dist/decision.js CHANGED
@@ -25,6 +25,14 @@ export const LIMITS = {
25
25
  };
26
26
  /** Bytes an operator's note may occupy — the UTF-8 backstop under LIMITS.note. */
27
27
  export const NOTE_BYTE_CAP = TEXT_LIMITS.noteBytes;
28
+ /** Why a question closed without an answer (v119), in plain words. */
29
+ export function closedQuestionReason(reason) {
30
+ return reason === "scope-changed" ? "The scope changed, so planning started again." : "The task was cancelled.";
31
+ }
32
+ /** The same, said once on one line wherever its answer would show. */
33
+ export function closedQuestionWords(reason) {
34
+ return `Closed without an answer. ${closedQuestionReason(reason)}`;
35
+ }
28
36
  /**
29
37
  * Unicode that reorders or breaks lines invisibly: bidi controls and the
30
38
  * line/paragraph separators. A note carrying these can spoof what a
@@ -0,0 +1,8 @@
1
+ /** Separate from orders.db: no live writer is reserved while deployment drains or backs up.
2
+ * SQLite owns the OS lock until close/process death, including SIGKILL. Never unlink this file:
3
+ * replacing its inode would let two contenders hold different locks for the same journal. */
4
+ export type DeploymentLock = {
5
+ assertHeld(journalFile: string): void;
6
+ release(): void;
7
+ };
8
+ export declare function acquireDeploymentLock(journalFile: string, waitMs?: number): DeploymentLock;
@@ -0,0 +1,45 @@
1
+ import { createRequire } from "node:module";
2
+ import { closeSync, constants, lstatSync, openSync, realpathSync } from "node:fs";
3
+ import { dirname, join, basename } from "node:path";
4
+ const canonical = (file) => join(realpathSync(dirname(file)), basename(file));
5
+ export function acquireDeploymentLock(journalFile, waitMs = 5000) {
6
+ if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 5000)
7
+ throw Error("Invalid deployment lock wait.");
8
+ const journal = canonical(journalFile), file = `${journal}.lock.sqlite`;
9
+ // Only create a missing file. On POSIX, closing ANY independently opened descriptor
10
+ // for an existing inode can drop this process's fcntl lock, even another connection's.
11
+ // Let SQLite manage all descriptors for an existing coordination file.
12
+ try {
13
+ const fd = openSync(file, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
14
+ closeSync(fd);
15
+ }
16
+ catch (error) {
17
+ if (error.code !== "EEXIST")
18
+ throw error;
19
+ }
20
+ if (!lstatSync(file).isFile())
21
+ throw Error(`Unexpected deployment lock: ${file}`);
22
+ // Lazy, as elsewhere in the CLI: merely importing the help must not load SQLite.
23
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
24
+ const db = new DatabaseSync(file);
25
+ try {
26
+ db.exec(`PRAGMA busy_timeout=${waitMs}; BEGIN EXCLUSIVE`);
27
+ }
28
+ catch (cause) {
29
+ db.close();
30
+ throw new Error(`Deployment is busy or its lock is unavailable: ${journal}. Let it finish, then retry.`, { cause });
31
+ }
32
+ let held = true;
33
+ return {
34
+ assertHeld(expected) {
35
+ if (!held || canonical(expected) !== journal)
36
+ throw Error("The deployment journal lock is not held.");
37
+ },
38
+ release() {
39
+ if (!held)
40
+ return;
41
+ db.close();
42
+ held = false;
43
+ },
44
+ };
45
+ }
@@ -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/dispatch.js CHANGED
@@ -319,7 +319,7 @@ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
319
319
  // this is where the task page says so.
320
320
  const sourceProblem = plannerSourceProblemOf(store, taskId);
321
321
  if (sourceProblem !== null)
322
- return answer("planner-source", "waiting", "The filed request cannot be planned as filed", sourceProblem, { action: "write-scope", role });
322
+ return answer("planner-source", "waiting", sourceProblem.title, sourceProblem.detail, { action: "write-scope", role });
323
323
  return answer("planning-ready", "retrying", "Planner ready", "An eligible worker can draft the scope on the next pass.", { role });
324
324
  }
325
325
  if (role === "scout")
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"],
package/dist/lead-cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { configureLeadFollow, leadFollowStatus } from './lead-follow.js';
2
+ import { closedQuestionWords } from "./decision.js";
2
3
  /**
3
4
  * `toolroll chat` (mate arc §6): the same thread the console shows,
4
5
  * driven from a terminal. The password is typed once — it mints the lead
@@ -45,6 +46,8 @@ export function answerContextLines(store, payload) {
45
46
  const decision = id === null ? null : store.getDecision(id);
46
47
  if (decision === null)
47
48
  return [" (the decision is gone)"];
49
+ if (decision.supersededReason !== null)
50
+ return [` ${decision.question}`, ` ${closedQuestionWords(decision.supersededReason)}`];
48
51
  const pick = typeof payload["option"] === "string" ? payload["option"] : "";
49
52
  return [
50
53
  ` ${decision.question}${decision.state !== "open" ? ` [${decision.state}]` : ""}`,
@@ -14,6 +14,7 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
14
14
  * scope was rewritten, the decision answered) is a refusal, never a
15
15
  * silent re-read.
16
16
  */
17
+ import { closedQuestionWords } from "./decision.js";
17
18
  import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
18
19
  import { leadTurnKeepsProposals, verifiedAuthor } from "./store.js";
19
20
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
@@ -317,6 +318,8 @@ function executeProposal(store, actor, kind, payload, now, options) {
317
318
  return refuse("stale", "that option no longer exists on the decision");
318
319
  // The proposal contract is "open": a decision past its deadline — swept
319
320
  // or not — is not answered through a card (v3 review, finding 7).
321
+ if (decision.supersededReason !== null)
322
+ return refuse("stale", closedQuestionWords(decision.supersededReason));
320
323
  if (decision.state === "answered")
321
324
  return refuse("already-answered", "already answered — somebody got there first");
322
325
  if (decision.state !== "open" || (decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime())) {
@@ -284,12 +284,13 @@ export function decisionOver(store, repos, decisionId, now) {
284
284
  return null;
285
285
  // "Open" is derived, not stored (v3 review, finding 7): a decision past
286
286
  // its deadline is expired here whether or not the sweep has run.
287
- const state = decision.state === "open" && decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime() ? "expired" : decision.state;
287
+ const state = decision.supersededReason !== null ? "closed" : decision.state === "open" && decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime() ? "expired" : decision.state;
288
288
  return {
289
289
  repoIndex,
290
290
  decision: decision.id,
291
291
  task: ref.externalId,
292
292
  state,
293
+ ...(decision.supersededReason === null ? {} : { closedBecause: decision.supersededReason }),
293
294
  question: decision.question,
294
295
  options: decision.options.map(one => ({ id: one.id, label: one.label, reversible: one.reversible, consequence: one.consequence })),
295
296
  ageHours: Math.max(0, Math.round((now.getTime() - Date.parse(decision.createdAt)) / 3_600_000)),
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;