@intentic/sandbox-contract 1.302.0 → 1.304.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 (139) 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 +207 -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 +131 -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 +20 -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 +108 -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 +25 -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 +31 -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 +489 -544
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +9 -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/policy/persona-home.d.ts +10 -0
  55. package/dist/policy/persona-home.d.ts.map +1 -0
  56. package/dist/policy/persona-home.js +11 -0
  57. package/dist/policy/persona-home.js.map +1 -0
  58. package/dist/schemas/agents.d.ts +54 -19
  59. package/dist/schemas/agents.d.ts.map +1 -1
  60. package/dist/schemas/agents.js +11 -22
  61. package/dist/schemas/agents.js.map +1 -1
  62. package/dist/schemas/areas.d.ts +21 -0
  63. package/dist/schemas/areas.d.ts.map +1 -0
  64. package/dist/schemas/areas.js +32 -0
  65. package/dist/schemas/areas.js.map +1 -0
  66. package/dist/schemas/automations.d.ts +20 -3
  67. package/dist/schemas/automations.d.ts.map +1 -1
  68. package/dist/schemas/automations.js +2 -0
  69. package/dist/schemas/automations.js.map +1 -1
  70. package/dist/schemas/capabilities.d.ts +11 -46
  71. package/dist/schemas/capabilities.d.ts.map +1 -1
  72. package/dist/schemas/capabilities.js +15 -14
  73. package/dist/schemas/capabilities.js.map +1 -1
  74. package/dist/schemas/personas.d.ts.map +1 -1
  75. package/dist/schemas/personas.js +5 -1
  76. package/dist/schemas/personas.js.map +1 -1
  77. package/dist/schemas/providers/usage.d.ts +4 -0
  78. package/dist/schemas/providers/usage.d.ts.map +1 -1
  79. package/dist/schemas/providers/usage.js +4 -0
  80. package/dist/schemas/providers/usage.js.map +1 -1
  81. package/dist/schemas/settings.d.ts +98 -3
  82. package/dist/schemas/settings.d.ts.map +1 -1
  83. package/dist/schemas/settings.js +46 -14
  84. package/dist/schemas/settings.js.map +1 -1
  85. package/dist/schemas/shared.d.ts +2 -0
  86. package/dist/schemas/shared.d.ts.map +1 -1
  87. package/dist/schemas/shared.js +3 -3
  88. package/dist/schemas/shared.js.map +1 -1
  89. package/dist/schemas/turn-break.d.ts +32 -0
  90. package/dist/schemas/turn-break.d.ts.map +1 -0
  91. package/dist/schemas/turn-break.js +16 -0
  92. package/dist/schemas/turn-break.js.map +1 -0
  93. package/dist/state/definition.d.ts +36 -56
  94. package/dist/state/definition.d.ts.map +1 -1
  95. package/dist/state/history-state.d.ts.map +1 -1
  96. package/dist/state/history-state.js +1 -0
  97. package/dist/state/history-state.js.map +1 -1
  98. package/dist/state/workspace-state.d.ts +5 -0
  99. package/dist/state/workspace-state.d.ts.map +1 -1
  100. package/dist/state/workspace-state.js +1 -0
  101. package/dist/state/workspace-state.js.map +1 -1
  102. package/dist/text/transcript-fold.d.ts.map +1 -1
  103. package/dist/text/transcript-fold.js +3 -7
  104. package/dist/text/transcript-fold.js.map +1 -1
  105. package/dist/time/zone.d.ts +27 -0
  106. package/dist/time/zone.d.ts.map +1 -0
  107. package/dist/time/zone.js +35 -0
  108. package/dist/time/zone.js.map +1 -0
  109. package/package.json +16 -5
  110. package/src/contracts/agents.contract.ts +7 -30
  111. package/src/contracts/areas.contract.ts +42 -0
  112. package/src/contracts/endpoints.contract.ts +82 -0
  113. package/src/contracts/providers.contract.ts +5 -0
  114. package/src/contracts/settings.contract.ts +28 -0
  115. package/src/events/agent-events.ts +4 -1
  116. package/src/events/transcript.ts +9 -1
  117. package/src/index.ts +20 -1
  118. package/src/models/agent-catalog.ts +1 -1
  119. package/src/policy/fence-paths.test.ts +114 -0
  120. package/src/policy/fence-paths.ts +99 -0
  121. package/src/policy/persona-home.test.ts +52 -0
  122. package/src/policy/persona-home.ts +39 -0
  123. package/src/schemas/agents.ts +22 -35
  124. package/src/schemas/areas.test.ts +40 -0
  125. package/src/schemas/areas.ts +49 -0
  126. package/src/schemas/automations.ts +8 -0
  127. package/src/schemas/capabilities.ts +37 -19
  128. package/src/schemas/personas.ts +15 -4
  129. package/src/schemas/providers/usage.ts +9 -0
  130. package/src/schemas/settings.ts +98 -26
  131. package/src/schemas/shared.ts +6 -5
  132. package/src/schemas/turn-break.ts +49 -0
  133. package/src/state/history-state.ts +4 -0
  134. package/src/state/workspace-state.test.ts +7 -3
  135. package/src/state/workspace-state.ts +9 -2
  136. package/src/text/transcript-fold.test.ts +3 -5
  137. package/src/text/transcript-fold.ts +3 -7
  138. package/src/time/zone.test.ts +90 -0
  139. package/src/time/zone.ts +118 -0
@@ -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.
@@ -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, " "));