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
@@ -756,6 +756,10 @@ export declare const LEAD_TOOL_INPUTS: {
756
756
  report: z.ZodOptional<z.ZodBoolean>;
757
757
  checks: z.ZodOptional<z.ZodString>;
758
758
  }, z.core.$strict>;
759
+ readonly propose_start: z.ZodObject<{
760
+ repo: z.ZodString;
761
+ title: z.ZodString;
762
+ }, z.core.$strict>;
759
763
  readonly propose_sweep: z.ZodObject<{
760
764
  repo: z.ZodString;
761
765
  title: z.ZodString;
@@ -1567,6 +1571,10 @@ export declare const LEAD_TOOL_READ_INPUTS: {
1567
1571
  report: z.ZodOptional<z.ZodBoolean>;
1568
1572
  checks: z.ZodOptional<z.ZodString>;
1569
1573
  }, z.core.$strict>;
1574
+ readonly propose_start: z.ZodObject<{
1575
+ repo: z.ZodString;
1576
+ title: z.ZodString;
1577
+ }, z.core.$strict>;
1570
1578
  readonly propose_sweep: z.ZodObject<{
1571
1579
  repo: z.ZodString;
1572
1580
  title: z.ZodString;
@@ -2212,6 +2220,15 @@ export declare const LEAD_TOOL_OUTPUTS: {
2212
2220
  kind: z.ZodLiteral<"task">;
2213
2221
  awaiting: z.ZodString;
2214
2222
  }, z.core.$loose>;
2223
+ readonly propose_start: z.ZodObject<{
2224
+ proposal: z.ZodNumber;
2225
+ kind: z.ZodEnum<{
2226
+ action: "action";
2227
+ task: "task";
2228
+ }>;
2229
+ startsNow: z.ZodBoolean;
2230
+ awaiting: z.ZodString;
2231
+ }, z.core.$loose>;
2215
2232
  readonly propose_sweep: z.ZodObject<{
2216
2233
  proposal: z.ZodNumber;
2217
2234
  label: z.ZodString;
@@ -52,7 +52,7 @@ export const CODE_MAP_VIEWS = ["overview", "area", "file", "search"];
52
52
  /** Task states a list filters by. */
53
53
  export const LISTED_TASK_STATES = ["queued", "running", "done", "failed", "cancelled"];
54
54
  /** The shared actions the lead proposes with propose_action; flows and subagents have their own tools. */
55
- const ACTION_OPERATIONS = Object.keys(CHAT_ACTIONS).filter(one => !one.startsWith("flow_") && !one.startsWith("subagent_"));
55
+ const ACTION_OPERATIONS = Object.keys(CHAT_ACTIONS).filter(one => !one.startsWith("flow_") && !one.startsWith("subagent_") && one !== "task_start");
56
56
  /** A card's spec as the lead sees it: one flat props object. Each element's own props are read strictly by card-catalog.ts. */
57
57
  const cardProps = z.strictObject({
58
58
  source: z.enum(CARD_SOURCES).optional(), chart: z.enum(CARD_CHART_TYPES).optional(), columns: z.array(z.string().max(40)).max(CARD_LIMITS.columns).optional(), facts: z.array(z.string().max(40)).max(CARD_LIMITS.facts).optional(),
@@ -147,6 +147,8 @@ export const LEAD_TOOL_INPUTS = {
147
147
  not: scopeText.nullable().optional(), touches: touches.optional(), acceptance,
148
148
  planning: z.enum(["auto", "required", "skip"]).optional(), report: z.boolean().optional(), checks: text("checksWords").optional(),
149
149
  }),
150
+ // One-step start from chat (docs/plans/one-step-chat.md C3): one small change the person asked to be done now.
151
+ propose_start: z.strictObject({ repo: repoId, title: text("taskTitle") }),
150
152
  // Lead fan-out (docs/plans/lead-fan-out.md): 2 to 20 independent items under one shared brief and one success test.
151
153
  propose_sweep: z.strictObject({
152
154
  repo: repoId, title: text("taskTitle"), brief: scopeText,
@@ -286,6 +288,7 @@ export const LEAD_TOOL_OUTPUTS = {
286
288
  get_decision: decisionOutput,
287
289
  queue: z.looseObject({ repo: str, queueRevision: int, ...queueColumnsOutput }),
288
290
  propose_task: proposed("task", { repo: str, deliverable: z.enum(["branch", "report"]), planning: str }),
291
+ propose_start: z.looseObject({ proposal: int, kind: z.enum(["action", "task"]), startsNow: z.boolean(), awaiting: str }),
289
292
  propose_sweep: cardDrafted,
290
293
  get_sweep: z.looseObject({ sweep: int, title: str, line: str, table: str, maxParallel: int, items: z.array(z.looseObject({ item: int, label: str, task: str, state: str })) }),
291
294
  propose_sweep_cancel: cardDrafted,
@@ -232,6 +232,7 @@ declare const STORE_COLUMN_VALUES: {
232
232
  task_reopen: "task_reopen";
233
233
  task_restore: "task_restore";
234
234
  task_resume: "task_resume";
235
+ task_start: "task_start";
235
236
  tool_add: "tool_add";
236
237
  tool_remove: "tool_remove";
237
238
  trial_stop: "trial_stop";
@@ -0,0 +1,21 @@
1
+ {
2
+ "phases": [
3
+ { "phase": "preparing", "action": "lift-pause", "flags": ["beforeSwap", "beforePause"] },
4
+ { "phase": "admission-paused", "action": "lift-pause", "flags": ["beforeSwap"] },
5
+ { "phase": "frozen", "action": "lift-pause", "flags": ["beforeSwap"] },
6
+ { "phase": "backup-verified", "action": "lift-pause", "flags": ["beforeSwap"] },
7
+ { "phase": "rehearsed", "action": "lift-pause", "flags": ["beforeSwap"] },
8
+ { "phase": "stopping", "action": "roll-back", "flags": [] },
9
+ { "phase": "stopped", "action": "roll-back", "flags": ["restartable"] },
10
+ { "phase": "migrating", "action": "roll-back", "flags": ["migrationStarted", "restartable"] },
11
+ { "phase": "migrated", "action": "roll-back", "flags": ["migrationStarted", "restartable"] },
12
+ { "phase": "starting", "action": "forward", "flags": [] },
13
+ { "phase": "started", "action": "forward", "flags": [] },
14
+ { "phase": "healthy", "action": "forward", "flags": [] },
15
+ { "phase": "start-failed", "action": "roll-back", "flags": ["migrationStarted", "restartable"] },
16
+ { "phase": "backup-restored", "action": "roll-back", "flags": ["restartable"] },
17
+ { "phase": "restored", "action": "roll-back", "flags": [] },
18
+ { "phase": "released", "action": "none", "flags": [] },
19
+ { "phase": "deployed", "action": "none", "flags": ["deployed"] }
20
+ ]
21
+ }
@@ -0,0 +1,37 @@
1
+ /** What `toolroll release` does with a deployment that stopped at a phase; "refuse" for a word that is no phase. */
2
+ export type DeployAction = "none" | "lift-pause" | "forward" | "roll-back" | "refuse";
3
+ type RowAction = Exclude<DeployAction, "refuse">;
4
+ type Flag = "beforeSwap" | "beforePause" | "migrationStarted" | "restartable" | "deployed";
5
+ type Row = {
6
+ readonly phase: string;
7
+ readonly action: RowAction;
8
+ readonly flags: readonly Flag[];
9
+ };
10
+ /** The rows of a parsed data file, or an Error naming what is wrong with it. Never a partial table. */
11
+ export declare function deployPhaseRows(data: unknown): readonly Row[];
12
+ /** Every phase, in journal order. */
13
+ export declare const DEPLOY_PHASES: readonly string[];
14
+ export declare const isDeployPhase: (phase: unknown) => phase is string;
15
+ /** The one action a journal's phase calls for; "refuse" for anything that is not a phase. */
16
+ export declare const deployActionOf: (phase: unknown) => DeployAction;
17
+ /** The word itself, or an Error: a journal is never written with a word the table does not hold. */
18
+ export declare function assertDeployPhase(phase: string): string;
19
+ /** The service was never stopped. */
20
+ export declare const BEFORE_SWAP: readonly string[];
21
+ /** Before the swap, with the pause in place. */
22
+ export declare const PAUSED_BEFORE_SWAP: readonly string[];
23
+ /** The candidate's store opened the live database, or the new service ran on it. */
24
+ export declare const MIGRATION_STARTED: readonly string[];
25
+ /** The old service is proved stopped: the previous definition may be started again. */
26
+ export declare const RESTARTABLE: readonly string[];
27
+ /** The new service may be running: never rolled back by a journal alone. */
28
+ export declare const FORWARD_ONLY: readonly string[];
29
+ /** Nothing is left to recover. Not the same as deployed: a released deployment never installed its runtime. */
30
+ export declare const NEEDS_NO_RECOVERY: readonly string[];
31
+ /** The deployment finished and its runtime is the installed one. */
32
+ export declare const isDeployed: (phase: unknown) => boolean;
33
+ /** Stopped where nothing was changed (before its pause), or with nothing left to do. */
34
+ export declare const stoppedWhereNothingChanged: (phase: unknown) => boolean;
35
+ /** The phase a lifted pause leaves behind (release-gate.ts). */
36
+ export declare const RELEASED: string;
37
+ export {};
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The phases of a browser deployment's journal, and what a stopped deployment at each one calls for.
3
+ *
4
+ * The rows are data: deploy-phase-data.json beside this file, the one authored list (the build copies it into dist,
5
+ * scripts/postbuild.mjs). The deploy scripts read the same file through scripts/deploy-phase-table.mjs, where each
6
+ * column is described. Both sides check its shape the same way; deploy-phase.test.ts holds them to each other and to
7
+ * its own hand-written expectations.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ const ACTIONS = ["none", "lift-pause", "forward", "roll-back"];
12
+ const FLAGS = ["beforeSwap", "beforePause", "migrationStarted", "restartable", "deployed"];
13
+ /** The rows of a parsed data file, or an Error naming what is wrong with it. Never a partial table. */
14
+ export function deployPhaseRows(data) {
15
+ const rows = data?.phases;
16
+ if (!Array.isArray(rows) || rows.length === 0)
17
+ throw new Error("The deployment phase table has no rows.");
18
+ const seen = new Set();
19
+ return Object.freeze(rows.map((row) => {
20
+ const { phase, action, flags } = (row ?? {});
21
+ if (typeof phase !== "string" || !/^[a-z]+(-[a-z]+)*$/.test(phase) || seen.has(phase))
22
+ throw new Error(`The deployment phase table has a bad or repeated phase: ${String(phase)}.`);
23
+ if (typeof action !== "string" || !ACTIONS.includes(action))
24
+ throw new Error(`The deployment phase ${phase} has no known action.`);
25
+ if (!Array.isArray(flags) || flags.some(flag => typeof flag !== "string" || !FLAGS.includes(flag)) || new Set(flags).size !== flags.length)
26
+ throw new Error(`The deployment phase ${phase} has an unknown or repeated flag.`);
27
+ seen.add(phase);
28
+ return Object.freeze({ phase, action: action, flags: Object.freeze([...flags]) });
29
+ }));
30
+ }
31
+ // By path, not by URL: a test environment that stands in for a browser replaces the global URL.
32
+ const ROWS = deployPhaseRows(JSON.parse(readFileSync(join(import.meta.dirname, "deploy-phase-data.json"), "utf8")));
33
+ const rowOf = (phase) => typeof phase === "string" ? ROWS.find(row => row.phase === phase) : undefined;
34
+ const withFlag = (flag) => Object.freeze(ROWS.filter(row => row.flags.includes(flag)).map(row => row.phase));
35
+ const withAction = (action) => Object.freeze(ROWS.filter(row => row.action === action).map(row => row.phase));
36
+ /** Every phase, in journal order. */
37
+ export const DEPLOY_PHASES = Object.freeze(ROWS.map(row => row.phase));
38
+ export const isDeployPhase = (phase) => rowOf(phase) !== undefined;
39
+ /** The one action a journal's phase calls for; "refuse" for anything that is not a phase. */
40
+ export const deployActionOf = (phase) => rowOf(phase)?.action ?? "refuse";
41
+ /** The word itself, or an Error: a journal is never written with a word the table does not hold. */
42
+ export function assertDeployPhase(phase) {
43
+ if (!isDeployPhase(phase))
44
+ throw new Error(`"${String(phase)}" is not a deployment phase; the journal was not written.`);
45
+ return phase;
46
+ }
47
+ /** The service was never stopped. */
48
+ export const BEFORE_SWAP = withFlag("beforeSwap");
49
+ /** Before the swap, with the pause in place. */
50
+ export const PAUSED_BEFORE_SWAP = Object.freeze(ROWS.filter(row => row.flags.includes("beforeSwap") && !row.flags.includes("beforePause")).map(row => row.phase));
51
+ /** The candidate's store opened the live database, or the new service ran on it. */
52
+ export const MIGRATION_STARTED = withFlag("migrationStarted");
53
+ /** The old service is proved stopped: the previous definition may be started again. */
54
+ export const RESTARTABLE = withFlag("restartable");
55
+ /** The new service may be running: never rolled back by a journal alone. */
56
+ export const FORWARD_ONLY = withAction("forward");
57
+ /** Nothing is left to recover. Not the same as deployed: a released deployment never installed its runtime. */
58
+ export const NEEDS_NO_RECOVERY = withAction("none");
59
+ /** The deployment finished and its runtime is the installed one. */
60
+ export const isDeployed = (phase) => rowOf(phase)?.flags.includes("deployed") === true;
61
+ /** Stopped where nothing was changed (before its pause), or with nothing left to do. */
62
+ export const stoppedWhereNothingChanged = (phase) => { const row = rowOf(phase); return row !== undefined && (row.flags.includes("beforePause") || row.action === "none"); };
63
+ /** The phase a lifted pause leaves behind (release-gate.ts). */
64
+ export const RELEASED = assertDeployPhase("released");
@@ -1,5 +1,6 @@
1
+ import { FORWARD_ONLY } from "./deploy-phase.js";
1
2
  /** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
2
- export declare const FORWARD_ONLY: readonly string[];
3
+ export { FORWARD_ONLY };
3
4
  export type RescueWorld = {
4
5
  /** The live database. */
5
6
  database: string;
@@ -18,10 +18,9 @@
18
18
  */
19
19
  import { realpathSync } from "node:fs";
20
20
  import { join } from "node:path";
21
- import { BEFORE_SWAP } from "./release-gate.js";
21
+ import { BEFORE_SWAP, FORWARD_ONLY, MIGRATION_STARTED, NEEDS_NO_RECOVERY, RESTARTABLE } from "./deploy-phase.js";
22
22
  /** Phases where the new service may be running: recovery leaves them to the release, which resumes forward. */
23
- export const FORWARD_ONLY = Object.freeze(["starting", "started", "healthy"]);
24
- const FINISHED = Object.freeze(["released", "deployed"]);
23
+ export { FORWARD_ONLY };
25
24
  const real = (path) => { try {
26
25
  return realpathSync(path);
27
26
  }
@@ -39,8 +38,8 @@ export function rescuePlan(phase, journal) {
39
38
  if (phase === "stopping") {
40
39
  words.push("The old service must already be stopped. Recovery checks for remaining processes and refuses while any may still be running. Stop them through the service owner, then rerun recovery.");
41
40
  }
42
- if (["stopping", "stopped", "migrating", "migrated", "start-failed", "backup-restored"].includes(phase)) {
43
- if (["migrating", "migrated", "start-failed"].includes(phase))
41
+ if (phase === "stopping" || RESTARTABLE.includes(phase)) {
42
+ if (MIGRATION_STARTED.includes(phase))
44
43
  words.push("Puts the verified backup back first when the previous runtime can't read the migrated database.");
45
44
  words.push(`Starts the previous service (${prior}) again and waits for it to answer, past a stopped worker's heartbeat window; then lifts update pause ${id}.`);
46
45
  }
@@ -56,7 +55,7 @@ export async function rescueDeployment(stageArg, yes, world) {
56
55
  return { ok: false, reason: "usage", message: `${stageArg} is not a deployment stage of this installation (under ${join(world.stateDir, "staged-upgrades")}).` };
57
56
  }
58
57
  else {
59
- const open = all.filter(one => one.journal === null || !FINISHED.includes(String(one.journal["phase"])));
58
+ const open = all.filter(one => one.journal === null || !NEEDS_NO_RECOVERY.includes(String(one.journal["phase"])));
60
59
  if (open.length === 0)
61
60
  return { ok: true, acted: false, stage: null, phase: null, words: ["Nothing to recover: every deployment finished or was already put back."] };
62
61
  if (open.length > 1)
@@ -74,7 +73,7 @@ export async function rescueDeployment(stageArg, yes, world) {
74
73
  return { ok: false, reason: "conflict", message: `That deployment ran on ${String(journal["database"])}, not ${world.database}; nothing was changed.` };
75
74
  if (!Number.isSafeInteger(run) || run < 1)
76
75
  return { ok: false, reason: "damaged", message: `${join(stage, "deployment.json")} names no builder run, so its commit's recovery can't be found; nothing was changed.` };
77
- if (FINISHED.includes(phase))
76
+ if (NEEDS_NO_RECOVERY.includes(phase))
78
77
  return { ok: true, acted: false, stage, phase, words: [`Nothing to recover: that deployment is ${phase}.`] };
79
78
  if (FORWARD_ONLY.includes(phase)) {
80
79
  return { ok: false, reason: "forward", message: `That deployment is at ${phase}: the new service may be running, so nothing was stopped. Continue it forward with \`toolroll release <branch>\` (it resumes the deployment's finish).` };
@@ -97,20 +97,20 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
- version?: never;
100
+ error?: never;
101
101
  active: boolean;
102
102
  phase: string;
103
103
  detail: string;
104
104
  running: boolean;
105
105
  canResume: boolean;
106
106
  canCancel: boolean;
107
- error?: never;
108
107
  automaticRecovery?: never;
109
108
  recoveryAttempts?: never;
110
109
  recoveryRepairs?: never;
111
110
  wasRunning?: never;
112
111
  backupPath?: never;
113
112
  updateId?: never;
113
+ version?: never;
114
114
  buildId?: never;
115
115
  workDir?: never;
116
116
  } | {
@@ -1,4 +1,5 @@
1
1
  import { type ApprovalPasswordFacts } from "./approval-password.js";
2
+ import type { ChatApprovalLimits } from "./chat-approval.js";
2
3
  import { type TaskSizing } from "./phase-routing.js";
3
4
  import { type ApproveResult, type Scope } from "./scope.js";
4
5
  import { type Store } from "./store.js";
@@ -9,9 +10,10 @@ export declare const FAST_MAX_FILES = 3;
9
10
  export declare const FAST_MAX_LINES = 150;
10
11
  /** How long a rendered form may still be pressed. */
11
12
  export declare const FAST_PREVIEW_MS: number;
12
- /** Which person surface is filing. A chat, a flow, a token or an agent has no entry here. */
13
- export type FastVia = "console" | "terminal-ui" | "cli";
14
- export type FastRefusal = "setting" | "size" | "classifier" | "plan" | "quality" | "rules" | "protected" | "password" | "release" | "trial" | "chain" | "monthly" | "policy" | "agents" | "machine" | "mode";
13
+ /** Which person surface is filing. `chat` is a Start card a person taps or answers yes to (one-step-chat.md); a flow, a
14
+ * token or an agent has no entry here. */
15
+ export type FastVia = "console" | "terminal-ui" | "cli" | "chat";
16
+ export type FastRefusal = "setting" | "size" | "classifier" | "plan" | "quality" | "rules" | "protected" | "password" | "release" | "trial" | "chain" | "monthly" | "policy" | "agents" | "machine" | "mode" | "limit";
15
17
  export declare const fastRefusalWords: (reason: FastRefusal) => string;
16
18
  /** Small tasks start right away: a project's own choice, else on for single-approver installs and off once there's more
17
19
  * than one approver. */
@@ -32,10 +34,34 @@ export type FastInput = {
32
34
  permissionMode?: "auto" | "bypassPermissions" | undefined;
33
35
  };
34
36
  export type PasswordFacts = Pick<ApprovalPasswordFacts, "passwordless" | "localOwner" | "freshAt" | "returnTo">;
37
+ /** How the person is proved at the one step that differs by surface: the password rule the form reads, or (a chat
38
+ * start) the person's own chat approval limits. */
39
+ export type FastProof = {
40
+ password: PasswordFacts;
41
+ } | {
42
+ chat: ChatApprovalLimits;
43
+ };
44
+ /** Every person surface's eligibility: the rules, then a machine is refused. */
35
45
  export declare function fastStartEligible(store: Store, input: FastInput & {
36
46
  taskId: string;
37
47
  sizing: TaskSizing;
48
+ } & ({
38
49
  password: PasswordFacts;
50
+ } | {
51
+ proof: FastProof;
52
+ }), now: Date): {
53
+ ok: true;
54
+ scope: Scope;
55
+ } | {
56
+ ok: false;
57
+ reason: FastRefusal;
58
+ };
59
+ /** The rules alone, the same for every surface: only the person-proof step has two forms (FastProof). A preview reads
60
+ * these as any caller; Build reads them again behind the machine check (fastStartEligible). */
61
+ export declare function fastStartRules(store: Store, input: FastInput & {
62
+ taskId: string;
63
+ sizing: TaskSizing;
64
+ proof: FastProof;
39
65
  }, now: Date): {
40
66
  ok: true;
41
67
  scope: Scope;
@@ -59,7 +85,7 @@ export type FastPreview = {
59
85
  * files exactly this, whatever is posted.
60
86
  */
61
87
  export declare function prepareFastStart(store: Store, input: FastInput & {
62
- via: FastVia;
88
+ via: Exclude<FastVia, "chat">;
63
89
  password: PasswordFacts;
64
90
  }, now: Date): Promise<FastPreview>;
65
91
  export type FastBuild = {
@@ -84,9 +110,14 @@ export declare function fastStartBuild(store: Store, input: {
84
110
  digest: string;
85
111
  actor: string;
86
112
  via: FastVia;
87
- password: PasswordFacts;
88
113
  approveNow: FastApprove;
89
- }, now: Date): FastBuild;
114
+ /** A chat start: the lead turn its card was drafted in. Its preview must have been prepared in that same turn. */
115
+ turn?: number;
116
+ } & ({
117
+ password: PasswordFacts;
118
+ } | {
119
+ proof: FastProof;
120
+ }), now: Date): FastBuild;
90
121
  /**
91
122
  * A fast start that didn't start in time: its seal is cleared, in a logged transaction, and the task shows the ordinary
92
123
  * Approve plan. A task with a build running or a result to read is left to that.
@@ -134,3 +165,49 @@ export declare function keepGrownChange(store: Store, taskId: string, now: Date)
134
165
  ok: false;
135
166
  message: string;
136
167
  };
168
+ /** What a chat start's preview is for: who asked, where, the lead's one-line title, and the lead turn it is prepared in. */
169
+ export type ChatStartInput = {
170
+ actor: string;
171
+ repo: string;
172
+ title: string;
173
+ turn: number;
174
+ };
175
+ /**
176
+ * A chat start's preview, prepared by the lead's turn loop before the Start card is drafted: the classifier asked (at
177
+ * most its budget), the filing dry-run, and the rules read. It refuses no machine caller, because a preview grants
178
+ * nothing (the lead's turn runs as the lead): Build refuses a machine (fastStartBuild), and only a person's own tap or
179
+ * typed yes reaches Build. The row is bound to the person, `via = 'chat'` and the turn; a preview without a turn is
180
+ * unusable. The person's own chat limits are read again at the tap, for the chat it comes from; here only the loosest
181
+ * limit that still means something applies (no full access, no cap), so a card is never drafted for terms no chat
182
+ * approval could allow.
183
+ */
184
+ export declare function prepareChatStart(store: Store, input: ChatStartInput, now: Date): Promise<FastPreview>;
185
+ /** A chat start's preview, as the Start card reads it again: this person's, `via = 'chat'`, prepared in this turn,
186
+ * unexpired and unused. Null otherwise. */
187
+ export declare function chatStartPreview(store: Store, preview: string, actor: string, turn: number, now: Date): {
188
+ taskId: string;
189
+ repo: string;
190
+ title: string;
191
+ sizing: TaskSizing;
192
+ digest: string;
193
+ } | null;
194
+ /** A dismissed Start card's preview can't be used: consumed, in the dismissal's transaction. */
195
+ export declare function consumeChatStartPreview(store: Store, preview: string, now: Date): void;
196
+ /**
197
+ * A chat start's filing dry-run, for its card: the scope filing would make, read in a savepoint that is always rolled
198
+ * back, with the rules read on it (any proof form; the card shows the terms, the tap proves the person). The card is made
199
+ * from these words; the tap files again and compares.
200
+ */
201
+ export declare function chatStartDryRun(store: Store, input: {
202
+ actor: string;
203
+ repo: string;
204
+ title: string;
205
+ taskId: string;
206
+ sizing: TaskSizing;
207
+ }, now: Date): {
208
+ ok: true;
209
+ scope: Scope;
210
+ } | {
211
+ ok: false;
212
+ reason: FastRefusal;
213
+ };
@@ -11,11 +11,18 @@
11
11
  * - **the Accept backstop**: a machine can't complete it, the project's grant doesn't push it until a person accepted it,
12
12
  * and completion, publication and merge all read one verified measure of the whole change (verifiedChange).
13
13
  *
14
- * Only a person's own surfaces reach here (the browser, the terminal UI as the local owner, interactive `task new`).
15
- * Every machine is refused in the writer from trusted context. `task add` never comes this way.
14
+ * Only a person's own surfaces reach here (the browser, the terminal UI as the local owner, interactive `task new`, and
15
+ * a person's own tap or typed yes on a chat's Start card: docs/plans/one-step-chat.md). Every machine is refused in the
16
+ * writer from trusted context. `task add` never comes this way.
17
+ *
18
+ * A chat start (`via: "chat"`) runs the same rules (fastStartRules). Its words are the lead's, so it is filed as the
19
+ * lead's text (`proposedVia: "mate"`, quarantined), and only its person-proof step differs: chat has no password, so
20
+ * instead of the password field it passes only within the person's own chat approval limits (chatLimitProblem), and
21
+ * quarantined text is approvable in chat only on a card that showed every term whole (store.ts sealScopeApproval).
16
22
  */
17
23
  import { randomBytes } from "node:crypto";
18
24
  import { approvalPasswordField } from "./approval-password.js";
25
+ import { chatLimitProblem } from "./chat-terms.js";
19
26
  import { isProtectedWork, protectedChanges } from "./approval-policy.js";
20
27
  import { originalTaskBase } from "./observations.js";
21
28
  import { legOf, makesNoPlan, routeFromJson } from "./phase-routing.js";
@@ -53,6 +60,7 @@ const FAST_WORDS = {
53
60
  agents: "Choose an agent first.",
54
61
  machine: "Only a person starts work this way.",
55
62
  mode: "This project's mode approves it.",
63
+ limit: "This is more than your chat approval setting allows.",
56
64
  };
57
65
  export const fastRefusalWords = (reason) => FAST_WORDS[reason];
58
66
  // ---- the project setting ---------------------------------------------------------------------------------------------
@@ -78,10 +86,16 @@ export function setFastStart(store, repo, on, by, now) {
78
86
  // ---- eligibility: one function, read by the form and again inside the transaction ------------------------------------
79
87
  /** Filed this way, the plan's words are the person's words, and they inspect the result. */
80
88
  export const fastAcceptance = (title) => [{ id: "c1", statement: title, how: null, evidence: ["manual-review"] }];
89
+ /** Every person surface's eligibility: the rules, then a machine is refused. */
81
90
  export function fastStartEligible(store, input, now) {
82
- const no = (reason) => ({ ok: false, reason });
83
91
  if (machineActing())
84
- return no("machine");
92
+ return { ok: false, reason: "machine" };
93
+ return fastStartRules(store, { ...input, proof: "proof" in input ? input.proof : { password: input.password } }, now);
94
+ }
95
+ /** The rules alone, the same for every surface: only the person-proof step has two forms (FastProof). A preview reads
96
+ * these as any caller; Build reads them again behind the machine check (fastStartEligible). */
97
+ export function fastStartRules(store, input, now) {
98
+ const no = (reason) => ({ ok: false, reason });
85
99
  if (!fastStartOn(store, input.repo))
86
100
  return no("setting");
87
101
  const account = store.accountOf(input.actor);
@@ -126,9 +140,13 @@ export function fastStartEligible(store, input, now) {
126
140
  const profile = scope.profile;
127
141
  if (levelOfProfile(scope.profile) === "escalated" || profile["checkAccess"] !== undefined || profile["machLookup"] !== undefined)
128
142
  return no("password");
129
- const field = approvalPasswordField(store, { name: input.actor, taskId: input.taskId, scope, ...input.password });
130
- if (field.field === "required" || field.field === "local-refused")
131
- return no("password");
143
+ if ("password" in input.proof) {
144
+ const field = approvalPasswordField(store, { name: input.actor, taskId: input.taskId, scope, ...input.proof.password });
145
+ if (field.field === "required" || field.field === "local-refused")
146
+ return no("password");
147
+ }
148
+ else if (chatLimitProblem(scope, input.proof.chat, "your chat approval setting") !== null)
149
+ return no("limit");
132
150
  const ref = store.lookupRef(input.taskId);
133
151
  if (ref === null || store.budgetGate(now)({ ...store.budgetSubject(ref.id), agents: store.agentsFor([scope.profile.provider]) }).over !== null)
134
152
  return no("monthly");
@@ -136,12 +154,14 @@ export function fastStartEligible(store, input, now) {
136
154
  return no("policy");
137
155
  return { ok: true, scope };
138
156
  }
139
- /** File the task, inside the caller's transaction, exactly as Build will. */
157
+ /** File the task, inside the caller's transaction, exactly as Build will. A chat start's words are the lead's: its text
158
+ * stays quarantined (`proposedVia: "mate"`), as every lead-written scope is. */
140
159
  function fileIt(store, id, input, now) {
141
160
  const filed = fileTaskProposal(store, {
142
161
  id, title: titleWithin(input.title), repo: input.repo, goal: input.title, acceptance: fastAcceptance(input.title), planning: "skip", sizing: input.sizing,
143
162
  ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
144
- filedVia: input.via === "console" ? "console" : input.via === "terminal-ui" ? "tui" : "cli", filedBy: { name: input.actor, kind: "person" },
163
+ filedVia: input.via === "console" ? "console" : input.via === "terminal-ui" ? "tui" : input.via === "chat" ? "chat" : "cli", filedBy: { name: input.actor, kind: "person" },
164
+ ...(input.via === "chat" ? { proposedVia: "mate" } : {}),
145
165
  }, now);
146
166
  return filed.ok ? { ok: true } : { ok: false, message: filed.message };
147
167
  }
@@ -152,10 +172,11 @@ class RolledBack extends Error {
152
172
  this.answer = answer;
153
173
  }
154
174
  }
155
- /** Run `body` in a transaction that never commits: a preview writes nothing. */
175
+ /** Run `body` in a savepoint that never commits: a preview writes nothing, at any nesting depth (a nested transact runs
176
+ * in place, so only the savepoint undoes the writes when a caller already holds a transaction). */
156
177
  function dryRun(store, body) {
157
178
  try {
158
- store.transact(() => { throw new RolledBack(body()); });
179
+ store.transact(() => store.savepoint(() => { throw new RolledBack(body()); }));
159
180
  }
160
181
  catch (error) {
161
182
  if (error instanceof RolledBack)
@@ -172,6 +193,9 @@ function dryRun(store, body) {
172
193
  export async function prepareFastStart(store, input, now) {
173
194
  if (machineActing())
174
195
  return { eligible: false, reason: "machine" };
196
+ // A chat start is prepared in the lead's turn (prepareChatStart), bound to it.
197
+ if (input.via === "chat")
198
+ return { eligible: false, reason: "rules" };
175
199
  if (!fastStartOn(store, input.repo))
176
200
  return { eligible: false, reason: "setting" };
177
201
  if (heuristicSizing({ title: input.title, goal: input.title }).size !== "small")
@@ -203,6 +227,10 @@ export async function prepareFastStart(store, input, now) {
203
227
  export function fastStartBuild(store, input, now) {
204
228
  if (machineActing())
205
229
  return { ok: false, reason: "machine", message: FAST_WORDS.machine };
230
+ const proof = "proof" in input ? input.proof : { password: input.password };
231
+ // The chat form of the proof is a chat start's alone; the form's surfaces keep the password rule.
232
+ if ("chat" in proof && input.via !== "chat")
233
+ return { ok: false, reason: "rules", message: FAST_WORDS.rules };
206
234
  class Refused extends Error {
207
235
  answer;
208
236
  constructor(answer) {
@@ -212,29 +240,37 @@ export function fastStartBuild(store, input, now) {
212
240
  }
213
241
  const refuse = (answer) => { throw new Refused(answer); };
214
242
  try {
215
- return store.transact(() => {
243
+ // A savepoint inside the transaction: a refusal undoes the filing even when the caller already holds a transaction
244
+ // (a nested transact runs in place, and this function catches its own refusal).
245
+ return store.transact(() => store.savepoint(() => {
216
246
  const row = store.handle.prepare("SELECT * FROM fast_preview WHERE id = ? AND consumed_at IS NULL AND expires_at > ?").get(input.preview, now.toISOString());
217
247
  if (row === undefined || row["actor"] !== input.actor || row["via"] !== input.via)
218
248
  return refuse({ ok: false, reason: "gone", message: "This form has expired. Start again." });
249
+ // A chat preview without a turn, or from another turn than its card's, is nobody's card.
250
+ if (input.via === "chat" && (row["turn"] === null || row["turn"] === undefined || input.turn === undefined || Number(row["turn"]) !== input.turn))
251
+ return refuse({ ok: false, reason: "gone", message: "This card has expired. Ask again." });
219
252
  const sizing = JSON.parse(String(row["sizing_json"]));
220
253
  const taskId = String(row["task_id"]);
221
254
  const filing = { actor: input.actor, repo: String(row["repo"]), title: String(row["title"]), ...(row["permission"] === null ? {} : { permissionMode: row["permission"] }) };
222
255
  const filed = fileIt(store, taskId, { ...filing, via: input.via, sizing }, now);
223
256
  if (!filed.ok)
224
257
  return refuse({ ok: false, reason: "changed", message: filed.message });
225
- const eligible = fastStartEligible(store, { ...filing, taskId, sizing, password: input.password }, now);
258
+ const eligible = fastStartEligible(store, { ...filing, taskId, sizing, proof }, now);
226
259
  if (!eligible.ok)
227
260
  return refuse({ ok: false, reason: eligible.reason, message: FAST_WORDS[eligible.reason] });
228
261
  const at = now.toISOString();
229
262
  // approve() compares the digest the person saw with the plan just filed: changed terms refuse "changed".
230
263
  const approved = input.approveNow(taskId, input.digest, { rule: FAST_START_RULE, at });
264
+ if (!approved.ok && approved.reason === "changed")
265
+ return refuse({ ok: false, reason: "changed", message: "This changed since you read it. Read it again." });
266
+ // Chat has no password: a refusal there says the rule, not "takes your password".
231
267
  if (!approved.ok)
232
- return refuse({ ok: false, reason: approved.reason === "changed" ? "changed" : "password", message: approved.reason === "changed" ? "This changed since you read it. Read it again." : FAST_WORDS.password });
268
+ return refuse(input.via === "chat" ? { ok: false, reason: "rules", message: approved.reason === "policy" ? approved.message : FAST_WORDS.rules } : { ok: false, reason: "password", message: FAST_WORDS.password });
233
269
  store.handle.prepare("UPDATE fast_preview SET consumed_at = ? WHERE id = ?").run(at, input.preview);
234
270
  store.handle.prepare("UPDATE task_ref SET fast_start_at = ?, fast_start_until = ? WHERE backend = ? AND external_id = ?").run(at, new Date(now.getTime() + FAST_WINDOW_MS).toISOString(), BUILT_IN, taskId);
235
271
  store.bumpWake();
236
272
  return { ok: true, taskId };
237
- });
273
+ }));
238
274
  }
239
275
  catch (error) {
240
276
  if (error instanceof Refused)
@@ -429,3 +465,64 @@ export function keepGrownChange(store, taskId, now) {
429
465
  return { ok: true, digest: proposed.digest };
430
466
  });
431
467
  }
468
+ /**
469
+ * A chat start's preview, prepared by the lead's turn loop before the Start card is drafted: the classifier asked (at
470
+ * most its budget), the filing dry-run, and the rules read. It refuses no machine caller, because a preview grants
471
+ * nothing (the lead's turn runs as the lead): Build refuses a machine (fastStartBuild), and only a person's own tap or
472
+ * typed yes reaches Build. The row is bound to the person, `via = 'chat'` and the turn; a preview without a turn is
473
+ * unusable. The person's own chat limits are read again at the tap, for the chat it comes from; here only the loosest
474
+ * limit that still means something applies (no full access, no cap), so a card is never drafted for terms no chat
475
+ * approval could allow.
476
+ */
477
+ export async function prepareChatStart(store, input, now) {
478
+ if (!Number.isSafeInteger(input.turn) || input.turn < 1)
479
+ return { eligible: false, reason: "rules" };
480
+ if (!fastStartOn(store, input.repo))
481
+ return { eligible: false, reason: "setting" };
482
+ if (heuristicSizing({ title: input.title, goal: input.title }).size !== "small")
483
+ return { eligible: false, reason: "size" };
484
+ const mode = store.activeMode(input.repo, now);
485
+ if (mode !== null && /"autoApproveFiling":true/.test(mode.termsJson))
486
+ return { eligible: false, reason: "mode" };
487
+ const sizer = filingSizer();
488
+ const sizing = await classifyTask({ title: input.title, goal: input.title }, sizer.sizer, sizer.budget);
489
+ const taskId = `task-${randomBytes(8).toString("hex")}`;
490
+ const filing = { actor: input.actor, repo: input.repo, title: input.title };
491
+ const read = dryRun(store, () => {
492
+ const filed = fileIt(store, taskId, { ...filing, via: "chat", sizing }, now);
493
+ if (!filed.ok)
494
+ return { ok: false, reason: "plan" };
495
+ return fastStartRules(store, { ...filing, taskId, sizing, proof: { chat: { fullAccess: false, capMicrousd: null } } }, now);
496
+ });
497
+ if (!read.ok)
498
+ return { eligible: false, reason: read.reason };
499
+ const preview = randomBytes(16).toString("hex");
500
+ store.handle.prepare(`INSERT INTO fast_preview (id, actor, via, task_id, repo, title, permission, sizing_json, digest, created_at, expires_at, turn) VALUES (?, ?, 'chat', ?, ?, ?, NULL, ?, ?, ?, ?, ?)`)
501
+ .run(preview, input.actor, taskId, input.repo, input.title, JSON.stringify(sizing), read.scope.digest, now.toISOString(), new Date(now.getTime() + FAST_PREVIEW_MS).toISOString(), input.turn);
502
+ return { eligible: true, preview, digest: read.scope.digest, scope: read.scope, taskId };
503
+ }
504
+ /** A chat start's preview, as the Start card reads it again: this person's, `via = 'chat'`, prepared in this turn,
505
+ * unexpired and unused. Null otherwise. */
506
+ export function chatStartPreview(store, preview, actor, turn, now) {
507
+ const row = store.handle.prepare("SELECT * FROM fast_preview WHERE id = ? AND consumed_at IS NULL AND expires_at > ?").get(preview, now.toISOString());
508
+ if (row === undefined || row["actor"] !== actor || row["via"] !== "chat" || row["turn"] === null || Number(row["turn"]) !== turn)
509
+ return null;
510
+ return { taskId: String(row["task_id"]), repo: String(row["repo"]), title: String(row["title"]), sizing: JSON.parse(String(row["sizing_json"])), digest: String(row["digest"]) };
511
+ }
512
+ /** A dismissed Start card's preview can't be used: consumed, in the dismissal's transaction. */
513
+ export function consumeChatStartPreview(store, preview, now) {
514
+ store.handle.prepare("UPDATE fast_preview SET consumed_at = ? WHERE id = ? AND via = 'chat' AND consumed_at IS NULL").run(now.toISOString(), preview);
515
+ }
516
+ /**
517
+ * A chat start's filing dry-run, for its card: the scope filing would make, read in a savepoint that is always rolled
518
+ * back, with the rules read on it (any proof form; the card shows the terms, the tap proves the person). The card is made
519
+ * from these words; the tap files again and compares.
520
+ */
521
+ export function chatStartDryRun(store, input, now) {
522
+ return dryRun(store, () => {
523
+ const filed = fileIt(store, input.taskId, { actor: input.actor, repo: input.repo, title: input.title, via: "chat", sizing: input.sizing }, now);
524
+ if (!filed.ok)
525
+ return { ok: false, reason: "plan" };
526
+ return fastStartRules(store, { actor: input.actor, repo: input.repo, title: input.title, taskId: input.taskId, sizing: input.sizing, proof: { chat: { fullAccess: false, capMicrousd: null } } }, now);
527
+ });
528
+ }