toolroll 0.9.97 → 0.9.99

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 (90) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.js +1 -1
  5. package/dist/browser-workspace.d.ts +4 -3
  6. package/dist/chat-actions.d.ts +40 -5
  7. package/dist/chat-actions.js +124 -13
  8. package/dist/chat-card.js +4 -1
  9. package/dist/chat-channel.d.ts +3 -0
  10. package/dist/chat-channel.js +24 -7
  11. package/dist/chat-decide.js +4 -2
  12. package/dist/chat-task-actions.js +1 -1
  13. package/dist/chat-terms.d.ts +8 -0
  14. package/dist/chat-terms.js +14 -0
  15. package/dist/chat-voice.js +1 -1
  16. package/dist/checkout-cleanup.js +2 -1
  17. package/dist/contracts/chat-actions.d.ts +6 -0
  18. package/dist/contracts/chat-actions.js +2 -0
  19. package/dist/contracts/cli.d.ts +6 -0
  20. package/dist/contracts/cli.js +3 -0
  21. package/dist/contracts/lead-tools.d.ts +17 -0
  22. package/dist/contracts/lead-tools.js +4 -1
  23. package/dist/contracts/store-json.d.ts +1 -0
  24. package/dist/deploy-phase-data.json +21 -0
  25. package/dist/deploy-phase.d.ts +37 -0
  26. package/dist/deploy-phase.js +64 -0
  27. package/dist/deploy-rescue.d.ts +2 -1
  28. package/dist/deploy-rescue.js +6 -7
  29. package/dist/desktop-update.d.ts +2 -2
  30. package/dist/fast-start.d.ts +83 -6
  31. package/dist/fast-start.js +112 -15
  32. package/dist/home-groups.d.ts +39 -0
  33. package/dist/home-groups.js +33 -0
  34. package/dist/lead-answer-eval.d.ts +7 -0
  35. package/dist/lead-answer-eval.js +6 -2
  36. package/dist/lead-capabilities.js +1 -1
  37. package/dist/lead-context.js +2 -2
  38. package/dist/lead-contract.d.ts +1 -1
  39. package/dist/lead-contract.js +10 -7
  40. package/dist/lead-doors.d.ts +32 -0
  41. package/dist/lead-doors.js +80 -14
  42. package/dist/lead-status.d.ts +3 -0
  43. package/dist/lead-status.js +8 -3
  44. package/dist/lead-tools.d.ts +20 -0
  45. package/dist/lead-tools.js +66 -3
  46. package/dist/lead-voice.js +1 -1
  47. package/dist/lead.js +18 -2
  48. package/dist/local-owner.js +2 -0
  49. package/dist/one-step-chat-schema.d.ts +22 -0
  50. package/dist/one-step-chat-schema.js +37 -0
  51. package/dist/operate.d.ts +27 -1
  52. package/dist/operate.js +206 -32
  53. package/dist/project-delete.js +9 -0
  54. package/dist/project-knowledge.d.ts +7 -7
  55. package/dist/publish.d.ts +2 -0
  56. package/dist/publish.js +1 -1
  57. package/dist/pull-request-flow.d.ts +4 -0
  58. package/dist/pull-request-flow.js +16 -1
  59. package/dist/release-adapters.js +30 -14
  60. package/dist/release-gate.d.ts +3 -3
  61. package/dist/release-gate.js +7 -4
  62. package/dist/release-next.d.ts +3 -3
  63. package/dist/release-next.js +13 -16
  64. package/dist/reply-voice.d.ts +23 -0
  65. package/dist/reply-voice.js +37 -0
  66. package/dist/scope.d.ts +6 -1
  67. package/dist/scope.js +3 -2
  68. package/dist/server/chat.js +4 -4
  69. package/dist/server/chrome.js +2 -2
  70. package/dist/server/home.d.ts +4 -3
  71. package/dist/server/home.js +33 -28
  72. package/dist/server/render-chat.js +11 -6
  73. package/dist/server/render-pages.js +2 -2
  74. package/dist/setup-guide.d.ts +2 -2
  75. package/dist/staged-runtimes.js +2 -1
  76. package/dist/store.d.ts +4 -2
  77. package/dist/store.js +35 -12
  78. package/dist/telegram-lead.js +69 -5
  79. package/dist/telegram-status.js +9 -4
  80. package/dist/telegram.d.ts +10 -0
  81. package/dist/telegram.js +92 -8
  82. package/dist/toolroll-update.js +3 -1
  83. package/dist/tui-contract.d.ts +2 -0
  84. package/dist/tui-source.js +3 -0
  85. package/dist/tui.js +41 -41
  86. package/dist/typed-start.d.ts +51 -0
  87. package/dist/typed-start.js +98 -0
  88. package/dist/wake-hub.d.ts +72 -0
  89. package/dist/wake-hub.js +151 -0
  90. package/package.json +1 -1
@@ -239,6 +239,15 @@ function deleteRows(store, repo, d, now) {
239
239
  del("chat_decide_action", IN("task_id"), K);
240
240
  del("chat_decide_prompt", IN("task_id"), K);
241
241
  del("chat_message_ref", `${IN("decision")} OR ${IN("task_id")} OR ${IN("run")} OR project = ? OR ${IN("notification")} OR ${IN("task_ref")}`, decisions, K, R, repo, notifications, T);
242
+ // A typed yes to a Start card (one-step-chat.md C5). Its record goes with its message. Where the message stays (it
243
+ // named no task of this project) but the card's proposal goes, the record is settled instead and forgets the task: a
244
+ // yes still waiting is then answered by Toolroll as one that started nothing, never handed to the lead.
245
+ del("chat_typed_start", `provider || ':' || event IN (SELECT value FROM json_each(?))`, conversations);
246
+ if (tableExists(db, "chat_typed_start")) {
247
+ db.prepare(`UPDATE chat_typed_start SET outcome = 'not-counted', task_id = NULL, finished = COALESCE(finished, ?) WHERE ${IN("proposal")}`).run(now.toISOString(), proposals);
248
+ // A yes that started one of this project's tasks, said in a message that stays: the record forgets the task.
249
+ db.prepare(`UPDATE chat_typed_start SET task_id = NULL WHERE ${IN("task_id")}`).run(K);
250
+ }
242
251
  del("chat_part", `provider || ':' || id IN (${parts})`, conversations, K, R);
243
252
  del("chat_event", `provider || ':' || id IN (SELECT value FROM json_each(?))`, conversations);
244
253
  del("chat_card_task", IN("task_ref"), T);
@@ -22,17 +22,20 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
+ content?: never;
26
+ history?: never;
25
27
  revision: number;
26
28
  stale: boolean;
27
29
  notice: string;
28
30
  title?: never;
29
- content?: never;
30
- history?: never;
31
31
  instructions?: never;
32
32
  references?: never;
33
33
  decisions?: never;
34
34
  waiting?: never;
35
35
  } | {
36
+ content?: never;
37
+ revision?: never;
38
+ history?: never;
36
39
  stale?: never;
37
40
  decision: {
38
41
  repo: string;
@@ -51,28 +54,25 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
51
54
  afterwards?: string;
52
55
  notice: string;
53
56
  title?: never;
54
- content?: never;
55
- revision?: never;
56
- history?: never;
57
57
  instructions?: never;
58
58
  references?: never;
59
59
  decisions?: never;
60
60
  waiting?: never;
61
61
  } | {
62
+ history?: never;
62
63
  stale?: never;
63
64
  revision: number;
64
65
  title: string;
65
66
  content: string;
66
67
  notice: string;
67
- history?: never;
68
68
  instructions?: never;
69
69
  references?: never;
70
70
  decisions?: never;
71
71
  waiting?: never;
72
72
  } | {
73
+ content?: never;
73
74
  stale?: never;
74
75
  title?: never;
75
- content?: never;
76
76
  revision: number;
77
77
  history: {
78
78
  revision: number;
package/dist/publish.d.ts CHANGED
@@ -107,6 +107,8 @@ export type ObservedPullRequest = {
107
107
  export declare function observeChecks(store: Store, options: {
108
108
  clock?: () => Date;
109
109
  exec?: PublishExec;
110
+ /** Read only these (the fast pass's waiting pull requests, live-wakeups.md); every one is read exactly as below. */
111
+ publications?: readonly Publication[];
110
112
  }): Promise<CheckReport>;
111
113
  export declare const MERGE_LEASE_MS: number;
112
114
  export declare const FIRING_GRACE_MS: number;
package/dist/publish.js CHANGED
@@ -304,7 +304,7 @@ export async function observeChecks(store, options) {
304
304
  const clock = options.clock ?? (() => new Date());
305
305
  const exec = options.exec ?? execRun;
306
306
  const report = { observed: 0, failing: 0, resolved: 0, problems: [], seen: [] };
307
- for (const publication of store.openedPublications()) {
307
+ for (const publication of options.publications ?? store.openedPublications()) {
308
308
  if (publication.prNumber === null)
309
309
  continue;
310
310
  // The observation generation is RESERVED before the network call
@@ -129,6 +129,8 @@ export declare function followPullRequests(store: Store, seen: readonly Observed
129
129
  evidenceRoot: string;
130
130
  exec?: PublishExec;
131
131
  clock?: () => Date;
132
+ /** Asked again before each revision, ready mark and merge: false ends the pass there and writes nothing more. */
133
+ proceed?: () => boolean;
132
134
  }): Promise<FollowReport>;
133
135
  /** A person's merge: their password first, then the same checked merge. */
134
136
  export declare function mergeAsPerson(store: Store, input: {
@@ -156,6 +158,8 @@ export declare function mergePullRequest(store: Store, input: {
156
158
  method?: MergeMethod;
157
159
  /** Only a person merging by hand may skip waiting for the full check. */
158
160
  anyway?: boolean;
161
+ /** An automatic pass's standing, read in the write that reserves the merge: false refuses ("halted") and merges nothing. */
162
+ proceed?: () => boolean;
159
163
  }): Promise<Result<{
160
164
  commit: string | null;
161
165
  }>>;
@@ -285,7 +285,10 @@ export async function followPullRequests(store, seen, options) {
285
285
  const exec = options.exec ?? execRun;
286
286
  const clock = options.clock ?? (() => new Date());
287
287
  const report = { revisions: 0, asked: 0, ready: 0, merged: 0, problems: [] };
288
+ const halted = () => options.proceed?.() === false;
288
289
  for (const one of seen) {
290
+ if (halted())
291
+ break;
289
292
  const publication = one.publication;
290
293
  const follow = followOf(store, publication.id);
291
294
  // A flow's Pull request zone follows its own PR: green moves the card on, red takes its failure path.
@@ -313,6 +316,8 @@ export async function followPullRequests(store, seen, options) {
313
316
  const check = failingChecks(one.rollup)[0] ?? { name: "a check", url: null };
314
317
  const skeleton = ciRevisionNote(check.name, pr, one.headOid, "");
315
318
  const excerpt = await logExcerpt(exec, publication.githubRepo, check.url, 490 - skeleton.length);
319
+ if (halted())
320
+ break;
316
321
  const note = ciRevisionNote(check.name, pr, one.headOid, excerpt);
317
322
  const request = createHash("sha256").update(`ci-revision ${publication.githubRepo} ${pr} ${one.headOid}`).digest("hex").slice(0, 32);
318
323
  const scope = store.getScope(taskId);
@@ -348,11 +353,17 @@ export async function followPullRequests(store, seen, options) {
348
353
  const grant = store.publicationGrantFor(store.refById(publication.taskRef)?.repo ?? "");
349
354
  updateFollow(store, publication.id, { ready_head: one.headOid }, clock());
350
355
  if (grant?.mergeWhenGreen === true && grant.publishOn === "complete") {
351
- const merged = await mergePullRequest(store, { runId: publication.run, by: "merge when checks pass", exec, clock });
356
+ const merged = await mergePullRequest(store, { runId: publication.run, by: "merge when checks pass", exec, clock,
357
+ ...(options.proceed === undefined ? {} : { proceed: options.proceed }) });
352
358
  if (merged.ok) {
353
359
  report.merged++;
354
360
  continue;
355
361
  }
362
+ // Halted before the merge was reserved: the ready mark is taken back so the next pass merges it, and nobody is asked to.
363
+ if (merged.reason === "halted") {
364
+ updateFollow(store, publication.id, { ready_head: follow.readyHead }, clock());
365
+ break;
366
+ }
356
367
  report.problems.push(`PR #${pr}: ${merged.message}`);
357
368
  }
358
369
  store.enqueueNotification({
@@ -485,6 +496,8 @@ export async function mergePullRequest(store, input) {
485
496
  // One merge at a time, each owning its reservation by its token: a second caller is refused, and each releases only its own.
486
497
  const token = randomUUID();
487
498
  const reserved = store.transact(() => {
499
+ if (input.proceed?.() === false)
500
+ return "halted";
488
501
  if (refArchived(store, publication.taskRef))
489
502
  return "archived";
490
503
  if (store.handle.prepare("SELECT 1 FROM hold WHERE task_ref = ? AND owner_id LIKE 'project-delete:%' AND (until IS NULL OR until > ?)").get(publication.taskRef, clock().toISOString()) !== undefined)
@@ -494,6 +507,8 @@ export async function mergePullRequest(store, input) {
494
507
  store.handle.prepare("INSERT OR REPLACE INTO merge_in_flight (publication, task_ref, token, by, at) VALUES (?, ?, ?, ?, ?)").run(publication.id, publication.taskRef, token, input.by, clock().toISOString());
495
508
  return "ok";
496
509
  });
510
+ if (reserved === "halted")
511
+ return refuse("halted", "The worker is stopping or paused, so this merge waits for its next pass.");
497
512
  if (reserved === "archived")
498
513
  return refuse("archived", "This task is put away. Restore it before merging.");
499
514
  if (reserved === "deleting")
@@ -18,7 +18,8 @@ import { callLabel, readOnlyCall, retryCall, retryingExec, writeWithRecheck } fr
18
18
  const sqlite = () => createRequire(import.meta.url)("node:sqlite");
19
19
  import { PROTECTION_MINIMUM_CHECKS, PROTECTION_REPORT_ONLY_CHECKS } from "./release.js";
20
20
  import { activeUpdateWork } from "./desktop-update-gate.js";
21
- import { BEFORE_SWAP, gateOwner } from "./release-gate.js";
21
+ import { BEFORE_SWAP, deployActionOf, stoppedWhereNothingChanged } from "./deploy-phase.js";
22
+ import { gateOwner } from "./release-gate.js";
22
23
  import { findCliLinks, switchCliLinks } from "./release-links.js";
23
24
  import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, recordReleaseStep, releaseIssuerGone, takeReleaseLease, } from "./store.js";
24
25
  import { acquireDeploymentLock } from "./deploy-lock.js";
@@ -62,8 +63,6 @@ const ownCli = () => {
62
63
  const bin = join(dirname(fileURLToPath(import.meta.url)), "bin.js");
63
64
  return existsSync(bin) ? [process.execPath, bin] : ["toolroll"];
64
65
  };
65
- /** Journal phases the deployment's finish accepts (scripts/deploy-phases.mjs finish): the new service runs, unproven. */
66
- const FINISHES_FORWARD = Object.freeze(["started", "healthy"]);
67
66
  const cleanEnv = (extra = {}) => {
68
67
  const env = { ...process.env, ...extra };
69
68
  delete env["NODE_OPTIONS"];
@@ -580,23 +579,40 @@ export function releaseAdapters(options) {
580
579
  const running = (options.processes ?? processes)().filter(one => one.command.includes("deploy-browser.mjs") && one.command.includes(stage));
581
580
  if (running.length > 0)
582
581
  return { ok: false, message: `the deployment is still running (process ${running.map(one => one.pid).join(", ")}); let it finish, then rerun` };
583
- // The new service started (deploy-rescue.ts FORWARD_ONLY): recovery refuses to roll it back, so the release
584
- // resumes it forward with the deployment's own finish (health, both consoles, leases, then the pause lifted).
582
+ // The new service was started (the phase table's "forward" rows, deploy-phase.ts): the release resumes it
583
+ // forward with the deployment's own finish (health, both consoles, leases, then the pause lifted).
585
584
  // Finishing deploys, so the candidate is proven first, as for a deploy.
586
- const phase = (() => { try {
585
+ const journalPhase = () => { try {
587
586
  return object(JSON.parse(readFileSync(join(stage, "deployment.json"), "utf8")))["phase"];
588
587
  }
589
588
  catch {
590
589
  return undefined;
591
- } })();
592
- if (FINISHES_FORWARD.includes(String(phase))) {
590
+ } };
591
+ const phase = journalPhase();
592
+ if (deployActionOf(phase) === "forward") {
593
593
  const proven = proveCandidate(runId);
594
- if (!proven.ok)
595
- return stopped(stage, proven.message);
596
- const finished = await deployScript(worktree, stage, runId, proven, ["--yes", "--phase", "finish"]);
597
- if (finished.code === 0)
594
+ let why;
595
+ if (proven.ok) {
596
+ const finished = await deployScript(worktree, stage, runId, proven, ["--yes", "--phase", "finish"]);
597
+ if (finished.code === 0)
598
+ return { ok: true };
599
+ why = `${tail(finished)} (the deployment journal is at ${String(phase)}; finishing it forward stopped; ${join(stage, "deployment.json")})`;
600
+ }
601
+ else
602
+ why = proven.message;
603
+ // Once the new service was recorded as started, a journal alone never rolls it back: the release stops here.
604
+ // Still at "starting" (killed while waiting for the new service; finish only looks, and it did not find it
605
+ // running): the deployment's own recovery puts the previous service back, and only when it proves the new
606
+ // one gone. That needs no approval of the candidate, exactly as below.
607
+ if (journalPhase() !== "starting")
608
+ return stopped(stage, why);
609
+ const recorded = recordedCommit(runId);
610
+ if (recorded === null)
611
+ return stopped(stage, why);
612
+ const back = await deployScript(worktree, stage, runId, recorded, ["--phase", "recover"]);
613
+ if (back.code === 0)
598
614
  return { ok: true };
599
- return stopped(stage, `${tail(finished)} (the deployment journal is at ${String(phase)}; finishing it forward stopped; ${join(stage, "deployment.json")})`);
615
+ return stopped(stage, `${why} The previous service was not put back either: ${tail(back)}`);
600
616
  }
601
617
  // Recovery needs no approval, but it runs only the recorded commit's own script, extracted by this runtime.
602
618
  const recorded = recordedCommit(runId);
@@ -642,7 +658,7 @@ export function releaseAdapters(options) {
642
658
  catch {
643
659
  return false;
644
660
  }
645
- return phase === "preparing" || phase === "released" || phase === "deployed";
661
+ return stoppedWhereNothingChanged(phase);
646
662
  },
647
663
  async cliLinks() { return findCliLinks(); },
648
664
  async switchLinks(links, bin, version) {
@@ -18,9 +18,10 @@
18
18
  * Anything missing, damaged or contradicting refuses and leaves the pause as it is. Without --yes it only shows the
19
19
  * proof; with --yes it lifts that one pause (the owner is checked again inside the same write) and journals it.
20
20
  */
21
+ import { BEFORE_SWAP } from "./deploy-phase.js";
21
22
  import type { Database } from "./store.js";
22
- /** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
23
- export declare const BEFORE_SWAP: readonly string[];
23
+ /** The journal phases a deployment passes before it stops the old service (deploy-phase.ts, the one table). */
24
+ export { BEFORE_SWAP };
24
25
  export type GateReleaseWorld = {
25
26
  /** The live database file. */
26
27
  database: string;
@@ -81,4 +82,3 @@ export type GateReleaseOutcome = {
81
82
  };
82
83
  /** Prove, then (with `yes`) lift exactly that pause and journal the release in the deployment's own journal. */
83
84
  export declare function releaseGate(id: string, world: GateReleaseWorld, yes: boolean): GateReleaseOutcome;
84
- export {};
@@ -23,8 +23,9 @@ import { join } from "node:path";
23
23
  import { durableJson } from "./desktop-update.js";
24
24
  import { removeUpdateGate, updateGateOwned } from "./desktop-update-gate.js";
25
25
  import { acquireDeploymentLock } from "./deploy-lock.js";
26
- /** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
27
- export const BEFORE_SWAP = Object.freeze(["preparing", "admission-paused", "frozen", "backup-verified", "rehearsed"]);
26
+ import { BEFORE_SWAP, RELEASED } from "./deploy-phase.js";
27
+ /** The journal phases a deployment passes before it stops the old service (deploy-phase.ts, the one table). */
28
+ export { BEFORE_SWAP };
28
29
  /** Journal fields that only the swap and later phases write. */
29
30
  const SWAP_FIELDS = ["stoppingService", "codingBackupBeforeSwap", "migration", "newService", "deployedAt"];
30
31
  const GATE_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
@@ -121,7 +122,9 @@ export function proveBeforeSwap(id, world) {
121
122
  if (staged.length > 0)
122
123
  return refuse("after-swap", `Process ${staged.map(one => one.pid).join(", ")} runs from the staged runtime ${r.nextRuntime}.`);
123
124
  const supervisor = processes.find(one => one.command.includes(`${r.priorRuntime}/controller-service.js`));
124
- const worker = processes.find(one => one.command.includes(`${r.priorRuntime}/cli.js up --db ${world.database}`));
125
+ // The database as a whole argument: an `up` on a path that only begins with this database's is another service's.
126
+ const upHere = `${r.priorRuntime}/cli.js up --db ${world.database}`;
127
+ const worker = processes.find(one => one.command.split(upHere).slice(1).some(rest => rest === "" || /^\s/.test(rest)));
125
128
  if (supervisor === undefined || worker === undefined)
126
129
  return refuse("conflict", `The service is not running from ${r.priorRuntime}, so it can't be shown that the swap never began.`);
127
130
  proof.push(`The service runs from ${r.priorRuntime} (process ${supervisor.pid}); nothing runs from the staged runtime, and no deployment is running.`);
@@ -163,7 +166,7 @@ export function releaseGate(id, world, yes) {
163
166
  // The owner is checked again within removeUpdateGate's short write transaction.
164
167
  removeUpdateGate(db, id);
165
168
  durableJson(journalFile, {
166
- ...fresh.journal, phase: "released", updatedAt: world.now().toISOString(),
169
+ ...fresh.journal, phase: RELEASED, updatedAt: world.now().toISOString(),
167
170
  releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: fresh.journal.phase, proof: fresh.proof },
168
171
  });
169
172
  return { ok: true, released: true, id, stage: fresh.stage, proof: fresh.proof };
@@ -1,3 +1,4 @@
1
+ import { deployActionOf, type DeployAction } from "./deploy-phase.js";
1
2
  import { type RescueWorld } from "./deploy-rescue.js";
2
3
  import { type GateReleaseWorld } from "./release-gate.js";
3
4
  import { type ReleaseJournal } from "./release.js";
@@ -29,9 +30,8 @@ export type SettleWorld = {
29
30
  rescue: RescueWorld;
30
31
  gate: GateReleaseWorld;
31
32
  };
32
- export type DeployAction = "none" | "lift-pause" | "forward" | "roll-back" | "refuse";
33
- /** The one action a deployment's journal calls for. */
34
- export declare function deployActionOf(phase: unknown): DeployAction;
33
+ /** The one action a deployment's journal calls for: a row of the one phase table (deploy-phase.ts). */
34
+ export { deployActionOf, type DeployAction };
35
35
  /** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
36
36
  export declare function settleDeployment(world: SettleWorld, say: (line: string) => void): Promise<{
37
37
  ok: true;
@@ -11,8 +11,9 @@
11
11
  import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeSync } from "node:fs";
12
12
  import { hostname } from "node:os";
13
13
  import { dirname, join } from "node:path";
14
- import { FORWARD_ONLY, rescueDeployment } from "./deploy-rescue.js";
15
- import { BEFORE_SWAP, releaseGate } from "./release-gate.js";
14
+ import { deployActionOf } from "./deploy-phase.js";
15
+ import { rescueDeployment } from "./deploy-rescue.js";
16
+ import { releaseGate } from "./release-gate.js";
16
17
  import { journalFile, readJournal } from "./release.js";
17
18
  import { autoPublishHeld, PUBLISH_ALLOWED_ACTION } from "./release-auto.js";
18
19
  import { sameMachineHost } from "./machine-identity.js";
@@ -78,18 +79,8 @@ export async function takeReleaseRun(stateDir, branch, say, seams = {}) {
78
79
  await sleep(seams.pollMs ?? 5_000);
79
80
  }
80
81
  }
81
- /** The one action a deployment's journal calls for. */
82
- export function deployActionOf(phase) {
83
- if (typeof phase !== "string")
84
- return "refuse";
85
- if (phase === "released" || phase === "deployed")
86
- return "none";
87
- if (BEFORE_SWAP.includes(phase))
88
- return "lift-pause";
89
- if (FORWARD_ONLY.includes(phase))
90
- return "forward";
91
- return "roll-back";
92
- }
82
+ /** The one action a deployment's journal calls for: a row of the one phase table (deploy-phase.ts). */
83
+ export { deployActionOf };
93
84
  /** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
94
85
  export async function settleDeployment(world, say) {
95
86
  const open = world.rescue.journals().filter(one => deployActionOf(one.journal?.["phase"]) !== "none");
@@ -99,8 +90,14 @@ export async function settleDeployment(world, say) {
99
90
  return { ok: false, message: `${open.length} deployments are unfinished: ${open.map(one => one.stage).join(", ")}. Nothing was changed.` };
100
91
  const { stage, journal } = open[0];
101
92
  const action = deployActionOf(journal?.["phase"]);
102
- if (action === "refuse")
103
- return { ok: false, message: `The deployment journal in ${stage} can't be read. Nothing was changed.` };
93
+ if (action === "refuse") {
94
+ // A word this version's table does not hold (a newer candidate's, or damage) is never acted on by itself: the
95
+ // deployment's own recovery knows its own phases, and the person starts it.
96
+ const phase = journal?.["phase"];
97
+ return typeof phase === "string"
98
+ ? { ok: false, message: `The deployment in ${stage} stopped at a step this version doesn't know (${/^[a-z-]{1,40}$/.test(phase) ? phase : "not a plain word"}). Nothing was changed. Its own recovery can decide: \`toolroll release --recover-deploy --yes\`.` }
99
+ : { ok: false, message: `The deployment journal in ${stage} can't be read. Nothing was changed.` };
100
+ }
104
101
  if (action === "forward") {
105
102
  say("Finishing the deployment that was under way.");
106
103
  return { ok: true };
@@ -0,0 +1,23 @@
1
+ export type ReplyVoice = {
2
+ /** Characters of the shaped reply. */
3
+ characters: number;
4
+ sentences: number;
5
+ /** The `PLAIN_WORDS` rows said outside code, plus "owner" when the reply calls the person "the owner". */
6
+ internalWords: string[];
7
+ /** Opens with its own steps or a filler word ("I checked", "Let me", "Sure,") instead of the answer. */
8
+ narratedOpening: boolean;
9
+ };
10
+ /** A reply longer than this is counted as long in the eval's report. */
11
+ export declare const LONG_REPLY_CHARACTERS = 320;
12
+ export declare function replyVoice(reply: string): ReplyVoice;
13
+ export type VoiceSummary = {
14
+ answers: number;
15
+ medianCharacters: number;
16
+ /** Share of answers over `LONG_REPLY_CHARACTERS`, from 0 to 1. */
17
+ longShare: number;
18
+ /** Internal words said, summed over the answers. */
19
+ internalWords: number;
20
+ narratedOpenings: number;
21
+ };
22
+ /** One model's answers, summed up for the report. */
23
+ export declare function summarizeVoice(voices: readonly ReplyVoice[]): VoiceSummary;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * How a lead reply reads (docs/plans/lead-voice.md, V3): its length, its sentences, the internal words it says and
3
+ * whether it opens by describing its own steps. Measured on the shaped text a person sees, never the raw reply.
4
+ * A measure for the lead-answer eval's report; nothing gates on it and no reply is rewritten from it.
5
+ */
6
+ import { PLAIN_WORDS } from "./plain-words.js";
7
+ import { shapeReply } from "./reply-shape.js";
8
+ /** A reply longer than this is counted as long in the eval's report. */
9
+ export const LONG_REPLY_CHARACTERS = 320;
10
+ const CODE = /```[\s\S]*?(?:```|$)|`[^`\n]+`/g;
11
+ const OWNER_AS_ADDRESS = /(?<![\w-])the\s+owner(?![\w-])/i;
12
+ const NARRATED_OPENING = /^(?:i\s+checked|i\s+looked\s+at|let\s+me|certainly|sure,)/i;
13
+ export function replyVoice(reply) {
14
+ const shaped = shapeReply(reply);
15
+ const prose = shaped.replace(CODE, " ");
16
+ const internalWords = PLAIN_WORDS.filter(one => one.pattern.test(prose)).map(one => one.never);
17
+ if (OWNER_AS_ADDRESS.test(prose))
18
+ internalWords.push("owner");
19
+ return {
20
+ characters: shaped.length,
21
+ sentences: prose.split(/(?<=[.!?])\s+|\n+/).filter(part => /[\p{L}\p{N}]/u.test(part)).length,
22
+ internalWords,
23
+ narratedOpening: NARRATED_OPENING.test(shaped.trimStart()),
24
+ };
25
+ }
26
+ /** One model's answers, summed up for the report. */
27
+ export function summarizeVoice(voices) {
28
+ const sorted = voices.map(one => one.characters).sort((a, b) => a - b);
29
+ const mid = Math.floor(sorted.length / 2);
30
+ return {
31
+ answers: voices.length,
32
+ medianCharacters: sorted.length === 0 ? 0 : sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2,
33
+ longShare: voices.length === 0 ? 0 : voices.filter(one => one.characters > LONG_REPLY_CHARACTERS).length / voices.length,
34
+ internalWords: voices.reduce((sum, one) => sum + one.internalWords.length, 0),
35
+ narratedOpenings: voices.filter(one => one.narratedOpening).length,
36
+ };
37
+ }
package/dist/scope.d.ts CHANGED
@@ -796,10 +796,15 @@ export declare function fileAndSealUnderMode(store: Store, input: ScopeInput & {
796
796
  detail?: string;
797
797
  };
798
798
  export declare function approve(store: Store, taskId: string, by: string, now: Date, sawDigest: string, token: string, mutation?: Mutation,
799
- /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it. */
799
+ /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it.
800
+ * `typed`: a typed yes to a Start card (one-step-chat.md C5), the message that was the yes, recorded beside the marker. */
800
801
  fastStart?: {
801
802
  rule: string;
802
803
  at: string;
804
+ typed?: {
805
+ provider: string;
806
+ event: string;
807
+ };
803
808
  },
804
809
  /** A person's own yes in chat (owner's decision, docs/plans/toolroll-cards.md): their tap or signed-in click stands in
805
810
  * for a password, so `token` is unused. Every rule below still applies. The caller has proved the person is the one
package/dist/scope.js CHANGED
@@ -1075,7 +1075,8 @@ export function fileAndSealUnderMode(store, input) {
1075
1075
  });
1076
1076
  }
1077
1077
  export function approve(store, taskId, by, now, sawDigest, token, mutation = {},
1078
- /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it. */
1078
+ /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it.
1079
+ * `typed`: a typed yes to a Start card (one-step-chat.md C5), the message that was the yes, recorded beside the marker. */
1079
1080
  fastStart,
1080
1081
  /** A person's own yes in chat (owner's decision, docs/plans/toolroll-cards.md): their tap or signed-in click stands in
1081
1082
  * for a password, so `token` is unused. Every rule below still applies. The caller has proved the person is the one
@@ -1216,7 +1217,7 @@ export const APPROVAL_PROOF = "approval proved";
1216
1217
  export const approvalProofDetail = (at, generation, digest, local) => `${new Date(at).toISOString()} · generation ${generation}${local === undefined ? "" : ` · local ${local.generation} ${local.transport}`} · digest ${digest}`;
1217
1218
  /** A fast start's proof: the v1 record proofDetailOf reads, with the marker. `local` keeps the legacy words
1218
1219
  * localSealStandsSql reads, so a local owner's seal stands on the same rule. */
1219
- const fastProofDetail = (fastStart) => (at, generation, digest, local) => JSON.stringify({ v: 1, at: new Date(at).toISOString(), generation, digest, fastStart, ...(local === undefined ? {} : { local: `proof · local ${local.generation} ${local.transport} · seal` }) });
1220
+ const fastProofDetail = ({ typed, ...fastStart }) => (at, generation, digest, local) => JSON.stringify({ v: 1, at: new Date(at).toISOString(), generation, digest, fastStart, ...(typed === undefined ? {} : { typed: { provider: typed.provider, event: typed.event } }), ...(local === undefined ? {} : { local: `proof · local ${local.generation} ${local.transport} · seal` }) });
1220
1221
  /** The proof of THIS approval: the approver's own row for these exact bytes (never another approval's words). */
1221
1222
  export function approvalProofOf(store, taskId, by, digest) {
1222
1223
  const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 50").all(taskId, APPROVAL_PROOF, by);
@@ -780,7 +780,7 @@ export function createChatHandlers(runtime) {
780
780
  const cardJson = String(request.headers["accept"] ?? "").includes("application/json") && !body.has("nonce");
781
781
  const cardAnswer = (status, ok, said, taskId = null) => respond(response, status, "application/json", JSON.stringify({ ok, said, taskId }));
782
782
  if (principal === null)
783
- return cardJson ? cardAnswer(403, false, "Your approver standing changed. Sign in again.") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
783
+ return cardJson ? cardAnswer(403, false, "Your sign-in changed. Sign in again.") : refuse(response, who, 403, "Your sign-in changed. Sign in again.", back);
784
784
  if (op === "dismiss") {
785
785
  const dismissed = dismissLeadProposal(store, principal, id, now);
786
786
  if (cardJson)
@@ -849,7 +849,7 @@ export function createChatHandlers(runtime) {
849
849
  return refuse(response, who, 403, "proposals are confirmed from the browser or the CLI");
850
850
  const principal = leadPrincipal(who);
851
851
  if (principal === null)
852
- return refuse(response, who, 403, "your approver standing changed — sign in again", "/chat");
852
+ return refuse(response, who, 403, "Your sign-in changed. Sign in again.", "/chat");
853
853
  const id = Number(url.pathname.split("/")[2]);
854
854
  store.sweepCoordinatorProposals(now);
855
855
  const back = body.get("return") === null ? "/chat" : safeReturn(body.get("return"));
@@ -919,7 +919,7 @@ export function createChatHandlers(runtime) {
919
919
  if (who.role === "approver" && enabled.billing === "subscription" && unbound) {
920
920
  const principal = leadPrincipal(who);
921
921
  if (principal === null)
922
- return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
922
+ return wantsJson ? said(403, "Your sign-in changed. Sign in again.") : refuse(response, who, 403, "Your sign-in changed. Sign in again.", back);
923
923
  const sentRequest = body.get("request");
924
924
  if (sentRequest !== null && (body.getAll("request").length !== 1 || !/^[a-f0-9]{32}$/.test(sentRequest)))
925
925
  return said(409, "This conversation changed. Reload it before sending your message.");
@@ -961,7 +961,7 @@ export function createChatHandlers(runtime) {
961
961
  const requestId = sentRequest ?? randomBytes(16).toString("hex");
962
962
  const principal = leadPrincipal(who);
963
963
  if (principal === null)
964
- return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
964
+ return wantsJson ? said(403, "Your sign-in changed. Sign in again.") : refuse(response, who, 403, "Your sign-in changed. Sign in again.", back);
965
965
  const message = (body.get("message") ?? "").trim();
966
966
  if (message === "" || message.length > LEAD_MESSAGE_MAX_CHARS) {
967
967
  return said(400, `a message is 1 to ${LEAD_MESSAGE_MAX_CHARS} characters`, leadSession.id);
@@ -726,7 +726,7 @@ export function workToolRows(scoped = false, _offersChat = false, offersCode = f
726
726
  { key: "tasks", href: "/tasks", label: "Task list", hint: "everything, filterable by state" },
727
727
  { key: "recipes", href: "/recipes", label: "Recipes", hint: "choose, customize, and reuse a workflow" },
728
728
  { key: "workbench", href: "/tasks?scope=all", label: "Every project", hint: "every project's tasks in one list" },
729
- { key: "ledger", href: "/ledger", label: "Action ledger", hint: "who acted, what happened, and the result" },
729
+ { key: "ledger", href: "/ledger", label: "Audit log", hint: "who acted, what happened, and the result" },
730
730
  { key: "spend", href: "/spend", label: "Spend", hint: "what agent work cost, and monthly budgets" },
731
731
  ];
732
732
  return scoped ? rows.filter(row => row.key !== "workbench" && row.key !== "spend") : rows;
@@ -737,7 +737,7 @@ export function settingsRows(scoped = false, offersSettings = false) {
737
737
  { key: "fleet", href: "/fleet", label: "Fleet", hint: "who is working, and on what" },
738
738
  { key: "caps", href: "/caps", label: "Requirements", hint: "tools and credentials builds need" },
739
739
  { key: "people", href: "/people", label: "People", hint: "who can sign in, and what they have done" },
740
- { key: "mode", href: "/mode", label: "Operating mode", hint: "the signed posture this repository runs under" },
740
+ { key: "mode", href: "/mode", label: "Operating mode", hint: "how much this project may do without asking" },
741
741
  { key: "system", href: "/system", label: "System", hint: "workers, providers, and grants" },
742
742
  ];
743
743
  return scoped ? rows.filter(row => row.key === "people" || row.key === "settings") : rows;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Home ("/", UI revamp wave B): one sentence that answers "what needs me?", four counts read from the same work index
3
- * the Tasks list filters (so a count and the list it opens always agree), what waits on this person grouped by what it
4
- * asks, what is running, and the lead's message box. Reads only: every act keeps its own route and rules (an agent's
3
+ * the Tasks list filters (so a count and the list it opens always agree), the work in three groups (Waiting on you,
4
+ * Working, Done; home-groups.ts), and the lead's message box. Reads only: every act keeps its own route and rules (an agent's
5
5
  * question is answered through POST /d/<id>/answer, the same one-click act as its own page).
6
6
  */
7
7
  import type { BrowserHomeView } from "../browser-workspace.js";
@@ -37,5 +37,6 @@ export declare function homeSentence(needs: number, perProject: readonly {
37
37
  name: string;
38
38
  needs: number;
39
39
  }[], scope: string | null, any: boolean): string;
40
- /** Home's view for one person and scope, from two reads of the work index (what waits, what runs). */
40
+ /** Home's view for one person and scope, from four reads of the work index (what waits on you, what runs, what waits on
41
+ * Toolroll, what was done), grouped by the one rule every surface shares (home-groups.ts). */
41
42
  export declare function homeView(facts: HomeFacts): BrowserHomeView;