@intentic/sandbox-contract 1.301.0 → 1.303.0

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 (143) hide show
  1. package/dist/contracts/agent.contract.d.ts +6 -9
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/agents.contract.d.ts +193 -433
  4. package/dist/contracts/agents.contract.d.ts.map +1 -1
  5. package/dist/contracts/agents.contract.js +6 -24
  6. package/dist/contracts/agents.contract.js.map +1 -1
  7. package/dist/contracts/areas.contract.d.ts +24 -0
  8. package/dist/contracts/areas.contract.d.ts.map +1 -0
  9. package/dist/contracts/areas.contract.js +32 -0
  10. package/dist/contracts/areas.contract.js.map +1 -0
  11. package/dist/contracts/automations.contract.d.ts +3 -0
  12. package/dist/contracts/automations.contract.d.ts.map +1 -1
  13. package/dist/contracts/capabilities.contract.d.ts +0 -46
  14. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  15. package/dist/contracts/endpoints.contract.d.ts +132 -0
  16. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  17. package/dist/contracts/endpoints.contract.js +50 -0
  18. package/dist/contracts/endpoints.contract.js.map +1 -1
  19. package/dist/contracts/providers.contract.d.ts +21 -0
  20. package/dist/contracts/providers.contract.d.ts.map +1 -1
  21. package/dist/contracts/providers.contract.js +1 -0
  22. package/dist/contracts/providers.contract.js.map +1 -1
  23. package/dist/contracts/runner.contract.d.ts +98 -95
  24. package/dist/contracts/runner.contract.d.ts.map +1 -1
  25. package/dist/contracts/sessions.contract.d.ts +0 -1
  26. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  27. package/dist/contracts/settings.contract.d.ts +110 -6
  28. package/dist/contracts/settings.contract.d.ts.map +1 -1
  29. package/dist/contracts/settings.contract.js +19 -1
  30. package/dist/contracts/settings.contract.js.map +1 -1
  31. package/dist/contracts/system.contract.d.ts +23 -12
  32. package/dist/contracts/system.contract.d.ts.map +1 -1
  33. package/dist/events/agent-events.d.ts +1 -3
  34. package/dist/events/agent-events.d.ts.map +1 -1
  35. package/dist/events/agent-events.js +1 -0
  36. package/dist/events/agent-events.js.map +1 -1
  37. package/dist/events/system-events.d.ts +32 -6
  38. package/dist/events/system-events.d.ts.map +1 -1
  39. package/dist/events/transcript.d.ts +2 -6
  40. package/dist/events/transcript.d.ts.map +1 -1
  41. package/dist/events/transcript.js +5 -1
  42. package/dist/events/transcript.js.map +1 -1
  43. package/dist/index.d.ts +481 -549
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +8 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/models/agent-catalog.d.ts.map +1 -1
  48. package/dist/models/agent-catalog.js +1 -1
  49. package/dist/models/agent-catalog.js.map +1 -1
  50. package/dist/policy/fence-paths.d.ts +10 -0
  51. package/dist/policy/fence-paths.d.ts.map +1 -0
  52. package/dist/policy/fence-paths.js +58 -0
  53. package/dist/policy/fence-paths.js.map +1 -0
  54. package/dist/protocol/routes.d.ts.map +1 -1
  55. package/dist/protocol/routes.js +4 -1
  56. package/dist/protocol/routes.js.map +1 -1
  57. package/dist/schemas/agents.d.ts +51 -19
  58. package/dist/schemas/agents.d.ts.map +1 -1
  59. package/dist/schemas/agents.js +7 -22
  60. package/dist/schemas/agents.js.map +1 -1
  61. package/dist/schemas/areas.d.ts +21 -0
  62. package/dist/schemas/areas.d.ts.map +1 -0
  63. package/dist/schemas/areas.js +32 -0
  64. package/dist/schemas/areas.js.map +1 -0
  65. package/dist/schemas/automations.d.ts +19 -3
  66. package/dist/schemas/automations.d.ts.map +1 -1
  67. package/dist/schemas/automations.js +2 -0
  68. package/dist/schemas/automations.js.map +1 -1
  69. package/dist/schemas/capabilities.d.ts +11 -46
  70. package/dist/schemas/capabilities.d.ts.map +1 -1
  71. package/dist/schemas/capabilities.js +15 -14
  72. package/dist/schemas/capabilities.js.map +1 -1
  73. package/dist/schemas/personas.d.ts.map +1 -1
  74. package/dist/schemas/personas.js +5 -1
  75. package/dist/schemas/personas.js.map +1 -1
  76. package/dist/schemas/providers/provider-oauth.d.ts +2 -0
  77. package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
  78. package/dist/schemas/providers/provider-oauth.js +4 -0
  79. package/dist/schemas/providers/provider-oauth.js.map +1 -1
  80. package/dist/schemas/providers/usage.d.ts +4 -0
  81. package/dist/schemas/providers/usage.d.ts.map +1 -1
  82. package/dist/schemas/providers/usage.js +4 -0
  83. package/dist/schemas/providers/usage.js.map +1 -1
  84. package/dist/schemas/settings.d.ts +99 -3
  85. package/dist/schemas/settings.d.ts.map +1 -1
  86. package/dist/schemas/settings.js +51 -14
  87. package/dist/schemas/settings.js.map +1 -1
  88. package/dist/schemas/shared.d.ts +4 -0
  89. package/dist/schemas/shared.d.ts.map +1 -1
  90. package/dist/schemas/shared.js +3 -3
  91. package/dist/schemas/shared.js.map +1 -1
  92. package/dist/schemas/turn-break.d.ts +32 -0
  93. package/dist/schemas/turn-break.d.ts.map +1 -0
  94. package/dist/schemas/turn-break.js +16 -0
  95. package/dist/schemas/turn-break.js.map +1 -0
  96. package/dist/state/definition.d.ts +40 -56
  97. package/dist/state/definition.d.ts.map +1 -1
  98. package/dist/state/history-state.d.ts.map +1 -1
  99. package/dist/state/history-state.js +2 -0
  100. package/dist/state/history-state.js.map +1 -1
  101. package/dist/state/workspace-state.d.ts +5 -0
  102. package/dist/state/workspace-state.d.ts.map +1 -1
  103. package/dist/state/workspace-state.js +1 -0
  104. package/dist/state/workspace-state.js.map +1 -1
  105. package/dist/text/transcript-fold.d.ts.map +1 -1
  106. package/dist/text/transcript-fold.js +3 -7
  107. package/dist/text/transcript-fold.js.map +1 -1
  108. package/dist/time/zone.d.ts +27 -0
  109. package/dist/time/zone.d.ts.map +1 -0
  110. package/dist/time/zone.js +35 -0
  111. package/dist/time/zone.js.map +1 -0
  112. package/package.json +16 -5
  113. package/src/contracts/agents.contract.ts +7 -30
  114. package/src/contracts/areas.contract.ts +42 -0
  115. package/src/contracts/endpoints.contract.ts +82 -0
  116. package/src/contracts/providers.contract.ts +5 -0
  117. package/src/contracts/settings.contract.ts +28 -0
  118. package/src/events/agent-events.ts +4 -1
  119. package/src/events/transcript.ts +9 -1
  120. package/src/index.ts +19 -1
  121. package/src/models/agent-catalog.ts +1 -1
  122. package/src/policy/fence-paths.test.ts +114 -0
  123. package/src/policy/fence-paths.ts +99 -0
  124. package/src/protocol/routes.test.ts +12 -0
  125. package/src/protocol/routes.ts +8 -1
  126. package/src/schemas/agents.ts +14 -35
  127. package/src/schemas/areas.test.ts +40 -0
  128. package/src/schemas/areas.ts +49 -0
  129. package/src/schemas/automations.ts +8 -0
  130. package/src/schemas/capabilities.ts +37 -19
  131. package/src/schemas/personas.ts +15 -4
  132. package/src/schemas/providers/provider-oauth.ts +9 -0
  133. package/src/schemas/providers/usage.ts +9 -0
  134. package/src/schemas/settings.ts +107 -26
  135. package/src/schemas/shared.ts +9 -6
  136. package/src/schemas/turn-break.ts +49 -0
  137. package/src/state/history-state.ts +7 -0
  138. package/src/state/workspace-state.test.ts +7 -3
  139. package/src/state/workspace-state.ts +9 -2
  140. package/src/text/transcript-fold.test.ts +3 -5
  141. package/src/text/transcript-fold.ts +3 -7
  142. package/src/time/zone.test.ts +90 -0
  143. package/src/time/zone.ts +118 -0
@@ -5,6 +5,8 @@ import { z } from "zod";
5
5
  import { CommandJudgeModeSchema } from "../policy/safety-policy.js";
6
6
  import { ModelRoleSchema } from "../models/model-roles.js";
7
7
  import { AdmissionPolicySchema, AdmissionRuleSchema, ModelPinSchema } from "./agent.js";
8
+ import { LimitPolicySchema, RetryPolicySchema } from "./turn-break.js";
9
+ import { ZoneSchema } from "../time/zone.js";
8
10
  // Which prompt base the agent runs before this turn composes anything on top: Intentic's own (default), Claude Code's
9
11
  // preset, or the owner's text. Declared out here since both the daemon and the browser branch on it.
10
12
  export const SystemPromptModeSchema = z.enum(["intentic", "claude", "custom"]);
@@ -202,6 +204,20 @@ export type SkillSwitch = z.infer<typeof SkillSwitchSchema>;
202
204
  // picks rather than failing whole.
203
205
 
204
206
  export const SandboxSettingsSchema = z.object({
207
+ // The zone every WALL-CLOCK RULE in this sandbox is meant in — an automation's cron, and anything else that says
208
+ // "at 09:00" instead of naming an instant. It is not a display preference: nothing formats through it, and an
209
+ // instant on screen is still drawn in the reader's own clock.
210
+ // Empty means nobody has said, and the container's clock (UTC) answers. That is harmless for a sandbox where
211
+ // nothing is scheduled and wrong the moment something is, which is why the editor offers its own zone on first
212
+ // load and the automations screen names this one beside every schedule.
213
+ // An IANA id rather than an offset, because an offset cannot express the summer-time rule that moves "09:00" twice
214
+ // a year, and a chore set in March should still be right in November.
215
+ timezone: z
216
+ .union([z.literal(""), ZoneSchema])
217
+ .default("")
218
+ .describe(
219
+ "Which clock this sandbox's schedules are set by, as a zone name like Europe/Warsaw. Automations that repeat on a clock fire by this, not by the machine's own time. Leave it empty and they fire by UTC, which is almost certainly not what you meant when you typed a time.",
220
+ ),
205
221
  stableSystemPrompt: z
206
222
  .boolean()
207
223
  .default(false)
@@ -270,6 +286,34 @@ export const SandboxSettingsSchema = z.object({
270
286
  .describe(
271
287
  "What share of conversations to open without the map, so the two can be compared. Whole conversations rather than individual turns, because the map is sent once and stays in the conversation's history afterwards.",
272
288
  ),
289
+ // The one composed piece that is WRITTEN rather than derived: a monthly automation rewrites it off the session
290
+ // corpus, so it carries what no scan of the tree can (which commands really work here, what the box can take, how
291
+ // the owner asks for things) and the map keeps carrying what a scan can.
292
+ fieldNotes: z
293
+ .boolean()
294
+ .default(false)
295
+ .describe(
296
+ "Open every turn with a brief on how work actually goes in this sandbox: the traps that cost past sessions calls, the commands that really verify, what the machine can take. Written once a month by an automation that reads back the sessions run here, rather than worked out per turn, because it is drawn from history rather than from the tree. Off by default, since it rides every turn of every conversation.",
297
+ ),
298
+ // Characters, not sections: the file's own ranking decides WHICH sections, this decides HOW MANY fit. Same unit as
299
+ // the project map's ceiling so the two costs read on one scale.
300
+ fieldNotesBudget: z
301
+ .number()
302
+ .int()
303
+ .min(500)
304
+ .max(20000)
305
+ .default(4000)
306
+ .describe(
307
+ "How much of that brief to send. Its sections are ranked, most costly-to-not-know first, and they are taken whole in that order until this runs out — so raising it buys more of the tail, never a fuller version of the same thing.",
308
+ ),
309
+ fieldNotesHoldout: z
310
+ .number()
311
+ .min(0)
312
+ .max(1)
313
+ .default(0)
314
+ .describe(
315
+ "What share of conversations to run without the brief, so the two can be compared. Whole conversations rather than individual turns, because the brief sits in the prompt for the whole session and withholding it from one turn would not take it back.",
316
+ ),
273
317
  // Only the eager background pass; the `fileq` CLI itself is always on PATH regardless, gated only by its own skill.
274
318
  sidecars: z
275
319
  .boolean()
@@ -305,6 +349,15 @@ export const SandboxSettingsSchema = z.object({
305
349
  .describe(
306
350
  "Which models do which job, one ordered list per job: commit messages, session titles, the safety judge, pipeline fixes, and every other place this sandbox picks a model for you. Tried in order, so one spent account does not take a job down. Nothing is chosen for you: a one-shot job with no list does not run, and a whole session with no list opens on whatever your own chat is set to.",
307
351
  ),
352
+ // Folded into the Auto router's prompt (agent/prompt/model-router.ts), never into a turn's own. Capped small on
353
+ // purpose: the reading runs under a 5s deadline, and this text is paid for on every chat that opens on Auto.
354
+ autoModelGuidance: z
355
+ .string()
356
+ .max(2000)
357
+ .default("")
358
+ .describe(
359
+ "What you would tell somebody choosing the model for a new chat on your behalf: which model you want the cheap work on, which account to leave alone, when to reach for the strongest one. Read once per chat, alongside the models and allowances this sandbox can actually run, and it overrides the product's own advice where the two disagree. It cannot invent a model: the answer is still a choice from that list.",
360
+ ),
308
361
  // Named by repo id ("root" or discoverRepos's dir); a commit spanning repos gets the trailer only where one was
309
362
  // asked for.
310
363
  changelogRepos: z
@@ -322,30 +375,20 @@ export const SandboxSettingsSchema = z.object({
322
375
  .describe(
323
376
  "How many days a finished conversation stays on the board before being put away. Zero means never. The one setting here that defaults on, because each card left behind is a real working copy on disk, not just a row.",
324
377
  ),
325
- // Off still records the failure, so the per-conversation resume offer arms normally; nothing is lost, just not
326
- // automatic.
327
- resumeAfterOutage: z
328
- .boolean()
329
- .default(false)
330
- .describe(
331
- "Whether a turn killed by the model provider failing is re-run automatically, backing off between attempts. The sandbox-wide default; any one conversation can say otherwise. Off to begin with, because a retry spends your allowance on a turn you sent once and only you can say whether it was worth paying for twice. Worth turning on for a sandbox whose work mostly happens with nobody in the room.",
332
- ),
333
- // The one resume that waits for a published instant rather than guessing; a limit with no published reset (Grok,
334
- // Cursor) never fires this way at all.
335
- resumeAfterLimit: z
336
- .boolean()
337
- .default(false)
338
- .describe(
339
- "Whether a turn a spent usage limit refused is sent again by itself once the allowance reopens. The sandbox-wide default; any one conversation can say otherwise. Off to begin with, because the allowance is your budget and a turn that spends it the second it comes back is not a decision to make for you. Worth turning on for a sandbox whose work mostly happens with nobody in the room.",
340
- ),
341
- // Same provider only; a different provider would retire the session for a saving that isn't one. Composes with
342
- // `resumeAfterLimit` into four postures: hold, wait for reset, move-or-hold, move-or-wait.
343
- moveAfterLimit: z
344
- .boolean()
345
- .default(false)
346
- .describe(
347
- "Whether a turn a spent usage limit refused is moved to another connected account of the same provider that still has room, as soon as the refusal lands. The sandbox-wide default; any one conversation can say otherwise. Off to begin with, because it spends a second account on your behalf. With no account that has room the turn waits as the setting above says.",
348
- ),
378
+ // One answer per ending, never a set of switches over the same event: the chat's own question and these rows are
379
+ // the same question at two scopes. Every ending defaults to `wait`, because a re-run spends the reader's allowance
380
+ // on a turn they sent once.
381
+ limitPolicy: LimitPolicySchema.default("wait").describe(
382
+ "What happens to a turn a spent usage limit refused. `wait` holds it for a press. `resend` sends it again by itself once the allowance reopens, which needs a provider that publishes a reset (Grok and Cursor publish none). `move` also tries another connected account of the same provider that still has room, as soon as the refusal lands, and keeps the reset as its fallback. The sandbox-wide default; any one conversation can say otherwise.",
383
+ ),
384
+ // Off still records the failure, so the per-conversation offer arms normally; nothing is lost, just not automatic.
385
+ outagePolicy: RetryPolicySchema.default("wait").describe(
386
+ "What happens to a turn the model provider's own failure killed. `wait` holds it for a press. `retry` re-runs it on the shared per-provider breaker, backing off between attempts. The sandbox-wide default; any one conversation can say otherwise. Worth `retry` for a sandbox whose work mostly happens with nobody in the room.",
387
+ ),
388
+ // The posture that used to live only in a browser tab; moving it here is what lets it fire with nothing open.
389
+ stopPolicy: RetryPolicySchema.default("wait").describe(
390
+ "What happens to a turn that stopped short with nothing to repair — a hung runtime, a crashed harness. `wait` holds it for a press. `retry` re-runs the held turn on a short ladder, standing down after three tries that got nowhere rather than looping forever.",
391
+ ),
349
392
  limitMoveCarryUnder: z
350
393
  .number()
351
394
  .int()
@@ -360,7 +403,7 @@ export const SandboxSettingsSchema = z.object({
360
403
  .boolean()
361
404
  .default(false)
362
405
  .describe(
363
- "Whether a turn killed by the sandbox restarting is re-run once it comes back. Off to begin with, for the same reason: it would spend your allowance on work you are not watching and edit files while you are still waiting for the sandbox to return. Either way the interruption is recorded rather than silently lost.",
406
+ "Whether a turn killed by the sandbox restarting is re-run once it comes back. A switch rather than one of the policies above, because a restart is the one ending with nobody watching it, so there is no in-chat question to answer. Off to begin with: it would spend your allowance on work you are not watching and edit files while you are still waiting for the sandbox to return. Either way the interruption is recorded rather than silently lost.",
364
407
  ),
365
408
  // Which repositories' own declarations (`<repo>/.intentic/checks.json`) the owner has switched on, each against the
366
409
  // fingerprint of what was declared when they did. A declaration that has since changed no longer matches its
@@ -421,6 +464,20 @@ export const SandboxSettingsSchema = z.object({
421
464
  .describe("How many levels deep the delegation may go, since a subagent can start subagents of its own."),
422
465
  });
423
466
  export type SandboxSettings = z.infer<typeof SandboxSettingsSchema>;
467
+
468
+ // A browser telling the sandbox which clock IT is on. An offer, not an instruction: see `adoptTimezone`.
469
+ export const TimezoneOfferSchema = z.object({
470
+ timezone: ZoneSchema.describe("The zone the offering machine is in, as an IANA name like Europe/Warsaw."),
471
+ });
472
+ export type TimezoneOffer = z.infer<typeof TimezoneOfferSchema>;
473
+
474
+ // What the sandbox's clock is after the offer, and whether the offer is what set it. `adopted: false` with a zone
475
+ // back means somebody had already chosen, which is the normal answer for every browser after the first.
476
+ export const TimezoneStateSchema = z.object({
477
+ timezone: z.string().describe("The zone this sandbox's schedules are now read in. Empty only if none could be resolved."),
478
+ adopted: z.boolean().describe("Whether this call is what set it. False means it was already answered and the stored zone stands."),
479
+ });
480
+ export type TimezoneState = z.infer<typeof TimezoneStateSchema>;
424
481
  // Read live from the installed CLI (preset-prompt.ts), not a stored transcription. `version` is the CLI build it came
425
482
  // from, so a fork from an older build reads as a snapshot; empty for Intentic's own prompt.
426
483
  export const BuiltinPromptTextSchema = z.object({ text: z.string(), version: z.string() });
@@ -456,9 +513,10 @@ export const SavingsArmSchema = z.object({ turns: z.number(), mean: z.number() }
456
513
  // openingSearches: same, narrowed to before the turn first touched a file.
457
514
  // openingListings: directory listings a turn ran to orient itself (the project map).
458
515
  // callsBeforeTarget: how far a turn walked before touching a file it went on to edit.
516
+ // failedCalls: tool calls that ended in error (the field notes, whose largest section is a failure taxonomy).
459
517
  // Never cost: each mechanism moves one small part of a turn's work, inside the noise of the rest.
460
518
  export const TurnMetricReadingSchema = z.object({
461
- metric: z.enum(["searchCalls", "openingSearches", "openingListings", "callsBeforeTarget"]),
519
+ metric: z.enum(["searchCalls", "openingSearches", "openingListings", "callsBeforeTarget", "failedCalls"]),
462
520
  on: SavingsArmSchema,
463
521
  off: SavingsArmSchema,
464
522
  // Additional control turns to reach a fixed target resolution, not today's delta (which inherits noise and always
@@ -509,11 +567,34 @@ export const DependencySavingsSchema = z.object({
509
567
  updatedAt: z.number().optional(),
510
568
  });
511
569
  export type DependencySavings = z.infer<typeof DependencySavingsSchema>;
570
+ // What the settings row can say about the brief without opening it: whether there is one, how much of it this budget
571
+ // reaches, and whether anything is scheduled to rewrite it. Read off the file and the automation store, never stored.
572
+ export const FieldNotesStatusSchema = z.object({
573
+ // False means the automation has never run (or was never set up) — the ordinary state before the first month.
574
+ present: z.boolean(),
575
+ // Epoch ms the file was last written. Absent when there is no file.
576
+ writtenAt: z.number().optional(),
577
+ // How many ranked sections the current budget reaches, out of how many the file holds. The pair is the point: "5"
578
+ // alone cannot tell a generous budget from a short file.
579
+ ranksSent: z.number().optional(),
580
+ ranksTotal: z.number().optional(),
581
+ // What the brief costs the prompt, in characters, at the current budget.
582
+ chars: z.number().optional(),
583
+ // The monthly rewrite: absent until the owner creates it from the offered template, since an automation names the
584
+ // models it spends and nothing chooses those for them.
585
+ automation: z.enum(["missing", "enabled", "disabled"]),
586
+ // When it next runs, epoch ms; absent when there is nothing scheduled.
587
+ nextRunAt: z.number().optional(),
588
+ // Said out loud rather than shown as "no file": a brief that exists and cannot be read is a broken automation.
589
+ unreadable: z.string().optional(),
590
+ });
591
+ export type FieldNotesStatus = z.infer<typeof FieldNotesStatusSchema>;
512
592
  export const SavingsReportSchema = z.object({
513
593
  input: InputSavingsSchema,
514
594
  search: TurnExperimentSchema.optional(),
515
595
  // Same absence rule as `search`: not measured, never zero.
516
596
  map: TurnExperimentSchema.optional(),
597
+ notes: TurnExperimentSchema.optional(),
517
598
  dependencies: DependencySavingsSchema.optional(),
518
599
  });
519
600
  export type SavingsReport = z.infer<typeof SavingsReportSchema>;
@@ -8,15 +8,18 @@ export const OkSchema = object({
8
8
  ok: literal(true)
9
9
  .describe("Always true. A route that answers this either did the thing or refused with a status; there is no third outcome to report."),
10
10
  });
11
- // Trust tiers, ordered low to high: viewer watches, collaborator's outward actions become requests, maintainer holds
12
- // the owner's authority but is revocable, owner is the one bound identity and not a grant.
13
- export const MemberRoleSchema = zEnum(["viewer", "collaborator", "maintainer", "owner"]);
11
+ // Trust tiers, ordered low to high: desk talks to the persona cards it was handed and sees nothing else, viewer
12
+ // watches, collaborator's outward actions become requests, writer changes files inside the areas it holds and ships
13
+ // none of them, maintainer holds the owner's authority but is revocable, owner is the one bound identity and not a
14
+ // grant.
15
+ export const MemberRoleSchema = zEnum(["desk", "viewer", "collaborator", "writer", "maintainer", "owner"]);
14
16
  export type MemberRole = z.infer<typeof MemberRoleSchema>;
15
17
  // Roles an invite can grant: everything but owner, which binds at first sign-in and is never granted.
16
- export const GrantedRoleSchema = zEnum(["viewer", "collaborator", "maintainer"]);
18
+ export const GrantedRoleSchema = zEnum(["desk", "viewer", "collaborator", "writer", "maintainer"]);
17
19
  export type GrantedRole = z.infer<typeof GrantedRoleSchema>;
18
- // Single source for role order; every surface that gates on a role reads this ranking.
19
- const MEMBER_ROLE_RANK: Record<MemberRole, number> = { viewer: 0, collaborator: 1, maintainer: 2, owner: 3 };
20
+ // Single source for role order; every surface that gates on a role reads this ranking. A desk is below every
21
+ // floor: its routes are an allowlist of its own (auth/role-floor.ts deskReach), never a floor it can clear.
22
+ const MEMBER_ROLE_RANK: Record<MemberRole, number> = { desk: 0, viewer: 1, collaborator: 2, writer: 3, maintainer: 4, owner: 5 };
20
23
  export const roleAtLeast = (role: MemberRole, floor: MemberRole): boolean => MEMBER_ROLE_RANK[role] >= MEMBER_ROLE_RANK[floor];
21
24
  // Rotating a door credential (event webhook, release gate, bug intake): the old value stops working immediately; the
22
25
  // new one is shown once to be saved.
@@ -0,0 +1,49 @@
1
+ // turn-break: what happens next when a turn stops before it finished
2
+ //
3
+ // One ending, one question, one answer. A turn stops for exactly one reason, and that reason gets a single
4
+ // mutually-exclusive answer — never a set of switches that can all be armed at once over the same event. The chat
5
+ // asks it about the ending in front of the reader; Settings holds the standing answer per ending; a conversation may
6
+ // override either.
7
+
8
+ import { z } from "zod";
9
+
10
+ // The three endings that leave finished work behind a live session and can be picked back up. Spelled exactly as
11
+ // `TurnEndingSchema.reason` spells them, so the ending a surface reads and the question it asks are one word. A restart
12
+ // is handled apart (SandboxSettings.autoResumeOnRestart): nobody is watching a restart, so it has no in-chat question.
13
+ export const TurnBreakSchema = z.enum(["limit", "outage", "stopped"]);
14
+ export type TurnBreak = z.infer<typeof TurnBreakSchema>;
15
+
16
+ // `move` implies `resend`: an account with room is tried at once, and the reset appointment stands as its fallback.
17
+ // There is deliberately no "move, else hold" — a reader willing to spend a second account is willing to wait.
18
+ export const LimitPolicySchema = z.enum(["wait", "resend", "move"]);
19
+ export type LimitPolicy = z.infer<typeof LimitPolicySchema>;
20
+
21
+ // Both ladders answer the same way, so they share a vocabulary: nothing, or keep trying on a bounded ladder.
22
+ export const RetryPolicySchema = z.enum(["wait", "retry"]);
23
+ export type RetryPolicy = z.infer<typeof RetryPolicySchema>;
24
+
25
+ // One answer, whichever ending asked. Read against the ending: only `limit` can be `move`.
26
+ export const TurnBreakPolicySchema = z.enum(["wait", "resend", "move", "retry"]);
27
+ export type TurnBreakPolicy = z.infer<typeof TurnBreakPolicySchema>;
28
+
29
+ // Which answers an ending may take, so a surface offers exactly these and a writer refuses anything else.
30
+ export const TURN_BREAK_POLICIES: Readonly<Record<TurnBreak, readonly TurnBreakPolicy[]>> = {
31
+ limit: ["wait", "resend", "move"],
32
+ outage: ["wait", "retry"],
33
+ stopped: ["wait", "retry"],
34
+ };
35
+
36
+ /** Whether this ending may be answered this way; the one gate every writer goes through. */
37
+ export const isTurnBreakPolicy = (ending: TurnBreak, policy: string): policy is TurnBreakPolicy =>
38
+ (TURN_BREAK_POLICIES[ending] as readonly string[]).includes(policy);
39
+
40
+ /** Whether an answer means something happens without the reader; the one read every "is it armed" question makes. */
41
+ export const breakArmed = (policy: TurnBreakPolicy): boolean => policy !== "wait";
42
+
43
+ // How many times a bounded ladder re-runs a turn that gets nowhere before standing down. Quoted in the notice it
44
+ // leaves, so the number and the sentence cannot drift.
45
+ export const RETRY_LADDER_MS = [5_000, 15_000, 45_000] as const;
46
+ export const RETRY_LADDER_TRIES = RETRY_LADDER_MS.length;
47
+
48
+ /** The wait before the next rung, or undefined once the ladder is spent. */
49
+ export const retryLadderDelay = (triesWithoutProgress: number): number | undefined => RETRY_LADDER_MS[triesWithoutProgress];
@@ -19,6 +19,10 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
19
19
  // Armed condition watches; carries safely: the journal holds no credential, only names re-derived on target.
20
20
  { path: "watches/", portability: "carry" },
21
21
  { path: "transcripts/", portability: "carry" },
22
+ // One runtime session store per fenced conversation, holding for it what `.intentic/records/sessions/claude/` holds
23
+ // for every other: its own transcripts, plans, backups and checklists, kept off /work so no other conversation's
24
+ // namespace can reach them. Carried for the same reason that one is.
25
+ { path: "sessions/", portability: "carry" },
22
26
  // What each message can restore to (a checkpoint or isolated commit); the join between transcripts and scopes.
23
27
  { path: "turn-anchors.json", portability: "carry" },
24
28
  // Index of published-page conversations; the pages travel with /work anyway, this makes one withdrawable.
@@ -32,6 +36,9 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
32
36
  { path: "provider-refusals.json", portability: "carry" },
33
37
  // Models refused to this sandbox's credentials; expires daily, same subscriptions apply elsewhere.
34
38
  { path: "model-refusals.json", portability: "carry" },
39
+ // When a model every credential is benched on reopens. Carried for the same reason as the refusals around it: the
40
+ // wait belongs to the subscription, not to the machine that hit it, and entries past their instant are inert.
41
+ { path: "model-cooldowns.json", portability: "carry" },
35
42
  // What each account has run out of, for a plan publishing no allowance to poll — the refusal is the reading.
36
43
  // Carried like the refusals above: an allowance belongs to the account, not to the machine that spent it.
37
44
  { path: "observed-limits.json", portability: "carry" },
@@ -33,9 +33,10 @@ describe(`staleQueryKeys`, () => {
33
33
  expect(staleQueryKeys([`.intentic/config/capabilities.json`], [])).toEqual([`capabilities`, `environment`, `panels`, `manifests`]);
34
34
  });
35
35
 
36
- it(`refreshes the unreadable-manifest notice for the three files a person hand-edits`, () => {
36
+ it(`refreshes the unreadable-manifest notice for the four files a person hand-edits`, () => {
37
37
  const carries = WORKSPACE_STATE_FILES.filter((file) => file.invalidates.includes(`manifests`)).map((file) => file.path);
38
38
  expect(carries.toSorted()).toEqual([
39
+ `.intentic/config/areas.json`,
39
40
  `.intentic/config/capabilities.json`,
40
41
  `.intentic/config/personas.json`,
41
42
  `.intentic/config/settings.json`,
@@ -316,9 +317,12 @@ describe(`VERSIONED_STATE_PATHS`, () => {
316
317
  });
317
318
 
318
319
  // Spelled out rather than derived, so adding a tracked entry is a visible edit here, not a silent side effect.
319
- it(`tracks exactly the configuration slice plus the agent's own authored output`, () => {
320
+ it(`tracks exactly the configuration files plus the agent's own authored output`, () => {
320
321
  expect(VERSIONED_STATE_PATHS.toSorted()).toEqual([
321
322
  `${STATE_DIR}/config/approvals/`,
323
+ // Which folders each named area holds: editing one moves what everyone granted it can see, so the
324
+ // change belongs in a diff rather than only in a screen.
325
+ `${STATE_DIR}/config/areas.json`,
322
326
  `${STATE_DIR}/config/automations.json`,
323
327
  // Which apps the daemon starts at boot: the starter site, plus whatever the owner adds.
324
328
  `${STATE_DIR}/config/autostart.json`,
@@ -493,7 +497,7 @@ describe(`SHARED_STATE_PATHS`, () => {
493
497
  });
494
498
  });
495
499
 
496
- // Pins the split: the authored slice that now backs up, and the credentials that still must not.
500
+ // Pins the split: the authored area that now backs up, and the credentials that still must not.
497
501
  describe(`BACKED_UP_STATE_PATHS`, () => {
498
502
  it(`splits the table in two with nothing falling between`, () => {
499
503
  expect([...BACKED_UP_STATE_PATHS, ...UNBACKED_STATE_PATHS].toSorted()).toEqual(WORKSPACE_STATE_FILES.map((file) => file.path).toSorted());
@@ -72,6 +72,13 @@ const STATE_FILES = [
72
72
  // capability catalog. `carry`: a card is a name and ids, never a credential.
73
73
  { path: ".intentic/config/personas.json", invalidates: ["personas", "capabilities", "manifests"], portability: "carry", versioned: true },
74
74
 
75
+ // Named parts of the workspace (AreaSchema), the unit a person's reach is granted in. `carry`: folder names, no
76
+ // credential, and a workspace that moves keeps the shape of its own fences. `versioned` because editing an area
77
+ // changes what people already granted it can see, which is precisely a change a human should review. It stays
78
+ // agent-writable like personas.json, and is safe there for a reason worth stating: a fenced conversation's own
79
+ // fence has to admit `.intentic` before its agent can reach this file, so no fence can widen itself.
80
+ { path: ".intentic/config/areas.json", invalidates: ["areas", "manifests"], portability: "carry", versioned: true },
81
+
75
82
  // Four files split by portability, not just prefix: `custom` is the owner-approved source and the only one that
76
83
  // must travel; `approved` is composed from custom + capability fragments + the base image and is rebuilt on the
77
84
  // target's first boot (carrying it would ship a `FROM` naming an image the target may not have); the proposal and
@@ -475,7 +482,7 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = STATE_FILES;
475
482
  // entry `versioned` is the only change needed.
476
483
  export const VERSIONED_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter((file) => file.versioned).map((file) => file.path);
477
484
 
478
- // Slice a workspace search may surface: `versioned` config plus `authored` content (approvals, staged docs,
485
+ // What a workspace search may surface: `versioned` config plus `authored` content (approvals, staged docs,
479
486
  // workspace extensions). Everything else under `.intentic` is machine state, denied by default. `auth/`, where
480
487
  // vaulted credentials live, is always denied regardless.
481
488
  export const SEARCHABLE_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter((file) => file.versioned || file.authored).map(
@@ -552,7 +559,7 @@ export const SHARED_STATE_PATHS: readonly string[] = STATE_GROUPS.flatMap((group
552
559
  : [`${STATE_GROUP_DIR[group]}/`];
553
560
  });
554
561
 
555
- // Slice desktop-sync copies down: ordinary state and the records binding this sandbox to its owner, minus anything
562
+ // What desktop-sync copies down: ordinary state and the records binding this sandbox to its owner, minus anything
556
563
  // that opts out via `backup: false`. Deliberately not the same question as the export bundle: a bundle asks what
557
564
  // may be reconstituted elsewhere, this asks what the owner may keep a copy of.
558
565
  export const BACKED_UP_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter(
@@ -188,11 +188,9 @@ describe("foldTurn", () => {
188
188
  outage: { retryAt: 1, attempt: 2, maxAttempts: 6 },
189
189
  },
190
190
  ];
191
- expect(foldOf("hi", outage).at(-1)).toEqual({
192
- role: "notice",
193
- text: "Anthropic is down. Retrying by itself: attempt 2 of 6.",
194
- noticeAction: "outageOptOut",
195
- });
191
+ // The row states what happened and nothing more: what happens next, and the one control that changes it, are
192
+ // the chat card's, asked once (ChatContinueStrip), not a second switch on a transcript line.
193
+ expect(foldOf("hi", outage).at(-1)).toEqual({ role: "notice", text: "Anthropic is down. Retrying by itself: attempt 2 of 6." });
196
194
  const renewal: AgentEvent[] = [{ kind: "error", code: "claude-token-refused", message: "Token refused.", autoResume: "scheduled" }];
197
195
  expect(foldOf("hi", renewal).at(-1)).toEqual({
198
196
  role: "notice",
@@ -96,18 +96,14 @@ const errorRow = (event: Extract<AgentEvent, { kind: "error" }>): TranscriptRow
96
96
  switch (code) {
97
97
  case "provider-outage":
98
98
  return event.outage === undefined
99
- ? { role: "notice", text: `${message} Nothing is retrying it, so the turn is waiting: keep this chat going and it continues from here.` }
100
- : {
101
- role: "notice",
102
- text: `${message} Retrying by itself: attempt ${event.outage.attempt} of ${event.outage.maxAttempts}.`,
103
- ...(event.autoResume === "scheduled" ? { noticeAction: "outageOptOut" } : {}),
104
- };
99
+ ? { role: "notice", text: `${message} Nothing is retrying it, so the turn is waiting here.` }
100
+ : { role: "notice", text: `${message} Retrying by itself: attempt ${event.outage.attempt} of ${event.outage.maxAttempts}.` };
105
101
  case "claude-token-refused":
106
102
  return event.autoResume === "scheduled"
107
103
  ? { role: "notice", text: `${message} The credential is being renewed and this turn continues automatically.`, noticeWait: "credentialRenewal" }
108
104
  : { role: "notice", text: `${message} Reconnect the account to pick this conversation back up.` };
109
105
  case "rate_limit":
110
- return { role: "notice", text: event.autoResume === "scheduled" ? `${message} This chat sends it again once the allowance comes back.` : message };
106
+ return { role: "notice", text: message };
111
107
  // Refused before the model saw it; the composer holds the message so the user can resend it.
112
108
  case "claude-reauth":
113
109
  case "unknown-command":
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { asCivilDay, asZone, civilDayIn, cronOptions, isZone, sameClock, utcDayOf, UTC, zoneLabel } from "./zone.js";
3
+ import type { Zone } from "./zone.js";
4
+
5
+ // The distinctions this module exists to keep, each of which was a live bug before it: a cron evaluated in the
6
+ // process's zone instead of the author's, a civil day rendered as if it were an instant, and a zone label shown to a
7
+ // reader who is already on that clock.
8
+
9
+ const WARSAW = "Europe/Warsaw" as Zone;
10
+ const NEW_YORK = "America/New_York" as Zone;
11
+ // 2026-09-20T20:43Z. Warsaw is +2 (summer time), New York -4.
12
+ const SEPTEMBER = Date.UTC(2026, 8, 20, 20, 43);
13
+ // 2026-12-20T20:43Z. Warsaw is +1, New York -5: the same two zones, different offsets.
14
+ const DECEMBER = Date.UTC(2026, 11, 20, 20, 43);
15
+
16
+ describe(`isZone`, () => {
17
+ test(`accepts what ICU knows and refuses what it does not`, () => {
18
+ expect(isZone(`Europe/Warsaw`)).toBe(true);
19
+ expect(isZone(`UTC`)).toBe(true);
20
+ expect(isZone(`Europe/Warszawa`)).toBe(false);
21
+ expect(isZone(``)).toBe(false);
22
+ });
23
+
24
+ /* An offset is not a zone: it cannot say what happens in March, which is the whole reason schedules need a zone. */
25
+ test(`refuses a bare offset, which is the shape that looks like it would work`, () => {
26
+ expect(isZone(`+02:00`)).toBe(false);
27
+ expect(isZone(`UTC+2`)).toBe(false);
28
+ });
29
+
30
+ test(`asZone passes real ids through and swallows the rest`, () => {
31
+ expect(asZone(`America/New_York`)).toBe(`America/New_York`);
32
+ expect(asZone(`Mars/Olympus`)).toBeUndefined();
33
+ expect(asZone(undefined)).toBeUndefined();
34
+ });
35
+ });
36
+
37
+ describe(`civilDayIn`, () => {
38
+ /* THE ONE THAT MATTERS: one instant, three zones, three different calendar days. */
39
+ test(`an instant falls on different days depending on the zone asked`, () => {
40
+ // 2026-09-20T23:30Z: already the 21st in Warsaw, still the 20th in UTC and New York.
41
+ const lateEvening = Date.UTC(2026, 8, 20, 23, 30);
42
+ expect(civilDayIn(lateEvening, WARSAW)).toBe(`2026-09-21`);
43
+ expect(civilDayIn(lateEvening, UTC)).toBe(`2026-09-20`);
44
+ expect(civilDayIn(lateEvening, NEW_YORK)).toBe(`2026-09-20`);
45
+ });
46
+
47
+ test(`utcDayOf is civilDayIn UTC, spelled so every UTC bucket is greppable`, () => {
48
+ expect(utcDayOf(SEPTEMBER)).toBe(civilDayIn(SEPTEMBER, UTC));
49
+ });
50
+
51
+ test(`asCivilDay takes the shape and nothing else`, () => {
52
+ expect(asCivilDay(`2026-09-20`)).toBe(`2026-09-20`);
53
+ expect(asCivilDay(`2026-09-20T00:00:00Z`)).toBeUndefined();
54
+ expect(asCivilDay(`20/09/2026`)).toBeUndefined();
55
+ });
56
+ });
57
+
58
+ describe(`sameClock`, () => {
59
+ test(`two zones on the same offset right now read as one clock`, () => {
60
+ expect(sameClock(WARSAW, `Europe/Berlin` as Zone, SEPTEMBER)).toBe(true);
61
+ expect(sameClock(WARSAW, UTC, SEPTEMBER)).toBe(false);
62
+ });
63
+
64
+ /* Asked at an instant, not in the abstract: London and Warsaw differ all year, London and UTC only in summer. */
65
+ test(`the answer is a property of the instant, not of the pair`, () => {
66
+ expect(sameClock(`Europe/London` as Zone, UTC, SEPTEMBER)).toBe(false);
67
+ expect(sameClock(`Europe/London` as Zone, UTC, DECEMBER)).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe(`zoneLabel`, () => {
72
+ test(`names the zone for a reader on a different clock`, () => {
73
+ expect(zoneLabel(WARSAW, NEW_YORK, SEPTEMBER)).toBe(`Europe/Warsaw`);
74
+ });
75
+
76
+ test(`stays quiet for a reader already on that clock, rather than adding width for nothing`, () => {
77
+ expect(zoneLabel(WARSAW, `Europe/Berlin` as Zone, SEPTEMBER)).toBeUndefined();
78
+ expect(zoneLabel(UTC, UTC, SEPTEMBER)).toBeUndefined();
79
+ });
80
+
81
+ test(`underscores become spaces, since the id is read as words here`, () => {
82
+ expect(zoneLabel(NEW_YORK, UTC, SEPTEMBER)).toBe(`America/New York`);
83
+ });
84
+ });
85
+
86
+ describe(`cronOptions`, () => {
87
+ test(`hands croner the key it actually reads`, () => {
88
+ expect(cronOptions(WARSAW)).toEqual({ timezone: `Europe/Warsaw` });
89
+ });
90
+ });
@@ -0,0 +1,118 @@
1
+ // The vocabulary for time values that cross a machine boundary, and the one rule behind all of it: an INSTANT means
2
+ // the same thing everywhere, a WALL RULE means nothing without a zone, and a CIVIL DAY is a zone's opinion wearing the
3
+ // costume of a fact.
4
+ //
5
+ // Intentic runs its daemon in a UTC container and its editor in whatever zone the reader's laptop is in. Anything that
6
+ // says "at 09:00" was authored on one of those machines and evaluated on the other, so the zone has to travel with it
7
+ // or the two disagree by the offset — silently, since both sides render a plausible number.
8
+ //
9
+ // Instants stay plain `number` (epoch ms) deliberately. That convention is already universal in this codebase and
10
+ // already safe; branding it would touch thousands of call sites to prevent a mistake nobody is making. The two types
11
+ // below are branded because they are the two that DO get confused for an instant, and each confusion is a shipped bug:
12
+ // a civil day rendered in the reader's zone slides a day westward, and a cron evaluated in the wrong zone fires at the
13
+ // wrong hour.
14
+ import { z } from "zod";
15
+
16
+ /**
17
+ * A named IANA zone ("Europe/Warsaw"), never a fixed offset. An offset cannot express the summer-time rule that moves
18
+ * "09:00" twice a year, which is exactly what a recurring schedule has to survive.
19
+ */
20
+ export type Zone = string & { readonly __brand: "Zone" };
21
+
22
+ /** The daemon's own zone, and the answer for a sandbox whose owner has never said where they are. */
23
+ export const UTC = "UTC" as Zone;
24
+
25
+ /**
26
+ * Whether ICU knows this id AND it names a place rather than an offset. The platform's own tables are the only honest
27
+ * source for the first half; the second half has to be enforced here, because ICU accepts `+02:00` as a `timeZone`
28
+ * since ES2024 and that is exactly the shape a naive `getTimezoneOffset()` would produce. A fixed offset is frozen at
29
+ * the moment it was read: a schedule stored as `+02:00` in September fires an hour off for the whole of the winter.
30
+ * `Etc/GMT+5` is left alone — it is a real id somebody may genuinely be on, not an accident of arithmetic.
31
+ */
32
+ export const isZone = (value: string): value is Zone => {
33
+ if (value.startsWith("+") || value.startsWith("-")) {
34
+ return false;
35
+ }
36
+ try {
37
+ return Intl.DateTimeFormat("en", { timeZone: value }).resolvedOptions().timeZone !== "";
38
+ } catch {
39
+ return false;
40
+ }
41
+ };
42
+
43
+ /** An id from outside (a manifest, a migrated config, a browser) as a `Zone`, or undefined if ICU does not know it. */
44
+ export const asZone = (value: string | undefined): Zone | undefined => (value !== undefined && isZone(value) ? value : undefined);
45
+
46
+ // Rejects at the edge rather than at the croner call: an unknown zone in a manifest should fail the save that
47
+ // introduced it, where somebody is still watching, not the tick three hours later.
48
+ export const ZoneSchema = z.string().refine(isZone, { message: "Not a zone name ICU knows, e.g. Europe/Warsaw or UTC." });
49
+
50
+ /**
51
+ * The zone the reader's own machine is in. Browser-side only by nature — in the daemon this answers UTC, which is true
52
+ * but useless, and is why the sandbox keeps a zone setting instead of asking its own clock.
53
+ */
54
+ export const localZone = (): Zone => Intl.DateTimeFormat().resolvedOptions().timeZone as Zone;
55
+
56
+ /**
57
+ * A calendar day as `YYYY-MM-DD`, carrying no time and no zone of its own. Branded so it cannot be handed to a
58
+ * formatter expecting an instant: `new Date("2026-09-20")` parses as UTC midnight, and rendering that in a zone behind
59
+ * UTC prints the 19th. That bug is invisible to whoever writes it, because it is correct in their own zone.
60
+ */
61
+ export type CivilDay = string & { readonly __brand: "CivilDay" };
62
+
63
+ export const CivilDaySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "A calendar day as YYYY-MM-DD.");
64
+
65
+ /** The calendar day an instant falls on, in a named zone. `en-CA` because its short date format IS ISO order. */
66
+ export const civilDayIn = (at: number, zone: Zone): CivilDay =>
67
+ new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(at) as CivilDay;
68
+
69
+ /** The UTC calendar day of an instant. Named rather than spelled inline, so every UTC bucket in the tree is greppable. */
70
+ export const utcDayOf = (at: number): CivilDay => new Date(at).toISOString().slice(0, 10) as CivilDay;
71
+
72
+ /**
73
+ * An instant as a person on a named clock would read it: "2026-09-20 20:43". For text the daemon writes — a log line,
74
+ * a note handed to an agent — where an ISO stamp is unambiguous but unreadable, and where the daemon cannot use the
75
+ * editor's formatters because there is no reader in the room. Never for the interface: on screen an instant is drawn
76
+ * in the reader's own clock, by `_editor/ui/src/lib/format.ts`.
77
+ * `sv-SE` because its short format is ISO-shaped, which is the point: no locale ambiguity in a machine-written line.
78
+ */
79
+ export const wallClockIn = (at: number, zone: Zone): string =>
80
+ new Intl.DateTimeFormat("sv-SE", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })
81
+ .format(at)
82
+ .replace(",", "");
83
+
84
+ /** A `YYYY-MM-DD` from a store, a query string or a fixture. Undefined for anything that is not one. */
85
+ export const asCivilDay = (value: string | undefined): CivilDay | undefined => (value !== undefined && /^\d{4}-\d{2}-\d{2}$/.test(value) ? (value as CivilDay) : undefined);
86
+
87
+ /**
88
+ * A recurring wall-clock rule: the cron AND the zone it is meant in, which is the only form in which it means anything.
89
+ * Kept as one type so the two cannot be passed around separately and drift apart — the original bug was a `cron:
90
+ * string` travelling alone from a browser in Europe/Warsaw to a daemon in UTC.
91
+ */
92
+ export interface WallRule {
93
+ readonly cron: string;
94
+ readonly tz: Zone;
95
+ }
96
+
97
+ /** What croner has to be given. Never call `new Cron(expr)` bare: that silently means "in whatever zone this process is". */
98
+ export const cronOptions = (tz: Zone): { timezone: string } => ({ timezone: tz });
99
+
100
+ // The offset of a zone at an instant, in minutes east of UTC. Via the formatter's own `longOffset` part, since the
101
+ // arithmetic alternative (formatting twice and subtracting) gets the summer-time changeover hour wrong.
102
+ const offsetMinutes = (at: number, zone: Zone): number => {
103
+ const part = new Intl.DateTimeFormat("en", { timeZone: zone, timeZoneName: "longOffset" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
104
+ const match = /GMT([+-])(\d{2}):(\d{2})/.exec(part ?? "");
105
+ if (match === null) {
106
+ return 0; // "GMT" with no offset is UTC itself.
107
+ }
108
+ return (match[1] === "-" ? -1 : 1) * (Number(match[2]) * 60 + Number(match[3]));
109
+ };
110
+
111
+ /** Whether two zones are showing the same wall clock right now — which is when naming one on screen would be noise. */
112
+ export const sameClock = (a: Zone, b: Zone, at: number = Date.now()): boolean => a === b || offsetMinutes(at, a) === offsetMinutes(at, b);
113
+
114
+ /**
115
+ * How to name a zone beside a wall-clock time, or undefined when the reader is already on that clock and the label
116
+ * would only add width. The city, not the offset: "Europe/Warsaw" survives the March changeover, "UTC+2" does not.
117
+ */
118
+ export const zoneLabel = (rule: Zone, reader: Zone, at: number = Date.now()): string | undefined => (sameClock(rule, reader, at) ? undefined : rule.replace(/_/g, " "));