@knpkv/jira-clockify 1.3.0 → 1.4.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 (242) hide show
  1. package/README.md +245 -1
  2. package/dist/package.json +36 -12
  3. package/dist/src/agent/agentSettings.d.ts +18 -0
  4. package/dist/src/agent/agentSettings.d.ts.map +1 -0
  5. package/dist/src/agent/agentSettings.js +23 -0
  6. package/dist/src/agent/agentSettings.js.map +1 -0
  7. package/dist/src/agent/schedule.d.ts +20 -0
  8. package/dist/src/agent/schedule.d.ts.map +1 -0
  9. package/dist/src/agent/schedule.js +189 -0
  10. package/dist/src/agent/schedule.js.map +1 -0
  11. package/dist/src/agent/sessions.d.ts +427 -0
  12. package/dist/src/agent/sessions.d.ts.map +1 -0
  13. package/dist/src/agent/sessions.js +868 -0
  14. package/dist/src/agent/sessions.js.map +1 -0
  15. package/dist/src/agent/sourceConsumption.d.ts +79 -0
  16. package/dist/src/agent/sourceConsumption.d.ts.map +1 -0
  17. package/dist/src/agent/sourceConsumption.js +191 -0
  18. package/dist/src/agent/sourceConsumption.js.map +1 -0
  19. package/dist/src/agent/watch.d.ts +76 -0
  20. package/dist/src/agent/watch.d.ts.map +1 -0
  21. package/dist/src/agent/watch.js +45 -0
  22. package/dist/src/agent/watch.js.map +1 -0
  23. package/dist/src/agent/writePlanning.d.ts +154 -0
  24. package/dist/src/agent/writePlanning.d.ts.map +1 -0
  25. package/dist/src/agent/writePlanning.js +210 -0
  26. package/dist/src/agent/writePlanning.js.map +1 -0
  27. package/dist/src/bin.d.ts +3 -0
  28. package/dist/src/bin.d.ts.map +1 -0
  29. package/dist/src/bin.js +9 -3
  30. package/dist/src/bin.js.map +1 -1
  31. package/dist/src/cli/agentWrite.d.ts +164 -0
  32. package/dist/src/cli/agentWrite.d.ts.map +1 -0
  33. package/dist/src/cli/agentWrite.js +362 -0
  34. package/dist/src/cli/agentWrite.js.map +1 -0
  35. package/dist/src/cli/auth.d.ts +8 -0
  36. package/dist/src/cli/auth.d.ts.map +1 -0
  37. package/dist/src/cli/auth.js +10 -10
  38. package/dist/src/cli/auth.js.map +1 -1
  39. package/dist/src/cli/calendar.d.ts +42 -0
  40. package/dist/src/cli/calendar.d.ts.map +1 -0
  41. package/dist/src/cli/calendar.js +189 -0
  42. package/dist/src/cli/calendar.js.map +1 -0
  43. package/dist/src/cli/config.d.ts +12 -0
  44. package/dist/src/cli/config.d.ts.map +1 -0
  45. package/dist/src/cli/config.js +163 -21
  46. package/dist/src/cli/config.js.map +1 -1
  47. package/dist/src/cli/fetchTicket.d.ts +51 -0
  48. package/dist/src/cli/fetchTicket.d.ts.map +1 -0
  49. package/dist/src/cli/fetchTicket.js +18 -2
  50. package/dist/src/cli/fetchTicket.js.map +1 -1
  51. package/dist/src/cli/fuzzySelect.d.ts +24 -0
  52. package/dist/src/cli/fuzzySelect.d.ts.map +1 -0
  53. package/dist/src/cli/layers.d.ts +40 -0
  54. package/dist/src/cli/layers.d.ts.map +1 -0
  55. package/dist/src/cli/layers.js +56 -12
  56. package/dist/src/cli/layers.js.map +1 -1
  57. package/dist/src/cli/list.d.ts +8 -0
  58. package/dist/src/cli/list.d.ts.map +1 -0
  59. package/dist/src/cli/list.js +2 -2
  60. package/dist/src/cli/list.js.map +1 -1
  61. package/dist/src/cli/reconcile.d.ts +129 -0
  62. package/dist/src/cli/reconcile.d.ts.map +1 -0
  63. package/dist/src/cli/reconcile.js +635 -46
  64. package/dist/src/cli/reconcile.js.map +1 -1
  65. package/dist/src/cli/root.d.ts +15 -0
  66. package/dist/src/cli/root.d.ts.map +1 -0
  67. package/dist/src/cli/root.js +4 -3
  68. package/dist/src/cli/root.js.map +1 -1
  69. package/dist/src/cli/runtimeFailure.d.ts +10 -0
  70. package/dist/src/cli/runtimeFailure.d.ts.map +1 -0
  71. package/dist/src/cli/runtimeFailure.js +17 -0
  72. package/dist/src/cli/runtimeFailure.js.map +1 -0
  73. package/dist/src/cli/setup.d.ts +10 -0
  74. package/dist/src/cli/setup.d.ts.map +1 -0
  75. package/dist/src/cli/setup.js +7 -7
  76. package/dist/src/cli/setup.js.map +1 -1
  77. package/dist/src/cli/timer/discard.d.ts +4 -0
  78. package/dist/src/cli/timer/discard.d.ts.map +1 -0
  79. package/dist/src/cli/timer/discard.js +2 -2
  80. package/dist/src/cli/timer/discard.js.map +1 -1
  81. package/dist/src/cli/timer/edit.d.ts +22 -0
  82. package/dist/src/cli/timer/edit.d.ts.map +1 -0
  83. package/dist/src/cli/timer/edit.js +62 -42
  84. package/dist/src/cli/timer/edit.js.map +1 -1
  85. package/dist/src/cli/timer/index.d.ts +12 -0
  86. package/dist/src/cli/timer/index.d.ts.map +1 -0
  87. package/dist/src/cli/timer/log.d.ts +16 -0
  88. package/dist/src/cli/timer/log.d.ts.map +1 -0
  89. package/dist/src/cli/timer/log.js +6 -6
  90. package/dist/src/cli/timer/log.js.map +1 -1
  91. package/dist/src/cli/timer/start.d.ts +21 -0
  92. package/dist/src/cli/timer/start.d.ts.map +1 -0
  93. package/dist/src/cli/timer/start.js +9 -9
  94. package/dist/src/cli/timer/start.js.map +1 -1
  95. package/dist/src/cli/timer/status.d.ts +22 -0
  96. package/dist/src/cli/timer/status.d.ts.map +1 -0
  97. package/dist/src/cli/timer/status.js +3 -3
  98. package/dist/src/cli/timer/status.js.map +1 -1
  99. package/dist/src/cli/timer/stop.d.ts +48 -0
  100. package/dist/src/cli/timer/stop.d.ts.map +1 -0
  101. package/dist/src/cli/timer/stop.js +19 -19
  102. package/dist/src/cli/timer/stop.js.map +1 -1
  103. package/dist/src/cli/timer.d.ts +11 -0
  104. package/dist/src/cli/timer.d.ts.map +1 -0
  105. package/dist/src/cli/timer.js +1 -1
  106. package/dist/src/cli/timer.js.map +1 -1
  107. package/dist/src/cli/watch.d.ts +54 -0
  108. package/dist/src/cli/watch.d.ts.map +1 -0
  109. package/dist/src/cli/watch.js +412 -0
  110. package/dist/src/cli/watch.js.map +1 -0
  111. package/dist/src/cli/watchLease.d.ts +99 -0
  112. package/dist/src/cli/watchLease.d.ts.map +1 -0
  113. package/dist/src/cli/watchLease.js +204 -0
  114. package/dist/src/cli/watchLease.js.map +1 -0
  115. package/dist/src/cli/writerGuard.d.ts +37 -0
  116. package/dist/src/cli/writerGuard.d.ts.map +1 -0
  117. package/dist/src/cli/writerGuard.js +92 -0
  118. package/dist/src/cli/writerGuard.js.map +1 -0
  119. package/dist/src/index.d.ts +46 -0
  120. package/dist/src/index.d.ts.map +1 -0
  121. package/dist/src/index.js +46 -0
  122. package/dist/src/index.js.map +1 -0
  123. package/dist/src/main.d.ts +4 -0
  124. package/dist/src/main.d.ts.map +1 -0
  125. package/dist/src/services/AgentSessionReader.d.ts +121 -0
  126. package/dist/src/services/AgentSessionReader.d.ts.map +1 -0
  127. package/dist/src/services/AgentSessionReader.js +392 -0
  128. package/dist/src/services/AgentSessionReader.js.map +1 -0
  129. package/dist/src/services/ClockifyAuth.d.ts +47 -0
  130. package/dist/src/services/ClockifyAuth.d.ts.map +1 -0
  131. package/dist/src/services/ClockifyAuth.js +2 -2
  132. package/dist/src/services/ClockifyAuth.js.map +1 -1
  133. package/dist/src/services/CodexTranscript.d.ts +112 -0
  134. package/dist/src/services/CodexTranscript.d.ts.map +1 -0
  135. package/dist/src/services/CodexTranscript.js +96 -0
  136. package/dist/src/services/CodexTranscript.js.map +1 -0
  137. package/dist/src/services/ConfigService.d.ts +99 -0
  138. package/dist/src/services/ConfigService.d.ts.map +1 -0
  139. package/dist/src/services/ConfigService.js +79 -7
  140. package/dist/src/services/ConfigService.js.map +1 -1
  141. package/dist/src/services/HomeDirectory.d.ts +17 -0
  142. package/dist/src/services/HomeDirectory.d.ts.map +1 -0
  143. package/dist/src/services/HomeDirectory.js +1 -1
  144. package/dist/src/services/IssueFacts.d.ts +127 -0
  145. package/dist/src/services/IssueFacts.d.ts.map +1 -0
  146. package/dist/src/services/IssueFacts.js +277 -0
  147. package/dist/src/services/IssueFacts.js.map +1 -0
  148. package/dist/src/services/ProviderDeletion.d.ts +19 -0
  149. package/dist/src/services/ProviderDeletion.d.ts.map +1 -0
  150. package/dist/src/services/ProviderDeletion.js +37 -0
  151. package/dist/src/services/ProviderDeletion.js.map +1 -0
  152. package/dist/src/services/ReconcileService.d.ts +378 -0
  153. package/dist/src/services/ReconcileService.d.ts.map +1 -0
  154. package/dist/src/services/ReconcileService.js +1270 -79
  155. package/dist/src/services/ReconcileService.js.map +1 -1
  156. package/dist/src/services/SavedEntries.d.ts +40 -0
  157. package/dist/src/services/SavedEntries.d.ts.map +1 -0
  158. package/dist/src/services/SavedEntries.js +209 -0
  159. package/dist/src/services/SavedEntries.js.map +1 -0
  160. package/dist/src/services/SessionAttributor.d.ts +114 -0
  161. package/dist/src/services/SessionAttributor.d.ts.map +1 -0
  162. package/dist/src/services/SessionAttributor.js +240 -0
  163. package/dist/src/services/SessionAttributor.js.map +1 -0
  164. package/dist/src/services/SourceLedger.d.ts +104 -0
  165. package/dist/src/services/SourceLedger.d.ts.map +1 -0
  166. package/dist/src/services/SourceLedger.js +302 -0
  167. package/dist/src/services/SourceLedger.js.map +1 -0
  168. package/dist/src/services/StateWriter.d.ts +39 -0
  169. package/dist/src/services/StateWriter.d.ts.map +1 -0
  170. package/dist/src/services/TicketService.d.ts +63 -0
  171. package/dist/src/services/TicketService.d.ts.map +1 -0
  172. package/dist/src/services/TimerService.d.ts +145 -0
  173. package/dist/src/services/TimerService.d.ts.map +1 -0
  174. package/dist/src/services/TimerService.js +113 -80
  175. package/dist/src/services/TimerService.js.map +1 -1
  176. package/dist/src/services/internal/JiraWorklogPost.d.ts +7 -0
  177. package/dist/src/services/internal/JiraWorklogPost.d.ts.map +1 -0
  178. package/dist/src/services/internal/JiraWorklogPost.js +32 -0
  179. package/dist/src/services/internal/JiraWorklogPost.js.map +1 -0
  180. package/dist/src/testing/fakeHeadless.d.ts +318 -0
  181. package/dist/src/testing/fakeHeadless.d.ts.map +1 -0
  182. package/dist/src/testing/fakeHeadless.js +1149 -0
  183. package/dist/src/testing/fakeHeadless.js.map +1 -0
  184. package/dist/src/tui/App.d.ts +6 -0
  185. package/dist/src/tui/App.d.ts.map +1 -0
  186. package/dist/src/tui/App.js +1 -1
  187. package/dist/src/tui/App.js.map +1 -1
  188. package/dist/src/tui/atoms/runtime.d.ts +8 -0
  189. package/dist/src/tui/atoms/runtime.d.ts.map +1 -0
  190. package/dist/src/tui/atoms/runtime.js +1 -1
  191. package/dist/src/tui/atoms/runtime.js.map +1 -1
  192. package/dist/src/tui/atoms/tickets.d.ts +3 -0
  193. package/dist/src/tui/atoms/tickets.d.ts.map +1 -0
  194. package/dist/src/tui/atoms/timer.d.ts +11 -0
  195. package/dist/src/tui/atoms/timer.d.ts.map +1 -0
  196. package/dist/src/tui/atoms/timer.js +1 -1
  197. package/dist/src/tui/atoms/timer.js.map +1 -1
  198. package/dist/src/tui/atoms/ui.d.ts +11 -0
  199. package/dist/src/tui/atoms/ui.d.ts.map +1 -0
  200. package/dist/src/tui/atoms/ui.js +1 -1
  201. package/dist/src/tui/atoms/ui.js.map +1 -1
  202. package/dist/src/tui/components/BigTimer.d.ts +2 -0
  203. package/dist/src/tui/components/BigTimer.d.ts.map +1 -0
  204. package/dist/src/tui/components/Footer.d.ts +2 -0
  205. package/dist/src/tui/components/Footer.d.ts.map +1 -0
  206. package/dist/src/tui/components/Header.d.ts +2 -0
  207. package/dist/src/tui/components/Header.d.ts.map +1 -0
  208. package/dist/src/tui/components/Header.js +1 -1
  209. package/dist/src/tui/components/Header.js.map +1 -1
  210. package/dist/src/tui/components/PopupInput.d.ts +18 -0
  211. package/dist/src/tui/components/PopupInput.d.ts.map +1 -0
  212. package/dist/src/tui/components/PopupMessage.d.ts +20 -0
  213. package/dist/src/tui/components/PopupMessage.d.ts.map +1 -0
  214. package/dist/src/tui/components/TicketList.d.ts +2 -0
  215. package/dist/src/tui/components/TicketList.d.ts.map +1 -0
  216. package/dist/src/tui/components/TicketList.js +1 -1
  217. package/dist/src/tui/components/TicketList.js.map +1 -1
  218. package/dist/src/tui/components/TicketRow.d.ts +8 -0
  219. package/dist/src/tui/components/TicketRow.d.ts.map +1 -0
  220. package/dist/src/tui/components/TimerDisplay.d.ts +2 -0
  221. package/dist/src/tui/components/TimerDisplay.d.ts.map +1 -0
  222. package/dist/src/tui/components/index.d.ts +12 -0
  223. package/dist/src/tui/components/index.d.ts.map +1 -0
  224. package/dist/src/tui/context/theme.d.ts +15 -0
  225. package/dist/src/tui/context/theme.d.ts.map +1 -0
  226. package/dist/src/tui/hooks/useElapsedTimer.d.ts +6 -0
  227. package/dist/src/tui/hooks/useElapsedTimer.d.ts.map +1 -0
  228. package/dist/src/tui/hooks/useElapsedTimer.js +1 -1
  229. package/dist/src/tui/hooks/useElapsedTimer.js.map +1 -1
  230. package/dist/src/tui/hooks/useTerminalSize.d.ts +5 -0
  231. package/dist/src/tui/hooks/useTerminalSize.d.ts.map +1 -0
  232. package/dist/src/utils/hints.d.ts +12 -0
  233. package/dist/src/utils/hints.d.ts.map +1 -0
  234. package/dist/src/utils/hints.js +12 -0
  235. package/dist/src/utils/hints.js.map +1 -0
  236. package/dist/src/utils/time.d.ts +104 -0
  237. package/dist/src/utils/time.d.ts.map +1 -0
  238. package/dist/src/utils/time.js +74 -8
  239. package/dist/src/utils/time.js.map +1 -1
  240. package/dist/tsconfig.tsbuildinfo +1 -1
  241. package/package.json +36 -18
  242. package/skills/jcf/SKILL.md +93 -1
@@ -0,0 +1,1149 @@
1
+ /**
2
+ * `FakeHeadlessLayer` — the same service shape as the CLI's `HeadlessLayer`, with every external
3
+ * boundary faked and every write captured.
4
+ *
5
+ * **Why this exists**
6
+ *
7
+ * `HeadlessLayer` is fully live, which is why `commandSurface.test.ts` can only assert `--help`
8
+ * outcomes. This layer keeps the *real* service implementations (TimerService, ReconcileService,
9
+ * AgentSessionReader) and fakes only what leaves the process: Clockify, Jira, both auth services,
10
+ * config, the filesystem, the terminal, and the Coding Agent. Commands can then be run end to end
11
+ * and asserted on what they printed, proposed, and wrote.
12
+ *
13
+ * **How to read a test written against it**
14
+ *
15
+ * - `world.createdClockifyEntries` / `world.jiraWorklogs` are the writes that actually happened.
16
+ * - `world.describeRequests` / `world.describeBatches` do the same for the notes written onto
17
+ * entries, so what a run *spends* on descriptions is assertable too.
18
+ * - `world.attributorRequests` is every session asked about and `world.attributorBatches` is the
19
+ * number of *calls* those took, so both "cost nothing" and "batched, not one-by-one" are
20
+ * assertable. `world.maxAttributorInFlight` is the most calls that ever overlapped.
21
+ * - `world.stdout` / `world.stderr` are the lines the command printed, split by stream, which is
22
+ * what the JSON Output Contract is about.
23
+ * - `world.prompts` is what the terminal rendered. Prompt text goes through `Terminal.display`,
24
+ * not `Console`, so this is the only place a confirmation's wording is observable.
25
+ * - `world.transcriptReads` is every transcript path opened, so a scope rule that promises a file is
26
+ * never read can be asserted rather than assumed.
27
+ * - `world.transcripts` is the transcripts on disk, read live. Append to it mid-run to model a
28
+ * session that starts, or grows, while a long-running command is watching.
29
+ * - `keep` scripts the row picker: `keep[i] === false` unchecks row `i`. Omitting `keep` entirely
30
+ * sends no input at all, which is the same signal as a missing TTY.
31
+ *
32
+ * **Why it ships**
33
+ *
34
+ * It lives in `src` and is exported as `@knpkv/jira-clockify/testing.js` because it is the highest
35
+ * seam in the package and the only one a *consumer* can drive: a web server over these services has
36
+ * to be testable against captured writes rather than a real Clockify account. Kept out of the barrel
37
+ * so nothing production imports it by accident.
38
+ */
39
+ import { NodePath } from "@effect/platform-node";
40
+ import { ClockifyApiClient } from "@knpkv/clockify-api-client";
41
+ import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client";
42
+ import { JiraAuth } from "@knpkv/jira-cli/JiraAuth";
43
+ import * as ByteSize from "effect/ByteSize";
44
+ import * as Console from "effect/Console";
45
+ import * as Effect from "effect/Effect";
46
+ import * as FileSystem from "effect/FileSystem";
47
+ import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
48
+ import * as Layer from "effect/Layer";
49
+ import * as Option from "effect/Option";
50
+ import { systemError } from "effect/PlatformError";
51
+ import * as Predicate from "effect/Predicate";
52
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
53
+ import * as Queue from "effect/Queue";
54
+ import * as Redacted from "effect/Redacted";
55
+ import * as Schema from "effect/Schema";
56
+ import * as Stdio from "effect/Stdio";
57
+ import * as Terminal from "effect/Terminal";
58
+ import { defaultSessionAgentSettings } from "../agent/agentSettings.js";
59
+ import { LogToStderrLive } from "../cli/layers.js";
60
+ import { layer as agentSessionReaderLayer } from "../services/AgentSessionReader.js";
61
+ import { ClockifyAuth } from "../services/ClockifyAuth.js";
62
+ import { ConfigService } from "../services/ConfigService.js";
63
+ import { HomeDirectory } from "../services/HomeDirectory.js";
64
+ import { layer as issueFactsLayer } from "../services/IssueFacts.js";
65
+ import { layer as reconcileServiceLayer } from "../services/ReconcileService.js";
66
+ import { layer as savedEntriesLayer } from "../services/SavedEntries.js";
67
+ import { SessionAttributor, SessionAttributorError } from "../services/SessionAttributor.js";
68
+ import { layer as sourceLedgerLayer } from "../services/SourceLedger.js";
69
+ import { StateWriter } from "../services/StateWriter.js";
70
+ import { layer as ticketServiceLayer } from "../services/TicketService.js";
71
+ import { layer as timerServiceLayer } from "../services/TimerService.js";
72
+ export const FAKE_HOME = "/fake-home";
73
+ export const FAKE_WORKSPACE_ID = "ws-fake";
74
+ export const FAKE_USER_ID = "user-fake";
75
+ /**
76
+ * The text of a Jira worklog comment, read back out of the document format Jira takes.
77
+ *
78
+ * Deliberately tolerant: a test asserts on what a reader would see, and walking the node tree for
79
+ * every `text` leaf says that without also pinning the paragraph structure around it.
80
+ */
81
+ /**
82
+ * One node of Jira's document format, reduced to what reading a comment back needs.
83
+ *
84
+ * Decoded rather than inspected: the tree is external data, and a schema is what turns "some
85
+ * object" into something with a `text` and a `content` this can rely on.
86
+ */
87
+ const AdfNode = Schema.Struct({
88
+ text: Schema.optional(Schema.String),
89
+ content: Schema.optional(Schema.Unknown)
90
+ });
91
+ const decodeAdfNode = Schema.decodeUnknownOption(AdfNode);
92
+ /** The worklog fields the fake reads back off a POST body. */
93
+ const WorklogPayload = Schema.Struct({
94
+ started: Schema.optional(Schema.String),
95
+ timeSpentSeconds: Schema.optional(Schema.Finite),
96
+ comment: Schema.optionalKey(Schema.Json)
97
+ });
98
+ const decodeWorklogPayload = Schema.decodeUnknownOption(Schema.fromJsonString(WorklogPayload));
99
+ const commentText = (comment) => {
100
+ const texts = [];
101
+ const walk = (node) => {
102
+ if (Array.isArray(node)) {
103
+ for (const child of node)
104
+ walk(child);
105
+ return;
106
+ }
107
+ const decoded = decodeAdfNode(node);
108
+ if (Option.isNone(decoded))
109
+ return;
110
+ const { content, text } = decoded.value;
111
+ if (text !== undefined)
112
+ texts.push(text);
113
+ if (content !== undefined)
114
+ walk(content);
115
+ };
116
+ walk(comment);
117
+ return texts.join(" ");
118
+ };
119
+ /** Whoever the fake is logged in as unless a test says otherwise. */
120
+ export const FAKE_ACCOUNT_ID = "acct-me";
121
+ const defaultConfig = {
122
+ sessionAgent: defaultSessionAgentSettings,
123
+ defaultJql: "",
124
+ refreshInterval: 30,
125
+ projectMap: {},
126
+ workspaceId: null,
127
+ defaultProjectId: null,
128
+ defaultProjectName: null,
129
+ defaultBillable: true,
130
+ sessionRoots: [],
131
+ sessionTicketMap: {},
132
+ sessionIdleCapSeconds: 300,
133
+ sessionConfidenceFloor: 0.7,
134
+ sessionOwnership: "assigned",
135
+ sessionOwnershipOverrides: [],
136
+ sessionDwellSeconds: 900
137
+ };
138
+ const makeTimeEntry = (entry, id) => ({
139
+ id: entry.id ?? id,
140
+ description: entry.description,
141
+ billable: entry.billable ?? true,
142
+ userId: entry.userId ?? FAKE_USER_ID,
143
+ ...(entry.projectId !== undefined && { projectId: entry.projectId }),
144
+ ...(entry.taskId !== undefined && { taskId: entry.taskId }),
145
+ workspaceId: FAKE_WORKSPACE_ID,
146
+ timeInterval: { start: entry.start, ...((entry.end !== undefined) && { end: entry.end }) },
147
+ tagIds: entry.tagIds ?? [],
148
+ type: "REGULAR",
149
+ isLocked: false
150
+ });
151
+ /** A `File.Info` with no mtime, so the reader's mtime pre-filter never skips a fixture. */
152
+ const fileInfo = (type) => ({
153
+ type,
154
+ mtime: Option.none(),
155
+ atime: Option.none(),
156
+ birthtime: Option.none(),
157
+ dev: 0,
158
+ ino: Option.none(),
159
+ mode: 0,
160
+ nlink: Option.none(),
161
+ uid: Option.none(),
162
+ gid: Option.none(),
163
+ rdev: Option.none(),
164
+ size: ByteSize.bytes(0),
165
+ blksize: Option.none(),
166
+ blocks: Option.none()
167
+ });
168
+ const TRANSCRIPT_ROOT = `${FAKE_HOME}/.claude/projects`;
169
+ /**
170
+ * A read-only in-memory filesystem serving the transcript fixtures a test declared.
171
+ *
172
+ * Read live from `world.transcripts` on every call rather than snapshotted at layer construction.
173
+ * A watch is a command that runs while the transcripts underneath it grow, so a test has to be able
174
+ * to append a prompt between two ticks — with a snapshot, every tick would see the same day.
175
+ */
176
+ const fakeFileSystemLayer = (transcripts, reads, written, unwritable, unreadable, afterFileWrite, pidNamespaceInode) => {
177
+ /**
178
+ * The project directory a transcript really lives in: the Claude CLI derives it from the working
179
+ * directory the session ran in, not from anything the test chose.
180
+ *
181
+ * Derived here rather than taken from the fixture key so a test cannot accidentally file an
182
+ * out-of-scope session inside a Session Root — which is the behaviour the reader's pre-read scope
183
+ * filter depends on. The key's own directory is the fallback for a fixture with no readable `cwd`.
184
+ */
185
+ const projectDirOf = (key, content) => {
186
+ for (const line of content.split("\n")) {
187
+ if (!line.includes("\"cwd\""))
188
+ continue;
189
+ const parsed = JSON.parse(line);
190
+ const cwd = Predicate.isObject(parsed) ? parsed["cwd"] : undefined;
191
+ if (Predicate.isString(cwd))
192
+ return cwd.replace(/[/.]/g, "-");
193
+ }
194
+ return key.split("/")[0] ?? key;
195
+ };
196
+ const fileName = (key) => key.slice(key.lastIndexOf("/") + 1);
197
+ const path = (key, content) => `${TRANSCRIPT_ROOT}/${projectDirOf(key, content)}/${fileName(key)}`;
198
+ const files = () => new Map(Object.entries(transcripts).map(([key, c]) => [path(key, c), c]));
199
+ const projectDirs = () => new Set(Object.entries(transcripts).map(([key, c]) => projectDirOf(key, c)));
200
+ const directories = () => {
201
+ const dirs = new Set([FAKE_HOME, `${FAKE_HOME}/.claude`, TRANSCRIPT_ROOT, `${FAKE_HOME}/.jcf`]);
202
+ for (const dir of projectDirs())
203
+ dirs.add(`${TRANSCRIPT_ROOT}/${dir}`);
204
+ return dirs;
205
+ };
206
+ const notFound = (method, path) => Effect.fail(systemError({
207
+ _tag: "NotFound",
208
+ module: "FileSystem",
209
+ method,
210
+ description: "No such file or directory",
211
+ pathOrDescriptor: path
212
+ }));
213
+ const permissionDenied = (method, path) => Effect.fail(systemError({
214
+ _tag: "PermissionDenied",
215
+ module: "FileSystem",
216
+ method,
217
+ description: "permission denied",
218
+ pathOrDescriptor: path
219
+ }));
220
+ const syncedHandle = (type) => ({
221
+ [FileSystem.FileTypeId]: FileSystem.FileTypeId,
222
+ stat: Effect.succeed(fileInfo(type)),
223
+ seek: () => Effect.succeed(ByteSize.bytes(0)),
224
+ sync: Effect.void,
225
+ read: () => Effect.succeed(0),
226
+ readAlloc: () => Effect.succeed(Option.none()),
227
+ truncate: () => Effect.void,
228
+ write: (buffer) => Effect.succeed(buffer.length),
229
+ writeAll: () => Effect.void
230
+ });
231
+ return FileSystem.layerNoop({
232
+ realPath: (path) => directories().has(path) || files().has(path) || written[path] !== undefined
233
+ ? Effect.succeed(path)
234
+ : notFound("realPath", path),
235
+ open: (path) => directories().has(path)
236
+ ? Effect.succeed(syncedHandle("Directory"))
237
+ : written[path] !== undefined
238
+ ? Effect.succeed(syncedHandle("File"))
239
+ : notFound("open", path),
240
+ exists: (path) => Effect.succeed(directories().has(path) || files().has(path) || written[path] !== undefined),
241
+ stat: (path) => unreadable.includes(path)
242
+ ? permissionDenied("stat", path)
243
+ : path === "/proc/self/ns/pid" && pidNamespaceInode !== undefined
244
+ ? Effect.succeed({ ...fileInfo("File"), ino: Option.some(pidNamespaceInode) })
245
+ : directories().has(path)
246
+ ? Effect.succeed(fileInfo("Directory"))
247
+ : files().has(path) || written[path] !== undefined
248
+ ? Effect.succeed(fileInfo("File"))
249
+ : notFound("stat", path),
250
+ readDirectory: (path) => {
251
+ if (unreadable.some((suffix) => path.endsWith(suffix)))
252
+ return permissionDenied("readDirectory", path);
253
+ if (path === TRANSCRIPT_ROOT)
254
+ return Effect.succeed([...projectDirs()]);
255
+ const prefix = `${path}/`;
256
+ const entries = [...files().keys()]
257
+ .filter((file) => file.startsWith(prefix))
258
+ .map((file) => file.slice(prefix.length));
259
+ return directories().has(path) ? Effect.succeed(entries) : notFound("readDirectory", path);
260
+ },
261
+ readFileString: (path) => {
262
+ if (path.startsWith(`${TRANSCRIPT_ROOT}/`))
263
+ reads.push(path);
264
+ // Matched by suffix, so a test names the fixture it wrote rather than reproducing the project
265
+ // directory encoding the Claude CLI derives from a session's `cwd`.
266
+ if (unreadable.some((suffix) => path.endsWith(suffix))) {
267
+ return permissionDenied("readFileString", path);
268
+ }
269
+ const stored = written[path];
270
+ if (stored !== undefined)
271
+ return Effect.succeed(stored);
272
+ const content = files().get(path);
273
+ return content === undefined ? notFound("readFileString", path) : Effect.succeed(content);
274
+ },
275
+ // The config service writes through this; nothing in these tests reads it back from disk.
276
+ makeDirectory: () => Effect.void,
277
+ // A real store, so anything the CLI writes and reads back — the watch lease — behaves. `wx` is
278
+ // honoured, because an exclusive create is exactly what the lease relies on for its exclusion.
279
+ writeFileString: (path, content, options) => unwritable.includes(path)
280
+ // Deliberately not `AlreadyExists`: telling those two apart is the whole point of the
281
+ // lease's fail-closed rule, so the fake must be able to produce the other one.
282
+ ? Effect.fail(systemError({
283
+ _tag: "PermissionDenied",
284
+ module: "FileSystem",
285
+ method: "writeFileString",
286
+ description: "permission denied",
287
+ pathOrDescriptor: path
288
+ }))
289
+ : options?.flag === "wx" && written[path] !== undefined
290
+ ? Effect.fail(systemError({
291
+ _tag: "AlreadyExists",
292
+ module: "FileSystem",
293
+ method: "writeFileString",
294
+ description: "file already exists",
295
+ pathOrDescriptor: path
296
+ }))
297
+ : Effect.sync(() => {
298
+ written[path] = content;
299
+ }).pipe(Effect.andThen(afterFileWrite(path))),
300
+ remove: (path) => Effect.sync(() => {
301
+ delete written[path];
302
+ }),
303
+ rename: (from, to) => written[from] === undefined
304
+ ? notFound("rename", from)
305
+ : unwritable.includes(to)
306
+ ? permissionDenied("rename", to)
307
+ : Effect.sync(() => {
308
+ written[to] = written[from];
309
+ delete written[from];
310
+ })
311
+ });
312
+ };
313
+ const keypress = (name) => ({
314
+ input: Option.none(),
315
+ key: { name, ctrl: false, meta: false, shift: false }
316
+ });
317
+ /**
318
+ * Translate "keep these rows" into the keys a multi-select actually receives.
319
+ *
320
+ * The picker's cursor starts on its two meta options (select-all, invert), so choice `i` sits at
321
+ * cursor position `i + 2`. Every row starts checked, so only the rows to *drop* need a `space`.
322
+ */
323
+ export const pickerKeys = (keep) => {
324
+ const keys = [];
325
+ let cursor = 0;
326
+ keep.forEach((keepRow, index) => {
327
+ if (keepRow)
328
+ return;
329
+ const target = index + 2;
330
+ for (; cursor < target; cursor++)
331
+ keys.push(keypress("down"));
332
+ keys.push(keypress("space"));
333
+ });
334
+ keys.push(keypress("enter"));
335
+ return keys;
336
+ };
337
+ /** The conventional terminal width, and the narrowest the picker lays rows out for. */
338
+ const DEFAULT_COLUMNS = 80;
339
+ /**
340
+ * A terminal that replays scripted keypresses. Once the script is exhausted the input queue ends,
341
+ * which `Prompt` reports as `QuitError` — the same outcome as running without a TTY.
342
+ */
343
+ const fakeTerminalLayer = (options, world) => {
344
+ let prompt = 0;
345
+ return Layer.succeed(Terminal.Terminal, Terminal.make({
346
+ columns: Effect.succeed(options.columns),
347
+ rows: Effect.succeed(24),
348
+ // Scoped in Effect 4, and the queue ends with `Cause.Done` rather than a terminal-specific
349
+ // error. An exhausted script is still what a missing TTY looks like to `Prompt`.
350
+ readInput: Effect.gen(function* () {
351
+ yield* Effect.sync(() => options.beforePromptInput?.(world));
352
+ const queue = yield* Queue.unbounded();
353
+ const promptInput = options.promptInputs?.[prompt++];
354
+ if (promptInput !== undefined)
355
+ yield* Queue.offerAll(queue, promptInput.map(keypress));
356
+ // No script at all means no input, so the prompt ends unanswered.
357
+ if (promptInput === undefined && options.keep !== undefined) {
358
+ yield* Queue.offerAll(queue, pickerKeys(options.keep));
359
+ }
360
+ yield* Queue.end(queue);
361
+ return queue;
362
+ }),
363
+ readLine: Effect.succeed(""),
364
+ display: (text) => Effect.sync(() => {
365
+ world.prompts.push(text);
366
+ })
367
+ }));
368
+ };
369
+ /** A console that keeps stdout and stderr apart, which is what the JSON Output Contract needs. */
370
+ const captureConsoleLayer = (world, afterLog) => Layer.succeed(Console.Console, {
371
+ log: (...args) => {
372
+ const line = args.map(String).join(" ");
373
+ world.stdout.push(line);
374
+ afterLog?.(line, world);
375
+ },
376
+ error: (...args) => {
377
+ world.stderr.push(args.map(String).join(" "));
378
+ },
379
+ assert: () => { },
380
+ clear: () => { },
381
+ count: () => { },
382
+ countReset: () => { },
383
+ debug: () => { },
384
+ dir: () => { },
385
+ dirxml: () => { },
386
+ group: () => { },
387
+ groupCollapsed: () => { },
388
+ groupEnd: () => { },
389
+ info: () => { },
390
+ table: () => { },
391
+ time: () => { },
392
+ timeEnd: () => { },
393
+ timeLog: () => { },
394
+ trace: () => { },
395
+ warn: () => { }
396
+ });
397
+ /**
398
+ * Build a fully closed layer with the same services `HeadlessLayer` provides, plus the world the
399
+ * assertions read.
400
+ */
401
+ export const makeFakeHeadless = (options = {}) => {
402
+ let jiraAccessTokenOverride;
403
+ let jiraAccountIdOverride;
404
+ let jiraCloudIdOverride;
405
+ let jiraSiteUrl = "https://fake.atlassian.net";
406
+ const world = {
407
+ createdClockifyEntries: [],
408
+ clockifyRequests: [],
409
+ clockifyAuth: {
410
+ baseUrl: "https://api.clockify.me/api",
411
+ workspaceId: FAKE_WORKSPACE_ID,
412
+ userId: FAKE_USER_ID,
413
+ apiKey: "key",
414
+ ...options.clockifyAuth
415
+ },
416
+ jiraAuth: {
417
+ get accessToken() {
418
+ return jiraAccessTokenOverride ?? options.jiraAccessToken ?? "jira-token";
419
+ },
420
+ set accessToken(value) {
421
+ jiraAccessTokenOverride = value;
422
+ },
423
+ get accountId() {
424
+ return jiraAccountIdOverride ?? options.jiraAccountId ?? FAKE_ACCOUNT_ID;
425
+ },
426
+ set accountId(value) {
427
+ jiraAccountIdOverride = value;
428
+ },
429
+ get cloudId() {
430
+ return jiraCloudIdOverride ?? options.jiraCloudId ?? "cloud-fake";
431
+ },
432
+ set cloudId(value) {
433
+ jiraCloudIdOverride = value;
434
+ },
435
+ get siteUrl() {
436
+ return jiraSiteUrl;
437
+ },
438
+ set siteUrl(value) {
439
+ jiraSiteUrl = value;
440
+ }
441
+ },
442
+ jiraRequests: [],
443
+ clockifyVerifiedUserId: options.clockifyVerifiedUserId ?? FAKE_USER_ID,
444
+ clockifyUserReadFailuresRemaining: 0,
445
+ clockifyEntryReadFailuresRemaining: 0,
446
+ clockifyEntriesReadFailuresRemaining: 0,
447
+ jiraWorklogs: [],
448
+ updatedClockifyEntries: [],
449
+ updatedJiraWorklogs: [],
450
+ attributorRequests: [],
451
+ attributorBatches: [],
452
+ describeRequests: [],
453
+ describeBatches: [],
454
+ maxAttributorInFlight: 0,
455
+ stdout: [],
456
+ stderr: [],
457
+ prompts: [],
458
+ transcripts: { ...options.transcripts },
459
+ transcriptReads: [],
460
+ // Ordinary fake scenarios model an installation whose earlier provider history was already
461
+ // reviewed. Pass an explicit empty map to exercise first-run migration and its fail-closed path.
462
+ writtenFiles: options.writtenFiles === undefined ?
463
+ {
464
+ [`${FAKE_HOME}/.jcf/source-consumption.v1.json`]: JSON.stringify({
465
+ version: 3,
466
+ reviewedWindows: [
467
+ {
468
+ provider: "clockify",
469
+ scope: JSON.stringify([
470
+ "clockify-v3",
471
+ options.clockifyAuth?.baseUrl ?? "https://api.clockify.me/api",
472
+ options.clockifyAuth?.workspaceId ?? FAKE_WORKSPACE_ID,
473
+ options.clockifyVerifiedUserId ?? FAKE_USER_ID
474
+ ]),
475
+ fromMs: 0,
476
+ toMs: 4_102_444_800_000
477
+ },
478
+ {
479
+ provider: "jira",
480
+ scope: JSON.stringify([options.jiraCloudId ?? "cloud-fake", options.jiraAccountId ?? FAKE_ACCOUNT_ID]),
481
+ fromMs: 0,
482
+ toMs: 4_102_444_800_000
483
+ }
484
+ ],
485
+ pending: [],
486
+ bindings: [],
487
+ observedUnbound: []
488
+ })
489
+ } :
490
+ { ...options.writtenFiles },
491
+ jiraLoggedIn: options.jiraLoggedIn ?? true,
492
+ runningTimer: options.runningTimer ?? null,
493
+ jiraLoginStateReadFailuresRemaining: 0,
494
+ jiraCurrentUserReadFailuresRemaining: 0,
495
+ jiraWorklogReadFailuresRemaining: 0,
496
+ jiraSearchFailuresRemaining: 0,
497
+ jiraSearchHiddenIssues: new Set(options.jiraSearchHiddenIssues),
498
+ setClockifyEntries: (entries) => {
499
+ clockifyLedger.splice(0, clockifyLedger.length, ...entries.map((entry, index) => makeTimeEntry(entry, `replaced-${index}`)));
500
+ },
501
+ setJiraWorklogs: (entries) => {
502
+ jiraLedger.clear();
503
+ for (const [key, worklogs] of Object.entries(entries)) {
504
+ jiraLedger.set(key, [...worklogs]);
505
+ for (const entry of worklogs) {
506
+ if (entry.id !== undefined)
507
+ issuedJiraIds.add(entry.id);
508
+ }
509
+ }
510
+ }
511
+ };
512
+ // Ledgers, not fixed fixtures: a write lands here and the next read sees it, which is what
513
+ // makes "run it twice and nothing is proposed the second time" a real assertion.
514
+ const clockifyLedger = (options.clockifyEntries ?? []).map((entry, index) => makeTimeEntry(entry, `existing-${index}`));
515
+ const jiraLedger = new Map(Object.entries(options.jiraWorklogs ?? {}).map(([key, worklogs]) => [key, [...worklogs]]));
516
+ const jiraLedgersByCloudId = new Map(Object.entries(options.jiraWorklogsByCloudId ?? {}).map(([cloudId, issues]) => [
517
+ cloudId,
518
+ new Map(Object.entries(issues).map(([key, worklogs]) => [key, [...worklogs]]))
519
+ ]));
520
+ const jiraLedgerForUrl = (url) => {
521
+ const cloudId = url.match(/\/ex\/jira\/([^/]+)\//u)?.[1];
522
+ return cloudId === undefined ? jiraLedger : jiraLedgersByCloudId.get(decodeURIComponent(cloudId)) ?? jiraLedger;
523
+ };
524
+ const issuedJiraIds = new Set([jiraLedger, ...jiraLedgersByCloudId.values()].flatMap((ledger) => [...ledger.values()].flatMap((entries) => entries.flatMap((entry) => entry.id === undefined ? [] : [entry.id]))));
525
+ const runningEntry = () => world.runningTimer === null ? null : makeTimeEntry(world.runningTimer, "running-1");
526
+ const clockify = {
527
+ getUser: () => Effect.succeed({
528
+ id: FAKE_USER_ID,
529
+ name: "Fake",
530
+ email: "fake@example.com",
531
+ activeWorkspace: FAKE_WORKSPACE_ID,
532
+ defaultWorkspace: FAKE_WORKSPACE_ID,
533
+ profilePicture: "",
534
+ status: "ACTIVE"
535
+ }),
536
+ getWorkspaces: () => Effect.succeed([{ id: FAKE_WORKSPACE_ID, name: "WS", imageUrl: "" }]),
537
+ getProjects: () => Effect.succeed(options.clockifyProjects ?? []),
538
+ getProjectByName: () => Effect.succeed(null),
539
+ // Paged when a test asks for it, so "reads only the first page" is observable rather than
540
+ // assumed. `getRunningTimer` has its own path, so the running entry rides along on page one.
541
+ getTimeEntries: (_ws, _user, params) => {
542
+ if (world.clockifyEntriesReadFailuresRemaining > 0) {
543
+ world.clockifyEntriesReadFailuresRemaining--;
544
+ return Effect.fail(new HttpClientError.HttpClientError({
545
+ reason: new HttpClientError.TransportError({
546
+ request: HttpClientRequest.get("https://fake.clockify/time-entries"),
547
+ description: "Clockify entries are unavailable"
548
+ })
549
+ }));
550
+ }
551
+ const running = runningEntry();
552
+ const all = running === null ? [...clockifyLedger] : [...clockifyLedger, running];
553
+ const page = params?.page ?? 1;
554
+ const cap = options.clockifyPageSize;
555
+ // One page holds everything, and the page after it is empty — a paginating caller has to be
556
+ // able to reach the end. Returning `all` for every page would model a server that never ends.
557
+ if (cap === undefined)
558
+ return Effect.succeed(page === 1 ? all : []);
559
+ // The smaller of the two, because Clockify may serve fewer than it was asked for — which is
560
+ // the case a caller that stops at the first short page gets wrong.
561
+ const size = Math.min(params?.pageSize ?? cap, cap);
562
+ return Effect.succeed(all.slice((page - 1) * size, page * size));
563
+ },
564
+ getRunningTimer: () => options.clockifyRunningTimerReadFails === true
565
+ ? Effect.fail(new HttpClientError.HttpClientError({
566
+ reason: new HttpClientError.TransportError({
567
+ request: HttpClientRequest.get("https://api.clockify.me/running-timer"),
568
+ description: "Clockify running timer is unavailable"
569
+ })
570
+ }))
571
+ : Effect.sync(runningEntry),
572
+ getTimeEntry: (_ws, id) => Effect.suspend(() => {
573
+ if (world.clockifyEntryReadFailuresRemaining > 0) {
574
+ world.clockifyEntryReadFailuresRemaining--;
575
+ return Effect.fail(new HttpClientError.HttpClientError({
576
+ reason: new HttpClientError.TransportError({
577
+ request: HttpClientRequest.get(`https://fake.clockify/entries/${id}`),
578
+ description: "Clockify entry read is unavailable"
579
+ })
580
+ }));
581
+ }
582
+ const entry = clockifyLedger.find((entry) => entry.id === id);
583
+ if (entry !== undefined)
584
+ return Effect.succeed(entry);
585
+ const request = HttpClientRequest.get(`https://fake.clockify/entries/${id}`);
586
+ return Effect.fail(new HttpClientError.HttpClientError({
587
+ reason: new HttpClientError.StatusCodeError({
588
+ request,
589
+ response: HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))
590
+ })
591
+ }));
592
+ }),
593
+ getTags: () => Effect.succeed(options.clockifyTags ?? []),
594
+ createTag: (_ws, name) => Effect.succeed({ id: `tag-${name}`, name, workspaceId: FAKE_WORKSPACE_ID, archived: false }),
595
+ findOrCreateTag: (_ws, name) => Effect.succeed({ id: `tag-${name}`, name, workspaceId: FAKE_WORKSPACE_ID, archived: false }),
596
+ createTimeEntry: (_ws, params) => Effect.gen(function* () {
597
+ if (options.beforeClockifyWrite !== undefined)
598
+ yield* options.beforeClockifyWrite(world);
599
+ if (options.clockifyWritesFail === true) {
600
+ return yield* new HttpClientError.HttpClientError({
601
+ reason: new HttpClientError.TransportError({
602
+ request: HttpClientRequest.post("https://api.clockify.me/time-entries"),
603
+ description: "Clockify refused the entry"
604
+ })
605
+ });
606
+ }
607
+ return yield* Effect.sync(() => {
608
+ world.createdClockifyEntries.push({
609
+ description: params.description,
610
+ start: params.start,
611
+ end: params.end,
612
+ billable: params.billable
613
+ });
614
+ const entry = makeTimeEntry({ description: params.description, start: params.start, end: params.end }, `created-${clockifyLedger.length}`);
615
+ clockifyLedger.push(entry);
616
+ options.afterClockifyWrite?.(world);
617
+ return entry;
618
+ });
619
+ }),
620
+ updateTimeEntry: (_ws, id, params) => Effect.sync(() => {
621
+ const index = clockifyLedger.findIndex((entry) => entry.id === id);
622
+ const existing = clockifyLedger[index];
623
+ const saved = makeTimeEntry({
624
+ ...existing,
625
+ ...params,
626
+ description: params.description ?? existing?.description ?? "",
627
+ start: params.start
628
+ }, id);
629
+ world.updatedClockifyEntries.push({ id, payload: params });
630
+ if (index >= 0)
631
+ clockifyLedger[index] = saved;
632
+ return saved;
633
+ }),
634
+ deleteTimeEntry: () => Effect.void,
635
+ stopTimer: (_ws, _user, params) => Effect.succeed(makeTimeEntry({ description: "", start: "", end: params.end }, "stopped-1"))
636
+ };
637
+ // Read through the world, not captured, so one world can model logging back in between two runs —
638
+ // which is exactly the flow the "Jira refused, go and log in, restart" message asks the user for.
639
+ const loggedIn = () => world.jiraLoggedIn;
640
+ const jsonResponse = (request, status, body) => HttpClientResponse.fromWeb(request, new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }));
641
+ const bodyFailureResponse = (request, status) => HttpClientResponse.fromWeb(request, new Response(new ReadableStream({ start: (controller) => controller.error("synthetic body read failure") }), {
642
+ status,
643
+ headers: { "content-type": "application/json" }
644
+ }));
645
+ const requestPayload = (body) => {
646
+ if (body._tag !== "Uint8Array" || !("body" in body) || !Predicate.isUint8Array(body.body))
647
+ return {};
648
+ return Option.getOrElse(decodeWorklogPayload(new TextDecoder().decode(body.body)), () => ({}));
649
+ };
650
+ /**
651
+ * The single Jira boundary. Both the generated `JiraApiClient` (reads) and TimerService's raw
652
+ * worklog POST (writes) go through it, so reads exercise the real client's decoding rather than
653
+ * a hand-shaped stub of a very large generated interface.
654
+ */
655
+ const httpClientLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make((request) => Effect.sync(() => {
656
+ const requestJiraLedger = jiraLedgerForUrl(request.url);
657
+ if (request.url.includes("atlassian")) {
658
+ world.jiraRequests.push({ method: request.method, url: request.url });
659
+ }
660
+ if (request.url.includes("/v1/user") && request.method === "GET") {
661
+ world.clockifyRequests.push({ method: request.method, url: request.url });
662
+ if (options.clockifyUserReadFails === true) {
663
+ return jsonResponse(request, 503, { message: "synthetic verification failure" });
664
+ }
665
+ if (world.clockifyUserReadFailuresRemaining > 0) {
666
+ world.clockifyUserReadFailuresRemaining--;
667
+ return jsonResponse(request, 503, { message: "synthetic verification failure" });
668
+ }
669
+ return jsonResponse(request, 200, {
670
+ id: world.clockifyVerifiedUserId,
671
+ name: "Fake",
672
+ email: "fake@example.com",
673
+ profilePicture: "",
674
+ status: "ACTIVE"
675
+ });
676
+ }
677
+ const clockifyEntries = request.url.match(/\/v1\/workspaces\/([^/]+)\/user\/([^/]+)\/time-entries/);
678
+ if (clockifyEntries !== null && request.method === "GET") {
679
+ world.clockifyRequests.push({ method: request.method, url: request.url });
680
+ if (world.clockifyEntriesReadFailuresRemaining > 0) {
681
+ world.clockifyEntriesReadFailuresRemaining--;
682
+ return jsonResponse(request, 503, { message: "synthetic entry read failure" });
683
+ }
684
+ if (options.clockifyRunningTimerReadFails === true &&
685
+ request.urlParams.params.some(([name]) => name === "in-progress")) {
686
+ return jsonResponse(request, 503, { message: "synthetic timer read failure" });
687
+ }
688
+ const page = Number(request.urlParams.params.find(([name]) => name === "page")?.[1] ?? 1);
689
+ const cap = options.clockifyPageSize;
690
+ const size = Math.min(Number(request.urlParams.params.find(([name]) => name === "page-size")?.[1] ?? 200), cap ?? 200);
691
+ const running = runningEntry();
692
+ const hiddenIds = new Set(options.clockifyListHiddenIds);
693
+ const visibleLedger = clockifyLedger.filter((entry) => entry.id === undefined || !hiddenIds.has(entry.id));
694
+ const all = request.urlParams.params.some(([name]) => name === "in-progress")
695
+ ? running === null ? [] : [running]
696
+ : running === null
697
+ ? visibleLedger
698
+ : [...visibleLedger, running];
699
+ const entries = all.slice((page - 1) * size, page * size);
700
+ options.afterClockifyEntriesRead?.(page, world);
701
+ return jsonResponse(request, 200, entries);
702
+ }
703
+ const exactClockifyEntry = request.url.match(/\/v1\/workspaces\/([^/]+)\/time-entries\/([^/?]+)/);
704
+ if (exactClockifyEntry !== null && request.method === "GET") {
705
+ world.clockifyRequests.push({ method: request.method, url: request.url });
706
+ const fault = options.clockifyExactEntryReadFault;
707
+ if (fault === "malformed-404") {
708
+ return HttpClientResponse.fromWeb(request, new Response("{", {
709
+ status: 404,
710
+ headers: { "content-type": "application/json" }
711
+ }));
712
+ }
713
+ if (fault === "body-failure-404")
714
+ return bodyFailureResponse(request, 404);
715
+ if (fault !== undefined)
716
+ return jsonResponse(request, Number(fault), { message: "synthetic read failure" });
717
+ const entry = clockifyLedger.find((candidate) => candidate.id === exactClockifyEntry[2]);
718
+ return entry === undefined
719
+ ? jsonResponse(request, 404, { message: "synthetic time entry not found" })
720
+ : jsonResponse(request, 200, entry);
721
+ }
722
+ if (request.url.match(/\/v1\/workspaces\/[^/]+\/time-entries$/) !== null && request.method === "POST") {
723
+ world.clockifyRequests.push({ method: request.method, url: request.url });
724
+ if (options.clockifyWritesFail === true)
725
+ return jsonResponse(request, 503, { message: "synthetic failure" });
726
+ const body = request.body;
727
+ if (body._tag !== "Uint8Array" || !("body" in body) || !Predicate.isUint8Array(body.body)) {
728
+ return jsonResponse(request, 400, { message: "missing body" });
729
+ }
730
+ const payload = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({
731
+ description: Schema.String,
732
+ start: Schema.String,
733
+ end: Schema.String,
734
+ billable: Schema.Boolean
735
+ })))(new TextDecoder().decode(body.body));
736
+ if (Option.isNone(payload))
737
+ return jsonResponse(request, 400, { message: "invalid body" });
738
+ const entry = makeTimeEntry(payload.value, `created-${clockifyLedger.length}`);
739
+ clockifyLedger.push(entry);
740
+ world.createdClockifyEntries.push(payload.value);
741
+ options.afterClockifyWrite?.(world);
742
+ return jsonResponse(request, 201, entry);
743
+ }
744
+ const exactWorklog = request.url.match(/issue\/([^/]+)\/worklog\/([^/?]+)/);
745
+ if (exactWorklog !== null) {
746
+ const issueKey = exactWorklog[1] ?? "";
747
+ const id = exactWorklog[2] ?? "";
748
+ const entries = requestJiraLedger.get(issueKey) ?? [];
749
+ const index = entries.findIndex((entry, index) => (entry.id ?? `wl-${index}`) === id);
750
+ const entry = entries[index];
751
+ const fault = options.jiraExactWorklogReadFault;
752
+ if (fault === "malformed-404") {
753
+ return HttpClientResponse.fromWeb(request, new Response("{", {
754
+ status: 404,
755
+ headers: { "content-type": "application/json" }
756
+ }));
757
+ }
758
+ if (fault === "body-failure-404")
759
+ return bodyFailureResponse(request, 404);
760
+ if (fault === "permission-404") {
761
+ return jsonResponse(request, 404, { errorMessages: ["synthetic visibility denied"] });
762
+ }
763
+ if (fault === "disabled-404") {
764
+ return jsonResponse(request, 404, { errorMessages: ["synthetic time tracking unavailable"] });
765
+ }
766
+ if (fault === "null-404")
767
+ return jsonResponse(request, 404, null);
768
+ if (fault === "empty-404")
769
+ return jsonResponse(request, 404, {});
770
+ if (fault !== undefined)
771
+ return jsonResponse(request, Number(fault), { errorMessages: ["synthetic failure"] });
772
+ if (entry === undefined)
773
+ return jsonResponse(request, 404, { errorMessages: ["Worklog not found"] });
774
+ if (request.method === "PUT") {
775
+ const payload = requestPayload(request.body);
776
+ entries[index] = {
777
+ ...entry,
778
+ started: payload.started ?? entry.started,
779
+ timeSpentSeconds: payload.timeSpentSeconds ?? entry.timeSpentSeconds,
780
+ ...(payload.comment !== undefined && { comment: payload.comment })
781
+ };
782
+ world.updatedJiraWorklogs.push({
783
+ issueKey,
784
+ id,
785
+ payload,
786
+ adjustEstimate: [...request.urlParams].find(([key]) => key === "adjustEstimate")?.[1]
787
+ });
788
+ }
789
+ return jsonResponse(request, 200, {
790
+ author: { accountId: options.jiraAccountId ?? FAKE_ACCOUNT_ID },
791
+ ...entries[index],
792
+ id: options.jiraReadOmitsId === true ? undefined : options.jiraExactWorklogReturnedId ?? id
793
+ });
794
+ }
795
+ const worklogMatch = request.url.match(/issue\/([^/]+)\/worklog/);
796
+ if (request.method === "POST" && worklogMatch !== null) {
797
+ const payload = requestPayload(request.body);
798
+ const issueKey = worklogMatch[1] ?? "unknown";
799
+ let nextId = 0;
800
+ while (issuedJiraIds.has(`wl-created-${nextId}`))
801
+ nextId++;
802
+ const entryId = options.jiraPostedWorklog?.id ?? `wl-created-${nextId}`;
803
+ if (issuedJiraIds.has(entryId)) {
804
+ return jsonResponse(request, 409, { errorMessages: ["Synthetic worklog ID already exists"] });
805
+ }
806
+ issuedJiraIds.add(entryId);
807
+ const started = payload.started ?? "";
808
+ const timeSpentSeconds = payload.timeSpentSeconds ?? 0;
809
+ world.jiraWorklogs.push({ issueKey, started, timeSpentSeconds, comment: commentText(payload.comment) });
810
+ requestJiraLedger.set(issueKey, [...(requestJiraLedger.get(issueKey) ?? []), {
811
+ id: entryId,
812
+ ...(options.jiraPostedWorklog !== undefined && {
813
+ author: { accountId: options.jiraPostedWorklog.accountId },
814
+ ...(options.jiraPostedWorklog.created !== undefined && { created: options.jiraPostedWorklog.created })
815
+ }),
816
+ started,
817
+ timeSpentSeconds,
818
+ ...(payload.comment !== undefined && { comment: payload.comment })
819
+ }]);
820
+ return jsonResponse(request, 201, options.jiraPostOmitsId === true ? {} : { id: entryId });
821
+ }
822
+ if (worklogMatch !== null) {
823
+ const requestedStartAt = Number(request.urlParams.params.find(([name]) => name === "startAt")?.[1] ?? 0);
824
+ if (requestedStartAt > 0 && options.jiraWorklogPageFault === "failure") {
825
+ return jsonResponse(request, 500, { errorMessages: ["Later worklog page unavailable"] });
826
+ }
827
+ if (options.jiraWorklogReadFails === true || world.jiraWorklogReadFailuresRemaining > 0) {
828
+ if (world.jiraWorklogReadFailuresRemaining > 0)
829
+ world.jiraWorklogReadFailuresRemaining--;
830
+ return jsonResponse(request, 500, { errorMessages: ["Jira is having a moment"] });
831
+ }
832
+ const numberParam = (name) => {
833
+ const raw = request.urlParams.params.find(([candidate]) => candidate === name)?.[1];
834
+ if (raw === undefined)
835
+ return undefined;
836
+ const parsed = Number(raw);
837
+ return Number.isFinite(parsed) ? parsed : undefined;
838
+ };
839
+ const startedAfter = numberParam("startedAfter");
840
+ const startedBefore = numberParam("startedBefore");
841
+ const startAt = numberParam("startAt") ?? 0;
842
+ const entries = (requestJiraLedger.get(worklogMatch[1] ?? "") ?? []).filter((worklog) => {
843
+ const startedMs = new Date(worklog.started).getTime();
844
+ return (startedAfter === undefined || startedMs >= startedAfter) &&
845
+ (startedBefore === undefined || startedMs < startedBefore);
846
+ });
847
+ const pageSize = options.jiraWorklogPageSize ?? entries.length;
848
+ const page = entries.slice(startAt, startAt + pageSize);
849
+ if (startAt > 0 && options.jiraWorklogPageFault === "missing") {
850
+ return jsonResponse(request, 200, { startAt, maxResults: pageSize, total: entries.length });
851
+ }
852
+ const response = {
853
+ startAt: startAt > 0 && options.jiraWorklogPageFault === "stalled" ? 0 : startAt,
854
+ maxResults: pageSize,
855
+ total: entries.length + (startAt > 0 && options.jiraWorklogPageFault === "inconsistent" ? 1 : 0),
856
+ worklogs: page.map((worklog, index) => {
857
+ const id = worklog.id ?? `wl-${startAt + index}`;
858
+ issuedJiraIds.add(id);
859
+ return {
860
+ id,
861
+ author: { accountId: options.jiraAccountId ?? FAKE_ACCOUNT_ID },
862
+ ...worklog,
863
+ ...(startAt > 0 && options.jiraWorklogPageFault === "missing-duration" && {
864
+ timeSpentSeconds: undefined
865
+ }),
866
+ ...(startAt > 0 && options.jiraWorklogPageFault === "missing-started" && { started: undefined }),
867
+ ...(startAt > 0 && options.jiraWorklogPageFault === "missing-author" && { author: undefined }),
868
+ ...(startAt > 0 && options.jiraWorklogPageFault === "missing-id" && { id: undefined }),
869
+ ...(startAt > 0 && options.jiraWorklogPageFault === "empty-id" && { id: "" }),
870
+ ...(startAt > 0 && options.jiraWorklogPageFault === "spaces-id" && { id: " " }),
871
+ ...(startAt > 0 && options.jiraWorklogPageFault === "tab-newline-id" && { id: "\t\n" }),
872
+ ...(options.jiraReadOmitsId === true && { id: undefined })
873
+ };
874
+ })
875
+ };
876
+ options.afterJiraWorklogsRead?.(worklogMatch[1] ?? "", startAt, world);
877
+ return jsonResponse(request, 200, response);
878
+ }
879
+ const issueMatch = request.url.match(/issue\/([^/?]+)(?:\?|$)/);
880
+ if (request.method === "GET" && issueMatch !== null) {
881
+ const key = issueMatch[1] ?? "";
882
+ const summary = (options.issueSummaries ?? {})[key];
883
+ if (summary === undefined) {
884
+ return jsonResponse(request, 404, { errorMessages: ["Issue does not exist"] });
885
+ }
886
+ const assignee = (options.issueAssignees ?? {})[key];
887
+ const displayName = (options.issueAssigneeDisplayNames ?? {})[key];
888
+ return jsonResponse(request, 200, {
889
+ id: "10000",
890
+ key,
891
+ fields: {
892
+ summary,
893
+ ...((assignee !== undefined) && {
894
+ assignee: assignee === null ? null : displayName === null
895
+ ? {}
896
+ : { displayName: displayName ?? assignee }
897
+ })
898
+ }
899
+ });
900
+ }
901
+ if (request.url.includes("/myself")) {
902
+ if (options.jiraCurrentUserFails === true || world.jiraCurrentUserReadFailuresRemaining > 0) {
903
+ if (world.jiraCurrentUserReadFailuresRemaining > 0)
904
+ world.jiraCurrentUserReadFailuresRemaining--;
905
+ return jsonResponse(request, 500, { errorMessages: ["Current user unavailable"] });
906
+ }
907
+ return jsonResponse(request, 200, {
908
+ accountId: world.jiraAuth.accountId,
909
+ displayName: "Fake User"
910
+ });
911
+ }
912
+ if (request.url.includes("/search/jql")) {
913
+ if (world.jiraSearchFailuresRemaining > 0) {
914
+ world.jiraSearchFailuresRemaining--;
915
+ return jsonResponse(request, 500, { errorMessages: ["Jira search is unavailable"] });
916
+ }
917
+ // A `key in (…)` search answers about exactly those keys. Any other JQL keeps the old
918
+ // behaviour: whatever the ledger happens to hold. The query lives in `urlParams` rather
919
+ // than in `url` — the client keeps them apart until the request is actually sent.
920
+ const jql = request.urlParams.params.find(([name]) => name === "jql")?.[1];
921
+ const clause = jql === undefined ? null : jql.match(/key in \(([^)]*)\)/);
922
+ if (clause !== null) {
923
+ const keys = (clause[1] ?? "").split(",").map((key) => key.trim()).filter((key) => key !== "");
924
+ const summaries = options.issueSummaries ?? {};
925
+ const assignees = options.issueAssignees ?? {};
926
+ const displayNames = options.issueAssigneeDisplayNames ?? {};
927
+ return jsonResponse(request, 200, {
928
+ // Only the keys this fake Jira knows about, so a test can model an issue that is gone.
929
+ issues: keys.filter((key) => summaries[key] !== undefined).map((key, index) => ({
930
+ id: String(index),
931
+ key,
932
+ fields: {
933
+ summary: summaries[key],
934
+ ...((assignees[key] !== undefined) && {
935
+ assignee: assignees[key] === null
936
+ ? null
937
+ : displayNames[key] === null
938
+ ? { accountId: assignees[key] }
939
+ : { accountId: assignees[key], displayName: displayNames[key] ?? assignees[key] }
940
+ })
941
+ }
942
+ }))
943
+ });
944
+ }
945
+ return jsonResponse(request, 200, {
946
+ ...(options.jiraSearchClaimsMoreWithoutToken === true && { isLast: false }),
947
+ issues: [...requestJiraLedger.keys()]
948
+ .filter((key) => !world.jiraSearchHiddenIssues.has(key))
949
+ .map((key, index) => ({ id: String(index), key }))
950
+ });
951
+ }
952
+ return jsonResponse(request, 404, { errorMessages: [`unrouted: ${request.method} ${request.url}`] });
953
+ })));
954
+ let attributorInFlight = 0;
955
+ const attributorLayer = Layer.succeed(SessionAttributor, {
956
+ attribute: (requests) => Effect.gen(function* () {
957
+ for (const request of requests) {
958
+ world.attributorRequests.push({ candidateKeys: request.candidateKeys, digest: request.digest });
959
+ }
960
+ world.attributorBatches.push(requests.map((request) => request.sessionId));
961
+ attributorInFlight += 1;
962
+ world.maxAttributorInFlight = Math.max(world.maxAttributorInFlight, attributorInFlight);
963
+ // Yield so sibling calls get a chance to start. A real call is a CLI process taking tens of
964
+ // seconds, so overlapping is the whole point; without a suspension point here every fake
965
+ // call would complete before the next began and the high-water mark could not tell a
966
+ // concurrent implementation from a sequential one.
967
+ //
968
+ // Cooperative yielding, not `Effect.sleep`: `it.effect` runs on a TestClock that nothing
969
+ // here advances, so a sleep would never return.
970
+ yield* Effect.yieldNow;
971
+ yield* Effect.yieldNow;
972
+ attributorInFlight -= 1;
973
+ const answers = [];
974
+ for (const request of requests) {
975
+ const answer = options.attributor?.(request) ?? { _tag: "None" };
976
+ // A single "fail" fails the whole call, which is what a real timeout does to a batch.
977
+ if (answer === "fail") {
978
+ return yield* new SessionAttributorError({ message: "claude executable not found" });
979
+ }
980
+ answers.push({ sessionId: request.sessionId, choice: answer });
981
+ }
982
+ return answers;
983
+ }),
984
+ describe: (requests) => Effect.gen(function* () {
985
+ options.beforeDescribe?.(world);
986
+ for (const request of requests) {
987
+ world.describeRequests.push({
988
+ ticketKey: request.ticketKey,
989
+ summary: request.summary,
990
+ digest: request.digest
991
+ });
992
+ }
993
+ world.describeBatches.push(requests.map((request) => request.ticketKey));
994
+ const answers = [];
995
+ for (const request of requests) {
996
+ const answer = options.describer?.(request) ?? null;
997
+ // As with attribution, one "fail" fails the whole call — a timeout costs a batch.
998
+ if (answer === "fail") {
999
+ return yield* new SessionAttributorError({ message: "claude executable not found" });
1000
+ }
1001
+ answers.push({ id: request.id, note: answer });
1002
+ }
1003
+ return answers;
1004
+ })
1005
+ });
1006
+ /**
1007
+ * A spawner that refuses to spawn.
1008
+ *
1009
+ * The Coding Agent is faked at the `SessionAttributor` seam, so nothing in these tests should
1010
+ * reach a child process — but the layer was previously left open, which typechecking would have
1011
+ * caught had this package's tests been in a tsconfig. Failing loudly beats a real `claude` process
1012
+ * starting during a test run.
1013
+ */
1014
+ const SpawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.die("the fake headless layer spawns no child processes")));
1015
+ const HomeLayer = Layer.succeed(HomeDirectory, { path: FAKE_HOME });
1016
+ // A ledger rather than a constant: `set` is how the config commands do their whole job, so a
1017
+ // fake that discards it can only ever assert what was printed, not what was stored.
1018
+ let config = { ...defaultConfig, ...options.config };
1019
+ const ConfigLayer = Layer.succeed(ConfigService, {
1020
+ get: Effect.gen(function* () {
1021
+ const snapshot = config;
1022
+ if (options.delayConfigReads === true)
1023
+ yield* Effect.sleep("10 millis");
1024
+ return snapshot;
1025
+ }),
1026
+ set: (patch) => Effect.sync(() => {
1027
+ config = { ...config, ...patch };
1028
+ }),
1029
+ configDir: Effect.succeed(`${FAKE_HOME}/.jcf`)
1030
+ });
1031
+ const ClockifyAuthLayer = Layer.succeed(ClockifyAuth, {
1032
+ getConfig: Effect.sync(() => ({
1033
+ ...world.clockifyAuth,
1034
+ apiKey: Redacted.make(world.clockifyAuth.apiKey)
1035
+ })),
1036
+ save: () => Effect.void,
1037
+ isConfigured: Effect.succeed(true)
1038
+ });
1039
+ const JiraAuthLayer = Layer.succeed(JiraAuth, {
1040
+ configure: () => Effect.void,
1041
+ isConfigured: () => Effect.succeed(loggedIn()),
1042
+ login: () => Effect.void,
1043
+ logout: () => Effect.void,
1044
+ getAccessToken: () => Effect.succeed(Redacted.make(loggedIn() ? world.jiraAuth.accessToken : "")),
1045
+ getCloudId: () => Effect.succeed(world.jiraAuth.cloudId),
1046
+ getSiteUrl: () => Effect.succeed(world.jiraAuth.siteUrl),
1047
+ getCurrentUser: () => Effect.succeed(!loggedIn() || options.jiraCachedUserMissing === true ? null : {
1048
+ account_id: world.jiraAuth.accountId,
1049
+ name: "Fake User",
1050
+ email: "fake@example.com"
1051
+ }),
1052
+ getActiveProfile: () => Effect.succeed(loggedIn() ?
1053
+ {
1054
+ id: "synthetic-profile",
1055
+ name: "Synthetic",
1056
+ token: {
1057
+ access_token: world.jiraAuth.accessToken,
1058
+ refresh_token: "synthetic-refresh",
1059
+ expires_at: 4_102_444_800_000,
1060
+ scope: "write:jira-work",
1061
+ cloud_id: world.jiraAuth.cloudId,
1062
+ site_url: world.jiraAuth.siteUrl,
1063
+ user: {
1064
+ account_id: world.jiraAuth.accountId,
1065
+ name: "Fake User",
1066
+ email: "fake@example.com"
1067
+ }
1068
+ },
1069
+ created_at: "2026-01-01T00:00:00.000Z",
1070
+ updated_at: "2026-01-01T00:00:00.000Z"
1071
+ } :
1072
+ null),
1073
+ listProfiles: () => Effect.succeed([]),
1074
+ switchProfile: () => Effect.succeed(null),
1075
+ removeProfile: () => Effect.succeed(null),
1076
+ isLoggedIn: () => {
1077
+ if (world.jiraLoginStateReadFailuresRemaining === 0)
1078
+ return Effect.succeed(loggedIn());
1079
+ world.jiraLoginStateReadFailuresRemaining--;
1080
+ return Effect.fail(systemError({
1081
+ _tag: "PermissionDenied",
1082
+ module: "FileSystem",
1083
+ method: "readFileString",
1084
+ description: "synthetic Jira login state read failure",
1085
+ pathOrDescriptor: `${FAKE_HOME}/.jira/config.json`
1086
+ }));
1087
+ }
1088
+ });
1089
+ const StateWriterLayer = Layer.succeed(StateWriter, {
1090
+ write: () => Effect.void,
1091
+ read: Effect.succeed({
1092
+ active: false,
1093
+ ticketKey: null,
1094
+ summary: null,
1095
+ project: null,
1096
+ startedAt: null,
1097
+ startedAt_unix: null,
1098
+ elapsed: 0,
1099
+ clockifyEntryId: null
1100
+ }),
1101
+ clear: Effect.void
1102
+ });
1103
+ const ClockifyLayer = Layer.succeed(ClockifyApiClient, clockify);
1104
+ const resolvedJiraCredential = () => ({
1105
+ type: "oauth2",
1106
+ accessToken: Redacted.make(world.jiraAuth.accessToken),
1107
+ cloudId: world.jiraAuth.cloudId
1108
+ });
1109
+ const jiraConfig = options.beforeJiraAuthResolution === undefined
1110
+ ? {
1111
+ baseUrl: "https://fake.atlassian.net",
1112
+ auth: { type: "basic", email: "fake@example.com", apiToken: Redacted.make("token") }
1113
+ }
1114
+ : {
1115
+ baseUrl: "",
1116
+ auth: resolvedJiraCredential(),
1117
+ resolveAuth: Effect.sync(() => {
1118
+ options.beforeJiraAuthResolution?.(world);
1119
+ return resolvedJiraCredential();
1120
+ })
1121
+ };
1122
+ const JiraConfigLayer = Layer.succeed(JiraApiConfig, jiraConfig);
1123
+ const FileSystemLayer = fakeFileSystemLayer(world.transcripts, world.transcriptReads, world.writtenFiles, options.unwritablePaths ?? [], options.unreadableTranscripts ?? [], options.afterFileWrite ?? (() => Effect.void), options.pidNamespaceInode);
1124
+ const Externals = Layer.mergeAll(ClockifyLayer, ClockifyAuthLayer, JiraAuthLayer, ConfigLayer, StateWriterLayer, HomeLayer, FileSystemLayer, NodePath.layer,
1125
+ // The CLI runner writes help and error text through Stdio; the command's own output goes
1126
+ // through the captured Console instead.
1127
+ Stdio.layerTest({}), httpClientLayer, attributorLayer, fakeTerminalLayer({
1128
+ keep: options.keep,
1129
+ promptInputs: options.promptInputs,
1130
+ columns: options.columns ?? DEFAULT_COLUMNS,
1131
+ beforePromptInput: options.beforePromptInput
1132
+ }, world), captureConsoleLayer(world, options.afterConsoleLog), SpawnerLayer,
1133
+ // Mirrors HeadlessLayer: warnings must land on stderr, or they corrupt --json output.
1134
+ LogToStderrLive);
1135
+ // The real generated client over the routed fake HttpClient, so read decoding is exercised.
1136
+ const JiraLayer = JiraApiClient.layer.pipe(Layer.provide(JiraConfigLayer), Layer.provide(httpClientLayer));
1137
+ const TimerLive = timerServiceLayer.pipe(Layer.provide(Externals));
1138
+ const SourceLedgerLive = sourceLedgerLayer.pipe(Layer.provide(Externals));
1139
+ const ReaderLive = agentSessionReaderLayer.pipe(Layer.provide(Externals));
1140
+ const ReconcileLive = reconcileServiceLayer.pipe(Layer.provide(Externals), Layer.provide(JiraLayer), Layer.provide(TimerLive), Layer.provide(ReaderLive), Layer.provide(SourceLedgerLive));
1141
+ const SavedLive = savedEntriesLayer.pipe(Layer.provide(Externals), Layer.provide(JiraLayer));
1142
+ const TicketLive = ticketServiceLayer.pipe(Layer.provide(Externals), Layer.provide(JiraLayer));
1143
+ const IssueFactsLive = issueFactsLayer.pipe(Layer.provide(Externals), Layer.provide(JiraLayer));
1144
+ // `ReconcileService` is built from the timer, the reader and the Jira client, so those cannot sit
1145
+ // beside it in a `mergeAll` — that builds its members in parallel. They go underneath.
1146
+ const layer = Layer.mergeAll(ReconcileLive, TicketLive, IssueFactsLive, SavedLive).pipe(Layer.provideMerge(Layer.mergeAll(TimerLive, ReaderLive, JiraLayer, SourceLedgerLive)), Layer.provideMerge(Externals));
1147
+ return { layer, sourceLedgerLayer: SourceLedgerLive, world };
1148
+ };
1149
+ //# sourceMappingURL=fakeHeadless.js.map