toolroll 0.9.88 → 0.9.90

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 (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +16 -10
  3. package/THIRD_PARTY_NOTICES.md +37 -0
  4. package/dist/agent-board.d.ts +9 -0
  5. package/dist/agent-board.js +20 -0
  6. package/dist/agent-fence.js +20 -2
  7. package/dist/approval-authority.d.ts +7 -0
  8. package/dist/approval-authority.js +13 -2
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +692 -28
  10. package/dist/browser/terminal.js +42 -42
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +30 -30
  13. package/dist/browser-workspace.d.ts +13 -0
  14. package/dist/builder.js +5 -2
  15. package/dist/chat-actions.d.ts +5 -0
  16. package/dist/chat-actions.js +7 -1
  17. package/dist/chat-channel.js +11 -1
  18. package/dist/chat-delivery.js +2 -0
  19. package/dist/chat-task-actions.d.ts +1 -0
  20. package/dist/check-access.d.ts +3 -0
  21. package/dist/check-access.js +4 -1
  22. package/dist/cli-help.d.ts +1 -1
  23. package/dist/cli-help.js +6 -2
  24. package/dist/cli.d.ts +6 -0
  25. package/dist/cli.js +23 -0
  26. package/dist/coding-ui.js +4 -2
  27. package/dist/connect-cli.js +2 -1
  28. package/dist/console-instance.d.ts +34 -1
  29. package/dist/console-instance.js +124 -15
  30. package/dist/contracts/cli.d.ts +72 -0
  31. package/dist/contracts/cli.js +13 -0
  32. package/dist/contracts/console-api.d.ts +9 -2
  33. package/dist/contracts/console-api.js +9 -2
  34. package/dist/desktop-host.js +8 -3
  35. package/dist/desktop-update.js +2 -1
  36. package/dist/dispatch.d.ts +3 -0
  37. package/dist/dispatch.js +22 -2
  38. package/dist/easy-start-args.d.ts +13 -0
  39. package/dist/easy-start-args.js +24 -0
  40. package/dist/easy-start.d.ts +77 -0
  41. package/dist/easy-start.js +321 -0
  42. package/dist/egress.js +5 -0
  43. package/dist/first-run.d.ts +77 -0
  44. package/dist/first-run.js +45 -0
  45. package/dist/flow-starters.js +3 -0
  46. package/dist/flow-triggers.js +4 -0
  47. package/dist/folder-gate.d.ts +32 -0
  48. package/dist/folder-gate.js +43 -0
  49. package/dist/guides.js +1 -1
  50. package/dist/invoke.js +11 -6
  51. package/dist/laptop-background.d.ts +171 -0
  52. package/dist/laptop-background.js +550 -0
  53. package/dist/laptop-service.d.ts +111 -0
  54. package/dist/laptop-service.js +268 -0
  55. package/dist/lead-cli.js +2 -1
  56. package/dist/lead-doors.d.ts +6 -0
  57. package/dist/lead-doors.js +20 -2
  58. package/dist/live-records.d.ts +1 -1
  59. package/dist/live-records.js +3 -12
  60. package/dist/local-owner.d.ts +124 -0
  61. package/dist/local-owner.js +212 -0
  62. package/dist/login-secrets.d.ts +60 -0
  63. package/dist/login-secrets.js +195 -0
  64. package/dist/mcp-oauth.d.ts +4 -0
  65. package/dist/mcp-oauth.js +13 -7
  66. package/dist/model-choices.d.ts +43 -0
  67. package/dist/model-choices.js +80 -0
  68. package/dist/operate.d.ts +46 -1
  69. package/dist/operate.js +461 -40
  70. package/dist/person-proof.js +3 -0
  71. package/dist/project-access.js +15 -1
  72. package/dist/project-admission.d.ts +6 -1
  73. package/dist/project-admission.js +44 -7
  74. package/dist/project-identity.d.ts +8 -0
  75. package/dist/project-identity.js +43 -0
  76. package/dist/project-knowledge.d.ts +7 -7
  77. package/dist/project.d.ts +1 -0
  78. package/dist/project.js +6 -0
  79. package/dist/proposal.js +11 -1
  80. package/dist/quick-start.d.ts +3 -1
  81. package/dist/quick-start.js +6 -1
  82. package/dist/recipes.js +12 -1
  83. package/dist/release-cli.js +2 -1
  84. package/dist/runner-process.d.ts +9 -0
  85. package/dist/runner-process.js +18 -0
  86. package/dist/sample-project.d.ts +31 -0
  87. package/dist/sample-project.js +141 -0
  88. package/dist/scope.d.ts +24 -2
  89. package/dist/scope.js +74 -3
  90. package/dist/serve.js +157 -28
  91. package/dist/server/chat.js +10 -3
  92. package/dist/server/chrome.d.ts +10 -2
  93. package/dist/server/chrome.js +7 -5
  94. package/dist/server/console.css +10 -0
  95. package/dist/server/guards.d.ts +3 -0
  96. package/dist/server/guards.js +22 -2
  97. package/dist/server/http.d.ts +22 -0
  98. package/dist/server/http.js +58 -0
  99. package/dist/server/live.js +4 -14
  100. package/dist/server/mutation-key.d.ts +70 -0
  101. package/dist/server/mutation-key.js +142 -0
  102. package/dist/server/pages.js +12 -9
  103. package/dist/server/people-tokens.js +240 -20
  104. package/dist/server/remote.d.ts +2 -0
  105. package/dist/server/remote.js +19 -4
  106. package/dist/server/render-new-task.js +5 -1
  107. package/dist/server/render-pages.d.ts +2 -1
  108. package/dist/server/render-pages.js +1 -1
  109. package/dist/server/render-people.d.ts +36 -0
  110. package/dist/server/render-people.js +65 -1
  111. package/dist/server/render-projects.js +4 -2
  112. package/dist/server/render-tasks.d.ts +12 -0
  113. package/dist/server/render-tasks.js +16 -6
  114. package/dist/server/request-context.d.ts +6 -0
  115. package/dist/server/route-table.d.ts +16 -0
  116. package/dist/server/route-table.js +121 -0
  117. package/dist/server/runtime.d.ts +6 -4
  118. package/dist/server/session.d.ts +31 -1
  119. package/dist/server/session.js +12 -2
  120. package/dist/server/settings.js +63 -8
  121. package/dist/server/task-views.js +10 -1
  122. package/dist/server/tasks.d.ts +10 -0
  123. package/dist/server/tasks.js +59 -9
  124. package/dist/server/terminals.js +15 -2
  125. package/dist/server/welcome.d.ts +22 -16
  126. package/dist/server/welcome.js +150 -52
  127. package/dist/settings-groups-ui.d.ts +7 -1
  128. package/dist/settings-groups-ui.js +23 -2
  129. package/dist/settings-proofs.js +1 -0
  130. package/dist/setup-guide.d.ts +2 -2
  131. package/dist/setup-guide.js +6 -0
  132. package/dist/store.d.ts +124 -1
  133. package/dist/store.js +245 -12
  134. package/dist/surface.d.ts +10 -0
  135. package/dist/surface.js +40 -1
  136. package/dist/task-text.d.ts +6 -0
  137. package/dist/task-text.js +17 -0
  138. package/dist/teardown-git.js +16 -1
  139. package/dist/telegram-lead.js +2 -0
  140. package/dist/terminal-text.d.ts +11 -0
  141. package/dist/terminal-text.js +26 -0
  142. package/dist/toolroll-update.d.ts +8 -1
  143. package/dist/toolroll-update.js +151 -4
  144. package/dist/tui-contract.d.ts +118 -0
  145. package/dist/tui-contract.js +9 -0
  146. package/dist/tui-source.d.ts +14 -0
  147. package/dist/tui-source.js +182 -0
  148. package/dist/tui.js +129 -0
  149. package/dist/up-processes.d.ts +13 -1
  150. package/dist/up-processes.js +13 -3
  151. package/dist/welcome-facts.d.ts +18 -0
  152. package/dist/welcome-facts.js +79 -0
  153. package/dist/youiyoux-intake.js +11 -1
  154. package/package.json +4 -2
package/dist/store.d.ts CHANGED
@@ -17,6 +17,7 @@ import { type Actor } from "./actor.js";
17
17
  import { type LiveActivityKind } from "./live.js";
18
18
  import { type CredentialKind, type TokenPurpose } from "./api-tokens.js";
19
19
  import { type CriterionMatrixRow, type CriterionJudgementWord } from "./proof.js";
20
+ import { type Authority, type LocalMode } from "./local-owner.js";
20
21
  import { type ScopeAuthor, type ExecutionProfile, type UnattendedPermissionMode, type AcceptanceCriterion } from "./scope.js";
21
22
  import { type ProviderRoom, type CandidateTier, type TaskSize, type TaskSizing, type PhaseRoute, type PublicationAuthority, type RouteStamp, type ReadinessLookup, type ReadinessObservation, type RouteOverride } from "./phase-routing.js";
22
23
  import type { TerminalClass } from "./exhaustion.js";
@@ -233,7 +234,7 @@ export type SealedLedgerEntry = LedgerEntry & {
233
234
  hash: string;
234
235
  } | null;
235
236
  };
236
- export declare const SCHEMA_VERSION = 146;
237
+ export declare const SCHEMA_VERSION = 147;
237
238
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
238
239
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
239
240
  /**
@@ -411,6 +412,21 @@ export declare const V145_SCHEMA = "\nCREATE TABLE IF NOT EXISTS task_archive (\
411
412
  * started, when it ended and how; recording it never changes the release. Both are read-only history for the Ship and
412
413
  * Release views and their CLI twins. */
413
414
  export declare const V146_SCHEMA = "\nCREATE TABLE IF NOT EXISTS ci_check (\n github_repo TEXT NOT NULL,\n pr_number INTEGER NOT NULL,\n head_sha TEXT NOT NULL,\n -- A check's identity: its kind (check run or commit status), its workflow and its name, so two workflows' checks\n -- with the same name stay two rows (a failing one never hides behind a passing namesake).\n ident TEXT NOT NULL,\n name TEXT NOT NULL,\n workflow TEXT,\n state TEXT NOT NULL CHECK (state IN ('queued','running','passed','failed','skipped','cancelled','neutral')),\n started_at TEXT,\n completed_at TEXT,\n url TEXT,\n observed_at TEXT NOT NULL,\n PRIMARY KEY (github_repo, pr_number, head_sha, ident)\n);\nCREATE TABLE IF NOT EXISTS release_step (\n task_id TEXT NOT NULL,\n tag TEXT NOT NULL,\n version TEXT NOT NULL,\n pr INTEGER,\n step TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('running','done','failed','waiting')),\n started_at TEXT NOT NULL,\n ended_at TEXT,\n detail TEXT,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (task_id, tag, step)\n);\nCREATE INDEX IF NOT EXISTS release_step_by_task ON release_step (task_id, updated_at);\n";
415
+ /** v147 (easy-start plan F): local mode, one row. Absent is off. Written only by initLocalMode (a database `up --laptop`
416
+ * created, with no account yet) and changed only through enrollOwnerPassword's precondition and setLocalModeOff, each
417
+ * of which moves `generation` when it changes what a local-owner session may stand on. `enabled` is the plan's `on`
418
+ * (ON is an SQL keyword). */
419
+ export declare const LOCAL_MODE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS local_mode (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n owner TEXT NOT NULL REFERENCES approver(name),\n generation INTEGER NOT NULL CHECK (generation >= 1),\n enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),\n initialized_at TEXT NOT NULL,\n changed_at TEXT NOT NULL\n);\n";
420
+ /** v147: an account's password flag, a browser session's authority and local binding, and the console's key epoch.
421
+ * Every saved row reads the declared value: an account has a password (1), a session is full ('full') with no local
422
+ * generation, no key epoch (stale: the next mutation asks to reconnect) and no key; the console record has no epoch. */
423
+ export declare const V147_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
424
+ definition: string;
425
+ saved: string;
426
+ }>>>>;
427
+ /** The v147 migration's steps after the epoch, in order: a test seam (OpenOptions.migrationStep) may stop after each. */
428
+ export declare const V147_STEPS: readonly ["epoch", "local-mode", "password-set", "web-session", "console-instance"];
429
+ export type MigrationStep = (typeof V147_STEPS)[number];
414
430
  /**
415
431
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
416
432
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -503,6 +519,12 @@ export type WebSessionRow = {
503
519
  ssoAt: number | null;
504
520
  agent: string | null;
505
521
  address: string | null;
522
+ /** v147: the session's authority (saved rows read 'full'); a local owner's local-mode generation; the console start its
523
+ * mutation key belongs to and a hash of that key (never the key). */
524
+ authority: Authority;
525
+ localGeneration: number | null;
526
+ keyEpoch: string | null;
527
+ keyHash: string | null;
506
528
  };
507
529
  /**
508
530
  * Every timestamp column holds `Date.prototype.toISOString()` output and
@@ -1861,6 +1883,11 @@ export declare function databasePath(env: Record<string, string | undefined>, ho
1861
1883
  export type OpenOptions = {
1862
1884
  /** Substituted in tests; production opens node:sqlite itself. */
1863
1885
  connect?: (file: string) => Database;
1886
+ /** v147: who created a NEW database. "laptop" (`up --laptop`) records installation_fact created-by=laptop in the same
1887
+ * transaction as installed-at, the one fact initLocalMode starts from. Ignored for a database that already exists. */
1888
+ createdBy?: "laptop";
1889
+ /** Test seam only: told after each v147 migration step (V147_STEPS); a throw stops the open there, as a crash would. */
1890
+ migrationStep?: (step: MigrationStep) => void;
1864
1891
  };
1865
1892
  /** Every connection's wait at SQLite's lock boundary, in milliseconds. */
1866
1893
  export declare const CONCURRENT_WRITER_WAIT_MS = 15000;
@@ -2465,6 +2492,7 @@ export declare class Store {
2465
2492
  * generation, revocation — one read, no inference. */
2466
2493
  accountOf(name: string): {
2467
2494
  credentialHash: string;
2495
+ passwordSet: boolean;
2468
2496
  role: "approver" | "viewer";
2469
2497
  generation: number;
2470
2498
  revokedAt: string | null;
@@ -8241,3 +8269,98 @@ export declare function releaseProgressFor(store: Store, taskId: string): Releas
8241
8269
  export declare function latestReleaseStepTask(store: Store): string | null;
8242
8270
  /** Seconds a step or check took (to its end, or to `now` while it runs); null without a start. */
8243
8271
  export declare function elapsedSeconds(startedAt: string | null, endedAt: string | null, now: Date): number | null;
8272
+ /** Local mode as saved, or null when this installation never had it (absent is off). */
8273
+ export declare function localModeOf(store: Store): LocalMode | null;
8274
+ /** Who made this database (installation_fact created-by), or null: every database made before v147, or by `serve`. */
8275
+ export declare function installationCreatedBy(store: Store): string | null;
8276
+ export type InitLocalModeResult = {
8277
+ ok: true;
8278
+ owner: string;
8279
+ generation: number;
8280
+ } | {
8281
+ ok: false;
8282
+ reason: "not-laptop" | "no-fence" | "has-accounts" | "already";
8283
+ };
8284
+ /**
8285
+ * Turn local mode on for a database `up --laptop` made, once (plan F). Only when: that database records created-by
8286
+ * laptop, it has no account yet, it never had local mode, and the caller found a fence on this machine (diagnostic
8287
+ * only: it decides whether local mode is offered). One transaction: the owner (no password: a hash of random bytes
8288
+ * that are thrown away, password_set 0), the local_mode row and one ledger line, each inserted only if nothing is
8289
+ * there. A retry or a racing start inserts nothing and writes no second ledger line. Migration and the update
8290
+ * rehearsal never call this.
8291
+ */
8292
+ export declare function initLocalMode(store: Store, input: {
8293
+ owner: string;
8294
+ fenceAvailable: boolean;
8295
+ now: Date;
8296
+ }): InitLocalModeResult;
8297
+ export type EnrollOwnerResult = {
8298
+ ok: true;
8299
+ generation: number;
8300
+ } | {
8301
+ ok: false;
8302
+ reason: "already-set" | "local-mode-changed" | "not-owner";
8303
+ };
8304
+ /**
8305
+ * The local owner sets their first password (plan C4), in one IMMEDIATE transaction: the account still has none,
8306
+ * local mode is on at the generation the caller's session was bound to, and the account is the active owner. Then the
8307
+ * hash is written with password_set 1 and the account's generation moves, every derived authority from before ends
8308
+ * (credential-rotation: every browser session of the account, local-owner ones included), and the ledger says so.
8309
+ * Local mode stays on. A second, concurrent enrollment finds password_set 1 and gets "already-set" ("A password was
8310
+ * just set — sign in"). `credentialHash` is hashPassword's output; the password itself never reaches the store.
8311
+ */
8312
+ export declare function enrollOwnerPassword(store: Store, owner: string, credentialHash: string, expectedLocalGeneration: number, now: Date): EnrollOwnerResult;
8313
+ export type SetLocalModeOffResult = {
8314
+ ok: true;
8315
+ generation: number;
8316
+ } | {
8317
+ ok: false;
8318
+ reason: "not-on" | "not-allowed";
8319
+ };
8320
+ /**
8321
+ * Turn local mode off (plan C5: one click in Settings for a full session). The caller passes the authority it proved
8322
+ * for this request; only full authority of an active instance operator turns it off. In one transaction: the row reads
8323
+ * off and its generation moves (every local-owner session, link and approval bound to the old one stops standing),
8324
+ * the account's local-owner sessions are deleted, and the ledger says who. Turning it back on is not offered.
8325
+ */
8326
+ export declare function setLocalModeOff(store: Store, by: {
8327
+ name: string;
8328
+ authority: Authority;
8329
+ }, now: Date): SetLocalModeOffResult;
8330
+ /**
8331
+ * What a local sign-in link is bound to, read now (plan C4): local mode on, and its owner an active approver. Null
8332
+ * otherwise. The console's mintLocalLink records this; redemption reads it again and compares every field.
8333
+ */
8334
+ export declare function localOwnerBinding(store: Store): {
8335
+ account: string;
8336
+ localGeneration: number;
8337
+ accountGeneration: number;
8338
+ } | null;
8339
+ /** easy-start D: "Keep Toolroll running in the background?" is asked once per installation; this fact records it. */
8340
+ export declare const BACKGROUND_ASKED_FACT = "background-asked";
8341
+ export type BackgroundAnswer = "yes" | "no";
8342
+ /** The person's one answer to the background question and when it was given, or null when it was never asked. */
8343
+ export declare function backgroundAsked(store: Store): {
8344
+ answer: BackgroundAnswer;
8345
+ at: string;
8346
+ } | null;
8347
+ /** Record the answer, once: the first write wins (installation facts are never updated). True when this call recorded it. */
8348
+ export declare function recordBackgroundAsked(store: Store, answer: BackgroundAnswer, now: Date): boolean;
8349
+ /**
8350
+ * Easy-start C5 provenance: a task a laptop's local owner files (no password, inside its admitted act) says so in its
8351
+ * filing ledger line — `filed via local-owner · local <generation> <browser|terminal>` — so the record shows which local
8352
+ * mode generation and which road it came by. Null for every other filing (a person with a password, the lead, a
8353
+ * machine), which keeps the line as it was. task_ref.filed_via is left to the road it came by (console, mate, cli…).
8354
+ */
8355
+ export declare function localFilingProvenance(filer: string | null): string | null;
8356
+ /**
8357
+ * Easy start: the port a NEW laptop install chose when `up`'s default (4180) was already held by something that isn't
8358
+ * this database's console (another Toolroll, the desktop app, any program). Saved once, the first time `up --laptop`
8359
+ * reserves a port on a database `up --laptop` made, so later starts, the background service's unit and `toolroll open`
8360
+ * all find the same address. Installs made any other way never record one: their port stays `up`'s default.
8361
+ */
8362
+ export declare const LAPTOP_PORT_FACT = "laptop-port";
8363
+ /** The saved laptop port, or null (none saved, or a value that isn't a port). */
8364
+ export declare function laptopPortOf(store: Store): number | null;
8365
+ /** Record the laptop port, once (installation facts are never updated). */
8366
+ export declare function recordLaptopPort(store: Store, port: number, now: Date): void;
package/dist/store.js CHANGED
@@ -76,7 +76,8 @@ import { containerEmptiness } from "./container-state.js";
76
76
  import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
77
77
  import { parseReviewContext, reviewContextCustodyProblem } from "./review-context.js";
78
78
  import { foldReview, manualReviewOnly } from "./proof.js";
79
- import { liveReleaseOf } from "./scope.js";
79
+ import { hashPassword, liveReleaseOf } from "./scope.js";
80
+ import { currentLocalOwner, LOCAL_MODE_CONFINES, LOCAL_OWNER_LAPSED, localModeOn, localOwnerInFlight, localOwnerStandsNow } from "./local-owner.js";
80
81
  import { nightlyDebtHold } from "./nightly-github.js";
81
82
  import { releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
82
83
  import { APPROVAL_BASES, approvalOf, machineActing, personProof, proofDetailOf, quarantinedAuthor, sealOriginOf, digestOf, canonicalProfileJson, chainFromJson, profileDigestOf, profileFromJson, scopeAuthorityOf, routeParityProblem, parseAcceptanceCriteria, exactAcceptance, exactStringList, exactSafeIntegerOrNull } from "./scope.js";
@@ -1104,7 +1105,12 @@ const REGISTERED_AT_FILING = "again, as it was filed";
1104
1105
  // deploy_in_flight, flow_step_task; V145_SCHEMA, new tables only).
1105
1106
  // v146 adds, and changes no saved row: each pull request check's own timing as GitHub last reported it for the head the
1106
1107
  // plane observed (ci_check), and each release step's start, end and outcome (release_step; V146_SCHEMA, new tables only).
1107
- export const SCHEMA_VERSION = 146;
1108
+ // v147 adds, and changes no saved row (easy-start plan F): local mode (local_mode, one row, absent = off: only
1109
+ // `up --laptop` on a new database ever writes it), whether an account has a password (approver.password_set; saved rows
1110
+ // read 1), a browser session's authority (web_session.authority; saved rows read 'full'), its local generation, key
1111
+ // epoch and key hash (saved rows read NULL: no local binding, and a stale key), and the running console's key epoch
1112
+ // (console_instance.key_epoch; NULL). V147_ADDED_COLUMNS.
1113
+ export const SCHEMA_VERSION = 147;
1108
1114
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
1109
1115
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
1110
1116
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -1237,7 +1243,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
1237
1243
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
1238
1244
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
1239
1245
  */
1240
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146]);
1246
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147]);
1241
1247
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
1242
1248
  export const V119_ADDED_COLUMNS = Object.freeze({
1243
1249
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1463,6 +1469,35 @@ CREATE TABLE IF NOT EXISTS release_step (
1463
1469
  );
1464
1470
  CREATE INDEX IF NOT EXISTS release_step_by_task ON release_step (task_id, updated_at);
1465
1471
  `;
1472
+ /** v147 (easy-start plan F): local mode, one row. Absent is off. Written only by initLocalMode (a database `up --laptop`
1473
+ * created, with no account yet) and changed only through enrollOwnerPassword's precondition and setLocalModeOff, each
1474
+ * of which moves `generation` when it changes what a local-owner session may stand on. `enabled` is the plan's `on`
1475
+ * (ON is an SQL keyword). */
1476
+ export const LOCAL_MODE_SCHEMA = `
1477
+ CREATE TABLE IF NOT EXISTS local_mode (
1478
+ id INTEGER PRIMARY KEY CHECK (id = 1),
1479
+ owner TEXT NOT NULL REFERENCES approver(name),
1480
+ generation INTEGER NOT NULL CHECK (generation >= 1),
1481
+ enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),
1482
+ initialized_at TEXT NOT NULL,
1483
+ changed_at TEXT NOT NULL
1484
+ );
1485
+ `;
1486
+ /** v147: an account's password flag, a browser session's authority and local binding, and the console's key epoch.
1487
+ * Every saved row reads the declared value: an account has a password (1), a session is full ('full') with no local
1488
+ * generation, no key epoch (stale: the next mutation asks to reconnect) and no key; the console record has no epoch. */
1489
+ export const V147_ADDED_COLUMNS = Object.freeze({
1490
+ approver: { password_set: { definition: "INTEGER NOT NULL DEFAULT 1 CHECK (password_set IN (0,1))", saved: "1" } },
1491
+ web_session: {
1492
+ authority: { definition: "TEXT NOT NULL DEFAULT 'full' CHECK (authority IN ('full','local-owner'))", saved: "'full'" },
1493
+ local_generation: { definition: "INTEGER CHECK (local_generation IS NULL OR local_generation >= 1)", saved: "NULL" },
1494
+ key_epoch: { definition: "TEXT", saved: "NULL" },
1495
+ key_hash: { definition: "TEXT", saved: "NULL" },
1496
+ },
1497
+ console_instance: { key_epoch: { definition: "TEXT", saved: "NULL" } },
1498
+ });
1499
+ /** The v147 migration's steps after the epoch, in order: a test seam (OpenOptions.migrationStep) may stop after each. */
1500
+ export const V147_STEPS = Object.freeze(["epoch", "local-mode", "password-set", "web-session", "console-instance"]);
1466
1501
  /**
1467
1502
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1468
1503
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -4207,7 +4242,7 @@ export function openStore(file, options = {}) {
4207
4242
  const db = connect(file);
4208
4243
  try {
4209
4244
  waitForConcurrentWriter(db);
4210
- return initializeStore(db, file);
4245
+ return initializeStore(db, file, options);
4211
4246
  }
4212
4247
  catch (error) {
4213
4248
  // An unsuccessful open must not retain an unfinished migration's lock
@@ -4285,7 +4320,7 @@ CREATE TABLE IF NOT EXISTS team_request (
4285
4320
  response_json TEXT NOT NULL, PRIMARY KEY(account,generation,request_id)
4286
4321
  );
4287
4322
  `;
4288
- function initializeStore(db, file) {
4323
+ function initializeStore(db, file, options = {}) {
4289
4324
  // THE PREFLIGHT (v48 integrity): the version is read as EXACTLY ONE
4290
4325
  // safe, supported integer before any DDL — one row, a safe integer,
4291
4326
  // its magnitude between 1 and this build's version. A database with no
@@ -4300,7 +4335,7 @@ function initializeStore(db, file) {
4300
4335
  throw new Error(olderThanBaseline(file, read));
4301
4336
  // A fresh file becomes the v107 baseline first, then takes the same steps an installation from 0.5.0 takes.
4302
4337
  const fresh = read === null;
4303
- const preflight = fresh ? createBaseline(db, file) : read;
4338
+ const preflight = fresh ? createBaseline(db, file, options.createdBy) : read;
4304
4339
  // History every database since v107 keeps: an open that finds it missing refuses rather than recreate it empty.
4305
4340
  if (!tableExists(db, "service_cursor")) {
4306
4341
  throw new Error(`${file}: service progress is missing; refusing to silently reset worker cursors`);
@@ -4404,6 +4439,8 @@ function initializeStore(db, file) {
4404
4439
  throw new Error(`${file}: refusing to open — the schema version moved from v${preflight} between the preflight and the epoch stamp (another process is migrating it); this build speaks schema v${SCHEMA_VERSION} and alters nothing it cannot name`);
4405
4440
  }
4406
4441
  }
4442
+ const step = options.migrationStep ?? (() => { });
4443
+ step("epoch");
4407
4444
  // v117 renames first, before any step below names a table: they all speak today's names (the lead's, the subagents').
4408
4445
  // A fresh file is the v107 baseline by now, under the old names, so it takes the same renames.
4409
4446
  renameForV117(db);
@@ -4726,6 +4763,19 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4726
4763
  db.exec(V145_SCHEMA);
4727
4764
  // v146: pull request checks' own timing and release steps (new tables only).
4728
4765
  db.exec(V146_SCHEMA);
4766
+ // v147: local mode (a new table) and the password flag, session authority and key columns (V147_ADDED_COLUMNS: saved
4767
+ // rows read their declared values). Each step is idempotent: a crash between them resumes here on the next open.
4768
+ db.exec(LOCAL_MODE_SCHEMA);
4769
+ step("local-mode");
4770
+ for (const [column, { definition }] of Object.entries(V147_ADDED_COLUMNS["approver"]))
4771
+ addColumn(db, "approver", column, definition);
4772
+ step("password-set");
4773
+ for (const [column, { definition }] of Object.entries(V147_ADDED_COLUMNS["web_session"]))
4774
+ addColumn(db, "web_session", column, definition);
4775
+ step("web-session");
4776
+ for (const [column, { definition }] of Object.entries(V147_ADDED_COLUMNS["console_instance"]))
4777
+ addColumn(db, "console_instance", column, definition);
4778
+ step("console-instance");
4729
4779
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4730
4780
  // nothing to reclaim.
4731
4781
  if (!fresh)
@@ -4755,7 +4805,7 @@ function olderThanBaseline(file, version) {
4755
4805
  * file is looked at again under the write lock: a second opener that waited
4756
4806
  * on the first finds its tables there and refuses rather than recreating them.
4757
4807
  */
4758
- function createBaseline(db, file) {
4808
+ function createBaseline(db, file, createdBy) {
4759
4809
  db.exec("BEGIN IMMEDIATE");
4760
4810
  try {
4761
4811
  if (Number(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master").get()?.["n"]) !== 0) {
@@ -4765,6 +4815,9 @@ function createBaseline(db, file) {
4765
4815
  db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(BASELINE_SCHEMA_VERSION);
4766
4816
  const installedAt = new Date().toISOString();
4767
4817
  db.prepare("INSERT INTO installation_fact (key, value, created_at) VALUES ('installed-at', ?, ?)").run(installedAt, installedAt);
4818
+ // v147: who made this database, in the same transaction as installed-at (initLocalMode starts only from this fact).
4819
+ if (createdBy === "laptop")
4820
+ db.prepare("INSERT INTO installation_fact (key, value, created_at) VALUES ('created-by', 'laptop', ?)").run(installedAt);
4768
4821
  db.exec("COMMIT");
4769
4822
  }
4770
4823
  catch (error) {
@@ -5172,7 +5225,10 @@ function tokenProjectsOf(value) {
5172
5225
  function readWebSession(row) {
5173
5226
  return { idHash: String(row["id_hash"]), account: String(row["account"]), csrf: String(row["csrf"]), role: row["role"] === "viewer" ? "viewer" : "approver", generation: Number(row["generation"]),
5174
5227
  createdAt: Number(row["created_at"]), lastSeen: Number(row["last_seen"]), project: row["project"] == null ? null : String(row["project"]), projectRevision: Number(row["project_revision"]),
5175
- ssoAt: row["sso_at"] == null ? null : Number(row["sso_at"]), agent: row["agent"] == null ? null : String(row["agent"]), address: row["address"] == null ? null : String(row["address"]) };
5228
+ ssoAt: row["sso_at"] == null ? null : Number(row["sso_at"]), agent: row["agent"] == null ? null : String(row["agent"]), address: row["address"] == null ? null : String(row["address"]),
5229
+ // A value this build can't name is never full authority: it reads local-owner, whose binding (NULL here) stands nowhere.
5230
+ authority: row["authority"] === "full" ? "full" : "local-owner", localGeneration: row["local_generation"] == null ? null : Number(row["local_generation"]),
5231
+ keyEpoch: row["key_epoch"] == null ? null : String(row["key_epoch"]), keyHash: row["key_hash"] == null ? null : String(row["key_hash"]) };
5176
5232
  }
5177
5233
  /**
5178
5234
  * v110: the ledger's source admits 'api' and 'mcp', from the v99 shape; rows, ids and the counter are carried whole, so the v103
@@ -6014,7 +6070,7 @@ export class Store {
6014
6070
  // v102: who filed it, set once with the filing (separation of duties reads it; nothing changes it).
6015
6071
  if (spec.filedBy !== undefined) {
6016
6072
  this.db.prepare("UPDATE task_ref SET filed_by = ?, filed_by_kind = ? WHERE id = ?").run(spec.filedBy.name, spec.filedBy.kind, ref.id);
6017
- this.recordAction({ at: stamp, actor: spec.filedBy.name ?? "system", repo: null, taskId: spec.id, runId: null, action: "task filed", outcome: "recorded", source: "work", detail: spec.filedBy.kind === "person" ? null : spec.filedBy.kind });
6073
+ this.recordAction({ at: stamp, actor: spec.filedBy.name ?? "system", repo: null, taskId: spec.id, runId: null, action: "task filed", outcome: "recorded", source: "work", detail: spec.filedBy.kind === "person" ? localFilingProvenance(spec.filedBy.name) : spec.filedBy.kind });
6018
6074
  }
6019
6075
  // The deliverable (v34) lands in the SAME transaction as the filing,
6020
6076
  // and there is no API to change it afterwards.
@@ -6566,6 +6622,11 @@ export class Store {
6566
6622
  this.transacting = true;
6567
6623
  let result;
6568
6624
  try {
6625
+ // Easy-start C1, the write-time re-check: a local owner's chain writes only while it still stands (local mode on
6626
+ // at its generation, the owner active, its session saved), read inside this same transaction.
6627
+ const local = currentLocalOwner();
6628
+ if (local !== null && localOwnerInFlight(local) && !localOwnerStandsNow(this, local))
6629
+ throw new Error(LOCAL_OWNER_LAPSED);
6569
6630
  result = body();
6570
6631
  this.db.exec("COMMIT");
6571
6632
  }
@@ -7684,13 +7745,16 @@ export class Store {
7684
7745
  * generation, revocation — one read, no inference. */
7685
7746
  accountOf(name) {
7686
7747
  const row = this.db
7687
- .prepare("SELECT credential_hash, role, generation, revoked_at, projects_json FROM approver WHERE name = ?")
7748
+ .prepare("SELECT credential_hash, password_set, role, generation, revoked_at, projects_json FROM approver WHERE name = ?")
7688
7749
  .get(name);
7689
7750
  if (row === undefined)
7690
7751
  return null;
7691
7752
  return {
7692
7753
  projects: readProjectAccess(row["projects_json"]),
7693
7754
  credentialHash: String(row["credential_hash"]),
7755
+ // v147: a local-mode owner has no password yet (0); its stored hash is random and must never verify. Anything
7756
+ // but exactly 1 reads as no password (fail closed).
7757
+ passwordSet: Number(row["password_set"]) === 1,
7694
7758
  role: String(row["role"]) === "viewer" ? "viewer" : "approver",
7695
7759
  generation: Number(row["generation"]),
7696
7760
  revokedAt: row["revoked_at"] === null || row["revoked_at"] === undefined ? null : String(row["revoked_at"]),
@@ -8824,9 +8888,11 @@ export class Store {
8824
8888
  });
8825
8889
  }
8826
8890
  saveWebSession(session) {
8827
- this.db.prepare(`INSERT INTO web_session (id_hash, account, csrf, role, generation, created_at, last_seen, project, project_revision, sso_at, agent, address) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8891
+ // v147: authority, local generation, key epoch and key hash are set when the session is made and never changed by a
8892
+ // later save of the same id (ON CONFLICT leaves them): a cached copy can't widen or rebind a kept session.
8893
+ this.db.prepare(`INSERT INTO web_session (id_hash, account, csrf, role, generation, created_at, last_seen, project, project_revision, sso_at, agent, address, authority, local_generation, key_epoch, key_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8828
8894
  ON CONFLICT(id_hash) DO UPDATE SET last_seen = excluded.last_seen, project = excluded.project, project_revision = excluded.project_revision, sso_at = excluded.sso_at`)
8829
- .run(session.idHash, session.account, session.csrf, session.role, session.generation, session.createdAt, session.lastSeen, session.project, session.projectRevision, session.ssoAt, session.agent, session.address);
8895
+ .run(session.idHash, session.account, session.csrf, session.role, session.generation, session.createdAt, session.lastSeen, session.project, session.projectRevision, session.ssoAt, session.agent, session.address, session.authority, session.localGeneration, session.keyEpoch, session.keyHash);
8830
8896
  }
8831
8897
  webSession(idHash) {
8832
8898
  const row = this.db.prepare("SELECT * FROM web_session WHERE id_hash = ?").get(idHash);
@@ -19078,10 +19144,16 @@ export class Store {
19078
19144
  }
19079
19145
  /** Whether this installation lets an agent run where no OS confinement exists (off unless an operator said so). */
19080
19146
  unfencedAgentsAllowed() {
19147
+ // Easy-start C5: while local mode is on, this installation always confines, whatever is stored (one chokepoint for
19148
+ // every unconfined fallback: agents, the planner, setup and build commands, checks, flow code, project tools).
19149
+ if (localModeOn(this))
19150
+ return false;
19081
19151
  return Number(this.db.prepare("SELECT unfenced_allowed FROM confinement_setting WHERE id = 1").get()?.["unfenced_allowed"] ?? 0) === 1;
19082
19152
  }
19083
19153
  setUnfencedAgentsAllowed(allowed, by, now) {
19084
19154
  this.transact(() => {
19155
+ if (allowed && localModeOn(this))
19156
+ throw new Error(LOCAL_MODE_CONFINES);
19085
19157
  const before = this.unfencedAgentsAllowed();
19086
19158
  this.db.prepare("UPDATE confinement_setting SET unfenced_allowed = ?, updated_at = ?, updated_by = ? WHERE id = 1").run(allowed ? 1 : 0, now.toISOString(), by);
19087
19159
  this.recordPolicy(by, null, "unconfined agents", before ? "allowed" : "refused", allowed ? "allowed" : "refused", now);
@@ -19999,6 +20071,9 @@ export class Store {
19999
20071
  return null;
20000
20072
  if (widerThan(level, before) && (authority.may !== "widen" || !this.isInstanceOperator(by)))
20001
20073
  return null;
20074
+ // Easy-start C5: while local mode is on, no project's checks run unsandboxed (callers say "Turn off local mode first").
20075
+ if (level === "unsandboxed" && localModeOn(this))
20076
+ return null;
20002
20077
  this.db.prepare(`INSERT INTO project_check_access (repo, level, updated_at, updated_by) VALUES (?, ?, ?, ?)
20003
20078
  ON CONFLICT (repo) DO UPDATE SET level = excluded.level, updated_at = excluded.updated_at, updated_by = excluded.updated_by`).run(repo, level, now.toISOString(), by);
20004
20079
  const after = this.projectCheckAccess(repo);
@@ -22143,3 +22218,161 @@ export function elapsedSeconds(startedAt, endedAt, now) {
22143
22218
  const start = Date.parse(startedAt), end = endedAt === null ? now.getTime() : Date.parse(endedAt);
22144
22219
  return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : null;
22145
22220
  }
22221
+ // ---- v147: local mode (easy-start plan F and C4) ------------------------------------------------------------------
22222
+ /** Local mode as saved, or null when this installation never had it (absent is off). */
22223
+ export function localModeOf(store) {
22224
+ const row = store.handle.prepare("SELECT owner, generation, enabled, initialized_at, changed_at FROM local_mode WHERE id = 1").get();
22225
+ if (row === undefined)
22226
+ return null;
22227
+ const generation = Number(row["generation"]);
22228
+ return { owner: String(row["owner"]), generation: Number.isSafeInteger(generation) ? generation : -1, enabled: Number(row["enabled"]) === 1,
22229
+ initializedAt: String(row["initialized_at"]), changedAt: String(row["changed_at"]) };
22230
+ }
22231
+ /** Who made this database (installation_fact created-by), or null: every database made before v147, or by `serve`. */
22232
+ export function installationCreatedBy(store) {
22233
+ const row = store.handle.prepare("SELECT value FROM installation_fact WHERE key = 'created-by'").get();
22234
+ return row === undefined ? null : String(row["value"]);
22235
+ }
22236
+ /**
22237
+ * Turn local mode on for a database `up --laptop` made, once (plan F). Only when: that database records created-by
22238
+ * laptop, it has no account yet, it never had local mode, and the caller found a fence on this machine (diagnostic
22239
+ * only: it decides whether local mode is offered). One transaction: the owner (no password: a hash of random bytes
22240
+ * that are thrown away, password_set 0), the local_mode row and one ledger line, each inserted only if nothing is
22241
+ * there. A retry or a racing start inserts nothing and writes no second ledger line. Migration and the update
22242
+ * rehearsal never call this.
22243
+ */
22244
+ export function initLocalMode(store, input) {
22245
+ if (!input.fenceAvailable)
22246
+ return { ok: false, reason: "no-fence" };
22247
+ // Hashed before the transaction (scrypt is slow); the random bytes are never kept anywhere.
22248
+ const unusable = hashPassword(randomBytes(32).toString("hex"));
22249
+ return store.transact(() => {
22250
+ if (installationCreatedBy(store) !== "laptop")
22251
+ return { ok: false, reason: "not-laptop" };
22252
+ if (store.handle.prepare("SELECT 1 FROM local_mode WHERE id = 1").get() !== undefined)
22253
+ return { ok: false, reason: "already" };
22254
+ const stamp = input.now.toISOString();
22255
+ const made = store.handle.prepare(`INSERT INTO approver (name, credential_hash, added_at, generation, password_set)
22256
+ SELECT ?, ?, ?, 1, 0 WHERE NOT EXISTS (SELECT 1 FROM approver) AND NOT EXISTS (SELECT 1 FROM local_mode)`).run(input.owner, unusable, stamp);
22257
+ if (Number(made.changes) !== 1)
22258
+ return { ok: false, reason: "has-accounts" };
22259
+ store.handle.prepare("INSERT INTO local_mode (id, owner, generation, enabled, initialized_at, changed_at) VALUES (1, ?, 1, 1, ?, ?)").run(input.owner, stamp, stamp);
22260
+ store.recordAction({ at: stamp, actor: input.owner, repo: null, taskId: null, runId: null, action: "local mode on", outcome: "initialized", source: "access", detail: "a new laptop install: the owner has no password yet" });
22261
+ return { ok: true, owner: input.owner, generation: 1 };
22262
+ });
22263
+ }
22264
+ /**
22265
+ * The local owner sets their first password (plan C4), in one IMMEDIATE transaction: the account still has none,
22266
+ * local mode is on at the generation the caller's session was bound to, and the account is the active owner. Then the
22267
+ * hash is written with password_set 1 and the account's generation moves, every derived authority from before ends
22268
+ * (credential-rotation: every browser session of the account, local-owner ones included), and the ledger says so.
22269
+ * Local mode stays on. A second, concurrent enrollment finds password_set 1 and gets "already-set" ("A password was
22270
+ * just set — sign in"). `credentialHash` is hashPassword's output; the password itself never reaches the store.
22271
+ */
22272
+ export function enrollOwnerPassword(store, owner, credentialHash, expectedLocalGeneration, now) {
22273
+ return store.transact(() => {
22274
+ const account = store.accountOf(owner);
22275
+ if (account === null || account.revokedAt !== null || account.role !== "approver")
22276
+ return { ok: false, reason: "not-owner" };
22277
+ if (account.passwordSet)
22278
+ return { ok: false, reason: "already-set" };
22279
+ const mode = localModeOf(store);
22280
+ if (mode === null || mode.owner !== owner)
22281
+ return { ok: false, reason: "not-owner" };
22282
+ if (!mode.enabled || mode.generation !== expectedLocalGeneration)
22283
+ return { ok: false, reason: "local-mode-changed" };
22284
+ const written = store.handle.prepare(`UPDATE approver SET credential_hash = ?, password_set = 1, generation = generation + 1
22285
+ WHERE name = ? AND password_set = 0 AND revoked_at IS NULL AND role = 'approver'`).run(credentialHash, owner);
22286
+ if (Number(written.changes) !== 1)
22287
+ return { ok: false, reason: "already-set" };
22288
+ store.revokeDerivedAuthority(owner, "credential-rotation", now);
22289
+ store.recordAction({ at: now.toISOString(), actor: owner, repo: null, taskId: null, runId: null, action: "password set", outcome: "enrolled", source: "access", detail: `local mode stays on (generation ${mode.generation})` });
22290
+ return { ok: true, generation: account.generation + 1 };
22291
+ });
22292
+ }
22293
+ /**
22294
+ * Turn local mode off (plan C5: one click in Settings for a full session). The caller passes the authority it proved
22295
+ * for this request; only full authority of an active instance operator turns it off. In one transaction: the row reads
22296
+ * off and its generation moves (every local-owner session, link and approval bound to the old one stops standing),
22297
+ * the account's local-owner sessions are deleted, and the ledger says who. Turning it back on is not offered.
22298
+ */
22299
+ export function setLocalModeOff(store, by, now) {
22300
+ if (by.authority !== "full")
22301
+ return { ok: false, reason: "not-allowed" };
22302
+ return store.transact(() => {
22303
+ if (!store.isInstanceOperator(by.name))
22304
+ return { ok: false, reason: "not-allowed" };
22305
+ const stamp = now.toISOString();
22306
+ const moved = store.handle.prepare("UPDATE local_mode SET enabled = 0, generation = generation + 1, changed_at = ? WHERE id = 1 AND enabled = 1").run(stamp);
22307
+ if (Number(moved.changes) !== 1)
22308
+ return { ok: false, reason: "not-on" };
22309
+ store.handle.prepare("DELETE FROM web_session WHERE authority <> 'full'").run();
22310
+ const mode = localModeOf(store);
22311
+ store.recordAction({ at: stamp, actor: by.name, repo: null, taskId: null, runId: null, action: "local mode off", outcome: "changed", source: "access", detail: `generation ${mode.generation}` });
22312
+ return { ok: true, generation: mode.generation };
22313
+ });
22314
+ }
22315
+ /**
22316
+ * What a local sign-in link is bound to, read now (plan C4): local mode on, and its owner an active approver. Null
22317
+ * otherwise. The console's mintLocalLink records this; redemption reads it again and compares every field.
22318
+ */
22319
+ export function localOwnerBinding(store) {
22320
+ const mode = localModeOf(store);
22321
+ if (mode === null || !mode.enabled || mode.generation < 1)
22322
+ return null;
22323
+ const account = store.accountOf(mode.owner);
22324
+ if (account === null || account.revokedAt !== null || account.role !== "approver")
22325
+ return null;
22326
+ return { account: mode.owner, localGeneration: mode.generation, accountGeneration: account.generation };
22327
+ }
22328
+ /** easy-start D: "Keep Toolroll running in the background?" is asked once per installation; this fact records it. */
22329
+ export const BACKGROUND_ASKED_FACT = "background-asked";
22330
+ /** The person's one answer to the background question and when it was given, or null when it was never asked. */
22331
+ export function backgroundAsked(store) {
22332
+ const row = store.handle.prepare("SELECT value, created_at FROM installation_fact WHERE key = ?").get(BACKGROUND_ASKED_FACT);
22333
+ if (row === undefined)
22334
+ return null;
22335
+ const value = String(row["value"]);
22336
+ // A value this build doesn't know still means "asked": never ask again on the strength of a word it can't read.
22337
+ return { answer: value === "yes" ? "yes" : "no", at: String(row["created_at"]) };
22338
+ }
22339
+ /** Record the answer, once: the first write wins (installation facts are never updated). True when this call recorded it. */
22340
+ export function recordBackgroundAsked(store, answer, now) {
22341
+ return store.transact(() => {
22342
+ if (backgroundAsked(store) !== null)
22343
+ return false;
22344
+ store.recordInstallationFact(BACKGROUND_ASKED_FACT, answer, now);
22345
+ return true;
22346
+ });
22347
+ }
22348
+ /**
22349
+ * Easy-start C5 provenance: a task a laptop's local owner files (no password, inside its admitted act) says so in its
22350
+ * filing ledger line — `filed via local-owner · local <generation> <browser|terminal>` — so the record shows which local
22351
+ * mode generation and which road it came by. Null for every other filing (a person with a password, the lead, a
22352
+ * machine), which keeps the line as it was. task_ref.filed_via is left to the road it came by (console, mate, cli…).
22353
+ */
22354
+ export function localFilingProvenance(filer) {
22355
+ const local = currentLocalOwner();
22356
+ if (local === null || !localOwnerInFlight(local) || local.account !== filer)
22357
+ return null;
22358
+ return `filed via local-owner · local ${local.localGeneration} ${local.transport}`;
22359
+ }
22360
+ /**
22361
+ * Easy start: the port a NEW laptop install chose when `up`'s default (4180) was already held by something that isn't
22362
+ * this database's console (another Toolroll, the desktop app, any program). Saved once, the first time `up --laptop`
22363
+ * reserves a port on a database `up --laptop` made, so later starts, the background service's unit and `toolroll open`
22364
+ * all find the same address. Installs made any other way never record one: their port stays `up`'s default.
22365
+ */
22366
+ export const LAPTOP_PORT_FACT = "laptop-port";
22367
+ /** The saved laptop port, or null (none saved, or a value that isn't a port). */
22368
+ export function laptopPortOf(store) {
22369
+ const value = store.installationFact(LAPTOP_PORT_FACT);
22370
+ if (value === null || !/^\d{1,5}$/.test(value))
22371
+ return null;
22372
+ const port = Number(value);
22373
+ return port >= 1 && port < 65536 ? port : null;
22374
+ }
22375
+ /** Record the laptop port, once (installation facts are never updated). */
22376
+ export function recordLaptopPort(store, port, now) {
22377
+ store.recordInstallationFact(LAPTOP_PORT_FACT, String(port), now);
22378
+ }
package/dist/surface.d.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  * SURFACE_NOTES.flags says "intended".
17
17
  */
18
18
  import { type CommandFlag, type CommandRow, type RemotePolicy } from './contracts/cli.js';
19
+ import type { LocalOwnerAct } from './local-owner.js';
19
20
  /** A guide row and its flags are derived from their schemas in src/contracts/cli.ts. */
20
21
  export type { CommandFlag, CommandRow, RemotePolicy };
21
22
  /** The guide's limits, stated machine-readably in every dump. */
@@ -30,6 +31,15 @@ export declare const SURFACE_SCHEMA_VERSION = 1;
30
31
  export declare const REMOTE_POLICY: ReadonlyMap<string, RemotePolicy>;
31
32
  /** How many times each invocation was classified — the contract test refuses a row stated twice. */
32
33
  export declare const REMOTE_POLICY_COUNT: number;
34
+ /** Every guide row's local-owner mark, by invocation: `allow` with its act, else `deny`. Exported for the inventory test. */
35
+ export declare const LOCAL_POLICY: ReadonlyMap<string, {
36
+ local: "allow";
37
+ act: LocalOwnerAct;
38
+ } | {
39
+ local: "deny";
40
+ }>;
41
+ /** The invocations LOCAL_ALLOW names, for the inventory test (one naming no row is stale). */
42
+ export declare const LOCAL_ALLOWED_INVOCATIONS: readonly string[];
33
43
  /** Every declared command, in guide order, paired with the schema of its `--json` answer (src/contracts/cli.ts).
34
44
  * An unclassified row is "no": nothing runs remotely by omission. */
35
45
  export declare const COMMAND_ENTRIES: readonly import("./contracts/cli.js").CommandEntry[];