@knpkv/jira-clockify 1.4.1 → 1.6.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 (147) hide show
  1. package/README.md +105 -22
  2. package/dist/package.json +1 -1
  3. package/dist/src/agent/schedule.d.ts +6 -5
  4. package/dist/src/agent/schedule.d.ts.map +1 -1
  5. package/dist/src/agent/schedule.js +117 -102
  6. package/dist/src/agent/schedule.js.map +1 -1
  7. package/dist/src/agent/sessions.d.ts +27 -33
  8. package/dist/src/agent/sessions.d.ts.map +1 -1
  9. package/dist/src/agent/sessions.js +121 -83
  10. package/dist/src/agent/sessions.js.map +1 -1
  11. package/dist/src/agent/writePlanning.d.ts +2 -0
  12. package/dist/src/agent/writePlanning.d.ts.map +1 -1
  13. package/dist/src/agent/writePlanning.js +4 -1
  14. package/dist/src/agent/writePlanning.js.map +1 -1
  15. package/dist/src/bin.js +16 -5
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/cli/CommandFailed.d.ts +27 -0
  18. package/dist/src/cli/CommandFailed.d.ts.map +1 -0
  19. package/dist/src/cli/CommandFailed.js +25 -0
  20. package/dist/src/cli/CommandFailed.js.map +1 -0
  21. package/dist/src/cli/agentWrite.d.ts.map +1 -1
  22. package/dist/src/cli/agentWrite.js +12 -5
  23. package/dist/src/cli/agentWrite.js.map +1 -1
  24. package/dist/src/cli/auth.d.ts +59 -2
  25. package/dist/src/cli/auth.d.ts.map +1 -1
  26. package/dist/src/cli/auth.js +151 -106
  27. package/dist/src/cli/auth.js.map +1 -1
  28. package/dist/src/cli/config.d.ts +4 -3
  29. package/dist/src/cli/config.d.ts.map +1 -1
  30. package/dist/src/cli/config.js +73 -31
  31. package/dist/src/cli/config.js.map +1 -1
  32. package/dist/src/cli/fetchTicket.d.ts +3 -3
  33. package/dist/src/cli/fetchTicket.d.ts.map +1 -1
  34. package/dist/src/cli/fetchTicket.js +7 -7
  35. package/dist/src/cli/fetchTicket.js.map +1 -1
  36. package/dist/src/cli/layers.d.ts +6 -5
  37. package/dist/src/cli/layers.d.ts.map +1 -1
  38. package/dist/src/cli/layers.js +16 -15
  39. package/dist/src/cli/layers.js.map +1 -1
  40. package/dist/src/cli/list.d.ts +3 -2
  41. package/dist/src/cli/list.d.ts.map +1 -1
  42. package/dist/src/cli/list.js +11 -5
  43. package/dist/src/cli/list.js.map +1 -1
  44. package/dist/src/cli/reconcile.d.ts +18 -3
  45. package/dist/src/cli/reconcile.d.ts.map +1 -1
  46. package/dist/src/cli/reconcile.js +73 -23
  47. package/dist/src/cli/reconcile.js.map +1 -1
  48. package/dist/src/cli/root.d.ts.map +1 -1
  49. package/dist/src/cli/root.js +3 -3
  50. package/dist/src/cli/root.js.map +1 -1
  51. package/dist/src/cli/setup.d.ts +14 -4
  52. package/dist/src/cli/setup.d.ts.map +1 -1
  53. package/dist/src/cli/setup.js +45 -94
  54. package/dist/src/cli/setup.js.map +1 -1
  55. package/dist/src/cli/timer/discard.d.ts +1 -1
  56. package/dist/src/cli/timer/discard.d.ts.map +1 -1
  57. package/dist/src/cli/timer/discard.js +2 -1
  58. package/dist/src/cli/timer/discard.js.map +1 -1
  59. package/dist/src/cli/timer/log.d.ts +1 -1
  60. package/dist/src/cli/timer/log.d.ts.map +1 -1
  61. package/dist/src/cli/timer/log.js +2 -1
  62. package/dist/src/cli/timer/log.js.map +1 -1
  63. package/dist/src/cli/timer/start.d.ts +2 -1
  64. package/dist/src/cli/timer/start.d.ts.map +1 -1
  65. package/dist/src/cli/timer/start.js +6 -8
  66. package/dist/src/cli/timer/start.js.map +1 -1
  67. package/dist/src/cli/timer/stop.d.ts +1 -1
  68. package/dist/src/cli/timer/stop.d.ts.map +1 -1
  69. package/dist/src/cli/timer.d.ts.map +1 -1
  70. package/dist/src/cli/timer.js +8 -1
  71. package/dist/src/cli/timer.js.map +1 -1
  72. package/dist/src/cli/unknownCommand.d.ts +24 -0
  73. package/dist/src/cli/unknownCommand.d.ts.map +1 -0
  74. package/dist/src/cli/unknownCommand.js +84 -0
  75. package/dist/src/cli/unknownCommand.js.map +1 -0
  76. package/dist/src/cli/watch.d.ts +2 -2
  77. package/dist/src/cli/watch.d.ts.map +1 -1
  78. package/dist/src/services/AgentSessionReader.d.ts +26 -12
  79. package/dist/src/services/AgentSessionReader.d.ts.map +1 -1
  80. package/dist/src/services/AgentSessionReader.js +163 -69
  81. package/dist/src/services/AgentSessionReader.js.map +1 -1
  82. package/dist/src/services/CodexTranscript.d.ts +50 -13
  83. package/dist/src/services/CodexTranscript.d.ts.map +1 -1
  84. package/dist/src/services/CodexTranscript.js +49 -13
  85. package/dist/src/services/CodexTranscript.js.map +1 -1
  86. package/dist/src/services/ConfigService.d.ts +4 -0
  87. package/dist/src/services/ConfigService.d.ts.map +1 -1
  88. package/dist/src/services/ConfigService.js +6 -2
  89. package/dist/src/services/ConfigService.js.map +1 -1
  90. package/dist/src/services/IssueFacts.d.ts +2 -2
  91. package/dist/src/services/IssueFacts.d.ts.map +1 -1
  92. package/dist/src/services/IssueFacts.js +5 -5
  93. package/dist/src/services/IssueFacts.js.map +1 -1
  94. package/dist/src/services/JiraAccess.d.ts +140 -0
  95. package/dist/src/services/JiraAccess.d.ts.map +1 -0
  96. package/dist/src/services/JiraAccess.js +204 -0
  97. package/dist/src/services/JiraAccess.js.map +1 -0
  98. package/dist/src/services/ProviderSnapshots.d.ts +41 -0
  99. package/dist/src/services/ProviderSnapshots.d.ts.map +1 -0
  100. package/dist/src/services/ProviderSnapshots.js +71 -0
  101. package/dist/src/services/ProviderSnapshots.js.map +1 -0
  102. package/dist/src/services/ReconcileService.d.ts +12 -4
  103. package/dist/src/services/ReconcileService.d.ts.map +1 -1
  104. package/dist/src/services/ReconcileService.js +223 -113
  105. package/dist/src/services/ReconcileService.js.map +1 -1
  106. package/dist/src/services/SavedEntries.d.ts +24 -4
  107. package/dist/src/services/SavedEntries.d.ts.map +1 -1
  108. package/dist/src/services/SavedEntries.js +335 -19
  109. package/dist/src/services/SavedEntries.js.map +1 -1
  110. package/dist/src/services/SessionAttributor.d.ts +12 -0
  111. package/dist/src/services/SessionAttributor.d.ts.map +1 -1
  112. package/dist/src/services/SessionAttributor.js +13 -1
  113. package/dist/src/services/SessionAttributor.js.map +1 -1
  114. package/dist/src/services/SessionLine.d.ts +29 -0
  115. package/dist/src/services/SessionLine.d.ts.map +1 -0
  116. package/dist/src/services/SessionLine.js +8 -0
  117. package/dist/src/services/SessionLine.js.map +1 -0
  118. package/dist/src/services/SourceLedger.d.ts +84 -1
  119. package/dist/src/services/SourceLedger.d.ts.map +1 -1
  120. package/dist/src/services/SourceLedger.js +178 -12
  121. package/dist/src/services/SourceLedger.js.map +1 -1
  122. package/dist/src/services/TicketRanking.d.ts +39 -0
  123. package/dist/src/services/TicketRanking.d.ts.map +1 -0
  124. package/dist/src/services/TicketRanking.js +87 -0
  125. package/dist/src/services/TicketRanking.js.map +1 -0
  126. package/dist/src/services/TimerService.d.ts +3 -3
  127. package/dist/src/services/TimerService.d.ts.map +1 -1
  128. package/dist/src/services/TimerService.js +6 -17
  129. package/dist/src/services/TimerService.js.map +1 -1
  130. package/dist/src/services/internal/retryTransport.d.ts +9 -0
  131. package/dist/src/services/internal/retryTransport.d.ts.map +1 -0
  132. package/dist/src/services/internal/retryTransport.js +15 -0
  133. package/dist/src/services/internal/retryTransport.js.map +1 -0
  134. package/dist/src/testing/fakeHeadless.d.ts +12 -1
  135. package/dist/src/testing/fakeHeadless.d.ts.map +1 -1
  136. package/dist/src/testing/fakeHeadless.js +83 -10
  137. package/dist/src/testing/fakeHeadless.js.map +1 -1
  138. package/dist/src/tui/App.js +1 -1
  139. package/dist/src/tui/atoms/runtime.d.ts +1 -1
  140. package/dist/src/tui/atoms/runtime.d.ts.map +1 -1
  141. package/dist/src/utils/hints.d.ts +4 -2
  142. package/dist/src/utils/hints.d.ts.map +1 -1
  143. package/dist/src/utils/hints.js +4 -2
  144. package/dist/src/utils/hints.js.map +1 -1
  145. package/dist/tsconfig.tsbuildinfo +1 -1
  146. package/package.json +8 -8
  147. package/skills/jcf/SKILL.md +9 -6
@@ -21,36 +21,40 @@
21
21
  *
22
22
  * @module
23
23
  */
24
- import { ClockifyApi, make as makeClockifyApi } from "@knpkv/clockify-api-client";
25
- import { JiraApiClient, make as makeJiraApi } from "@knpkv/jira-api-client";
26
- import { JiraAuth } from "@knpkv/jira-cli/JiraAuth";
24
+ import { ClockifyApi } from "@knpkv/clockify-api-client";
25
+ import { JiraApiClient } from "@knpkv/jira-api-client";
27
26
  import * as Clock from "effect/Clock";
28
27
  import * as Context from "effect/Context";
29
28
  import * as Data from "effect/Data";
30
29
  import * as Effect from "effect/Effect";
31
30
  import * as FileSystem from "effect/FileSystem";
32
- import * as HttpClient from "effect/http/HttpClient";
33
31
  import * as HttpClientRequest from "effect/http/HttpClientRequest";
34
32
  import * as HttpClientResponse from "effect/http/HttpClientResponse";
35
33
  import * as Layer from "effect/Layer";
34
+ import * as Option from "effect/Option";
36
35
  import * as Path from "effect/Path";
37
36
  import * as Predicate from "effect/Predicate";
38
- import * as Redacted from "effect/Redacted";
39
37
  import * as Schema from "effect/Schema";
40
- import { activeWindows, attributeSession, bothSides, buildSessionProposals, deterministicAttribution, expandHomePath, splitCredits } from "../agent/sessions.js";
38
+ import { activeWindows, attributeSession, bothSides, buildSessionProposals, deterministicAttribution, expandHomePath, mergeSpansWithinDays, splitCredits, splitSessionAttribution } from "../agent/sessions.js";
41
39
  import * as SourceConsumption from "../agent/sourceConsumption.js";
42
40
  import * as WriterGuard from "../cli/writerGuard.js";
41
+ import { CONNECT_JIRA_COMMAND, NOT_LOGGED_IN_HINT } from "../utils/hints.js";
43
42
  import { localDay, nextLocalMidnight, splitIntervalByLocalDay } from "../utils/time.js";
44
43
  import { AgentSessionReader } from "./AgentSessionReader.js";
45
- import { ClockifyAuth } from "./ClockifyAuth.js";
46
44
  import { ConfigService } from "./ConfigService.js";
47
45
  import { HomeDirectory } from "./HomeDirectory.js";
48
46
  import { postJiraWorklog } from "./internal/JiraWorklogPost.js";
47
+ import { retryTransport } from "./internal/retryTransport.js";
48
+ import { JiraAccess } from "./JiraAccess.js";
49
49
  import { readJiraDeletionEvidence } from "./ProviderDeletion.js";
50
+ import { make as makeProviderSnapshots } from "./ProviderSnapshots.js";
50
51
  import { jiraWorklogDescription } from "./SavedEntries.js";
51
52
  import { SessionAttributor } from "./SessionAttributor.js";
52
53
  import { SourceLedger } from "./SourceLedger.js";
54
+ import { candidateFact, readRankingFacts, ticketPriority } from "./TicketRanking.js";
53
55
  import { TimerService } from "./TimerService.js";
56
+ /** Concurrent Jira worklog reads per tally. Bounded so a large week does not trip rate limits. */
57
+ const JIRA_WORKLOG_READ_CONCURRENCY = 8;
54
58
  export { bothSides } from "../agent/sessions.js";
55
59
  const mergeIntervals = (intervals) => {
56
60
  const sorted = [...intervals].sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
@@ -137,6 +141,23 @@ const batchRequests = (requests) => {
137
141
  batches.push(current);
138
142
  return batches;
139
143
  };
144
+ /** Raw ignored presence, unioned per ticket/day so parallel sessions on one key never duplicate it. */
145
+ const ignoredCredits = (evidence, ignoredTickets) => {
146
+ const windowsByTicket = new Map();
147
+ for (const window of evidence) {
148
+ if (window.ticketKey === null || !ignoredTickets.has(window.ticketKey))
149
+ continue;
150
+ windowsByTicket.set(window.ticketKey, [...(windowsByTicket.get(window.ticketKey) ?? []), ...window.spans]);
151
+ }
152
+ return [...windowsByTicket].flatMap(([ticketKey, spans]) => {
153
+ const secondsByDay = new Map();
154
+ for (const span of mergeSpansWithinDays(spans)) {
155
+ const day = localDay(new Date(span.startMs));
156
+ secondsByDay.set(day, (secondsByDay.get(day) ?? 0) + (span.endMs - span.startMs) / 1000);
157
+ }
158
+ return [...secondsByDay].map(([day, seconds]) => ({ ticketKey, day, seconds: Math.floor(seconds) }));
159
+ }).sort((a, b) => a.ticketKey.localeCompare(b.ticketKey) || a.day.localeCompare(b.day));
160
+ };
140
161
  /**
141
162
  * Provider totals that may size a write. Compatibility Jira rows remain visible, but cannot settle
142
163
  * an executable gap until the same credential and account have been verified.
@@ -307,10 +328,8 @@ const toRawWorklog = (value) => {
307
328
  };
308
329
  };
309
330
  export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
310
- const clockifyAuth = yield* ClockifyAuth;
311
331
  const jira = yield* JiraApiClient;
312
- const jiraAuth = yield* JiraAuth;
313
- const httpClient = yield* HttpClient.HttpClient;
332
+ const jiraAccess = yield* JiraAccess;
314
333
  const config = yield* ConfigService;
315
334
  const fileSystem = yield* FileSystem.FileSystem;
316
335
  const path = yield* Path.Path;
@@ -320,68 +339,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
320
339
  const attributor = yield* SessionAttributor;
321
340
  const sourceLedger = yield* SourceLedger;
322
341
  const provideGuard = (effect) => effect.pipe(Effect.provideService(ConfigService, config), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path));
323
- const getAuth = clockifyAuth.getConfig.pipe(Effect.mapError((e) => new ReconcileError({ message: e.message })));
324
- /** One decoded credential and endpoint back every Clockify read and write in this operation. */
325
- const clockifyWriteSnapshot = Effect.gen(function* () {
326
- const auth = yield* getAuth;
327
- const endpoint = yield* Effect.try({
328
- try: () => new URL(auth.baseUrl),
329
- catch: (cause) => new ReconcileError({ message: "Configured Clockify endpoint is invalid", cause })
330
- });
331
- if (!["https:", "http:"].includes(endpoint.protocol) || endpoint.username !== "" ||
332
- endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "")
333
- return yield* new ReconcileError({ message: "Configured Clockify endpoint is invalid" });
334
- const canonicalEndpoint = `${endpoint.origin}${endpoint.pathname.replace(/\/+$/u, "")}`;
335
- const pinnedAuth = { ...auth, baseUrl: canonicalEndpoint };
336
- const client = makeClockifyApi(httpClient, pinnedAuth);
337
- const user = yield* client.getLoggedUser(undefined).pipe(Effect.mapError((cause) => new ReconcileError({ message: "Cannot verify the Clockify account", cause })));
338
- if (user.id === "" || auth.userId !== user.id || auth.workspaceId === "" || auth.baseUrl === "") {
339
- return yield* new ReconcileError({ message: "Configured Clockify account does not match the credential" });
340
- }
341
- return {
342
- auth: pinnedAuth,
343
- client,
344
- scope: JSON.stringify(["clockify-v3", canonicalEndpoint, auth.workspaceId, user.id]),
345
- legacyScope: JSON.stringify([auth.workspaceId, user.id])
346
- };
347
- });
348
- /** Bind verification and worklog POST to one OAuth credential and selected site. */
349
- const jiraWriteState = Effect.gen(function* () {
350
- const login = yield* Effect.result(jiraAuth.isLoggedIn());
351
- if (login._tag === "Failure")
352
- return { availability: "unverified", snapshot: null };
353
- if (!login.success)
354
- return { availability: "not-logged-in", snapshot: null };
355
- const tokenResult = yield* Effect.result(jiraAuth.getAccessToken());
356
- const profileResult = yield* Effect.result(jiraAuth.getActiveProfile());
357
- if (tokenResult._tag === "Failure" || profileResult._tag === "Failure") {
358
- return { availability: "unverified", snapshot: null };
359
- }
360
- const token = tokenResult.success;
361
- const profile = profileResult.success;
362
- if (profile === null || profile.token.cloud_id === "" || profile.token.site_url === "" ||
363
- profile.token.access_token !== Redacted.value(token))
364
- return { availability: "unverified", snapshot: null };
365
- const client = makeJiraApi(httpClient, {
366
- baseUrl: "",
367
- auth: { type: "oauth2", accessToken: token, cloudId: profile.token.cloud_id }
368
- });
369
- const live = yield* client.getCurrentUser({}).pipe(Effect.orElseSucceed(() => null));
370
- if (live?.accountId === undefined || live.accountId === "") {
371
- return { availability: "unverified", snapshot: null };
372
- }
373
- return {
374
- availability: "verified",
375
- snapshot: {
376
- client,
377
- ledgerScope: JSON.stringify([profile.token.cloud_id, live.accountId]),
378
- heldScope: JSON.stringify([profile.token.cloud_id, profile.token.site_url, live.accountId]),
379
- accountId: live.accountId,
380
- cloudId: profile.token.cloud_id,
381
- siteUrl: profile.token.site_url
382
- }
383
- };
384
- });
342
+ const providerSnapshots = yield* makeProviderSnapshots;
343
+ const clockifyWriteSnapshot = providerSnapshots.clockify.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
344
+ const jiraWriteState = providerSnapshots.jira;
385
345
  const jiraWriteSnapshot = jiraWriteState.pipe(Effect.map((state) => state.snapshot));
386
346
  /**
387
347
  * How many entries to ask Clockify for at a time, and how many pages to accept.
@@ -472,9 +432,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
472
432
  let nextStartAt = 0;
473
433
  let expectedTotal;
474
434
  for (;;) {
475
- const page = yield* client.getIssueWorklog(issueKey, {
435
+ const page = yield* retryTransport(client.getIssueWorklog(issueKey, {
476
436
  params: { startAt: nextStartAt, startedAfter, startedBefore }
477
- }).pipe(Effect.mapError((cause) => new ReconcileError({ message: `Jira worklog fetch failed for ${issueKey}: ${String(cause)}`, cause })));
437
+ })).pipe(Effect.mapError((cause) => new ReconcileError({ message: `Jira worklog fetch failed for ${issueKey}: ${String(cause)}`, cause })));
478
438
  const { maxResults, startAt, total, worklogs: entries } = page;
479
439
  if (startAt !== nextStartAt ||
480
440
  maxResults === undefined || !Number.isSafeInteger(maxResults) || maxResults < 0 ||
@@ -507,13 +467,21 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
507
467
  }
508
468
  });
509
469
  // Tally the current user's Jira worklogs in the period by (ticket, day).
510
- const jiraTally = (period, requiredIssueKeys = [], pinned = undefined) => Effect.gen(function* () {
470
+ const jiraTally = (period, requiredIssueKeys = [], pinned = undefined,
471
+ /**
472
+ * `window` searches every issue I logged in the period and adds the required keys;
473
+ * `required-only` reads just the required keys, to top up a tally that already searched.
474
+ */
475
+ scope = "window") => Effect.gen(function* () {
511
476
  // A verified snapshot pins the account, site and credential-backed client for every page.
512
477
  // Falling back is only for a side without a usable write credential; that side cannot be
513
478
  // written because its source scope remains null, but independent providers may still proceed.
514
479
  const snapshot = pinned === undefined ? yield* jiraWriteSnapshot : pinned;
515
480
  const client = snapshot?.client ?? jira;
516
- const cachedAccountId = (yield* jiraAuth.getCurrentUser().pipe(Effect.mapError((cause) => new ReconcileError({ message: "Could not read the Jira account", cause }))))?.account_id;
481
+ const connection = yield* jiraAccess.connection.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
482
+ const cachedAccountId = Option.isSome(connection) && connection.value.accountId !== ""
483
+ ? connection.value.accountId
484
+ : undefined;
517
485
  if (snapshot !== null && snapshot !== undefined && cachedAccountId !== snapshot.accountId) {
518
486
  return yield* new ReconcileError({
519
487
  message: "The cached Jira account does not match the verified credential"
@@ -522,7 +490,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
522
490
  const accountId = snapshot?.accountId ?? cachedAccountId;
523
491
  if (accountId === undefined || accountId === "") {
524
492
  return yield* new ReconcileError({
525
- message: "Jira account identity is unavailable; recorded time cannot be tallied"
493
+ message: Option.isNone(connection)
494
+ ? NOT_LOGGED_IN_HINT
495
+ : `Jira did not say which account is connected. Run ${CONNECT_JIRA_COMMAND} again.`
526
496
  });
527
497
  }
528
498
  const fromMs = period.from.getTime();
@@ -542,19 +512,19 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
542
512
  // "Jira is short by the whole day" — which `watch` would then post on top of hours Jira
543
513
  // already holds. A window of more than a hundred distinct issues is an ordinary week.
544
514
  const jql = `worklogAuthor = currentUser() AND worklogDate >= "${fromDay}" AND worklogDate <= "${toDay}"`;
545
- const searchPage = (pageToken) => client.searchIssuesUsingJql({
515
+ const searchPage = (pageToken) => retryTransport(client.searchIssuesUsingJql({
546
516
  params: {
547
517
  jql,
548
518
  maxResults: 100,
549
519
  fields: ["key"],
550
520
  ...((pageToken !== undefined) && { nextPageToken: pageToken })
551
521
  }
552
- }).pipe(Effect.mapError((e) => new ReconcileError({ message: `Jira search failed: ${String(e)}`, cause: e })));
522
+ })).pipe(Effect.mapError((e) => new ReconcileError({ message: `Jira search failed: ${String(e)}`, cause: e })));
553
523
  const searched = [];
554
524
  let pageToken = undefined;
555
525
  // Bounded so a server that keeps handing back a token cannot spin here for ever; hitting the
556
526
  // bound fails the run rather than proceeding on a partial tally.
557
- for (let page = 0; page < 50; page++) {
527
+ for (let page = 0; scope === "window" && page < 50; page++) {
558
528
  const result = yield* searchPage(pageToken);
559
529
  const issues = Predicate.isObject(result) ? result["issues"] : undefined;
560
530
  // A page with no `issues` at all is not an empty page. The generated success schema makes
@@ -601,8 +571,12 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
601
571
  }
602
572
  const toMs = period.to.getTime();
603
573
  const tally = [];
604
- for (const issueKey of issueKeys) {
605
- const worklogs = yield* readJiraWorklogs(client, issueKey, lookupFromMs - 1, toMs);
574
+ // Every issue's worklogs are read before any is tallied, a few at a time: a week of forty
575
+ // tickets read one after another made every confirmation wait on forty round trips. Tallying
576
+ // stays in issue order, so the result is the same as a sequential read.
577
+ const worklogsByIssue = yield* Effect.forEach(issueKeys, (issueKey) => readJiraWorklogs(client, issueKey, lookupFromMs - 1, toMs), { concurrency: JIRA_WORKLOG_READ_CONCURRENCY });
578
+ for (const [issueIndex, issueKey] of issueKeys.entries()) {
579
+ const worklogs = worklogsByIssue[issueIndex] ?? [];
606
580
  for (const wl of worklogs) {
607
581
  // Only this user's worklogs (the JQL narrows issues, not individual worklog authors).
608
582
  const author = wl.author?.accountId;
@@ -641,9 +615,11 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
641
615
  // A side that is out is not called at all. That is the point of the option for someone who
642
616
  // tracks in one system: no Clockify workspace to configure, no Jira login to keep alive, and
643
617
  // no request whose failure could stop a run that never needed it.
644
- const jiraSideRead = sides.jira
645
- ? jiraTally(period, options?.jiraIssueKeys, options?.jiraSnapshot)
646
- : Effect.succeed([]);
618
+ const jiraSideRead = !sides.jira
619
+ ? Effect.succeed([])
620
+ : options?.jiraPreRead !== undefined
621
+ ? Effect.succeed(options.jiraPreRead)
622
+ : jiraTally(period, options?.jiraIssueKeys, options?.jiraSnapshot);
647
623
  const [clockifySide, jiraSide] = yield* Effect.all([
648
624
  sides.clockify
649
625
  ? clockifyTally(period, options?.clockifySnapshot)
@@ -1118,7 +1094,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1118
1094
  // `attributeSession` remains the single place the precedence is actually applied.
1119
1095
  const needsAgent = options.mode === "deterministic" ?
1120
1096
  [] :
1121
- sessions.filter((session) => deterministicAttribution(session, { standingMap: options.standingMap }) === null &&
1097
+ sessions.filter((session) => deterministicAttribution(session, options) === null &&
1122
1098
  session.candidateKeys.length > 0);
1123
1099
  const batches = batchRequests(needsAgent);
1124
1100
  if (needsAgent.length > 0) {
@@ -1135,7 +1111,11 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1135
1111
  const batchResults = yield* Effect.all(batches.map((batch, batchIndex) => attributor.attribute(batch.map((session) => ({
1136
1112
  sessionId: session.sessionId,
1137
1113
  candidateKeys: session.candidateKeys,
1138
- digest: session.digest
1114
+ digest: session.digest,
1115
+ candidateFacts: new Map(session.candidateKeys.flatMap((key) => {
1116
+ const fact = options.candidateFact?.(key);
1117
+ return fact === undefined ? [] : [[key, fact]];
1118
+ }))
1139
1119
  })), (activity) => options.onProgress({
1140
1120
  _tag: "AgentActivity",
1141
1121
  batch: batchIndex + 1,
@@ -1145,7 +1125,10 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1145
1125
  const bySession = new Map(answers.map((answer) => [answer.sessionId, answer.choice]));
1146
1126
  return batch.map((session) => {
1147
1127
  const declined = { _tag: "None" };
1148
- const choice = bySession.get(session.sessionId) ?? declined;
1128
+ const answer = bySession.get(session.sessionId) ?? declined;
1129
+ const choice = answer._tag === "Chosen" && options.ignoredTickets.has(answer.ticketKey)
1130
+ ? declined
1131
+ : answer;
1149
1132
  return {
1150
1133
  session,
1151
1134
  choice,
@@ -1200,7 +1183,8 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1200
1183
  const attributions = sessions.map((session) => attributeSession(session, {
1201
1184
  standingMap: options.standingMap,
1202
1185
  agentChoice: chosen.get(session.sessionId) ?? null,
1203
- confidenceFloor: options.confidenceFloor
1186
+ confidenceFloor: options.confidenceFloor,
1187
+ ignoredTickets: options.ignoredTickets
1204
1188
  }));
1205
1189
  return { attributions, attributorAvailable, attributorCalls: batches.length };
1206
1190
  });
@@ -1224,15 +1208,40 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1224
1208
  return requests.map((request) => byId.get(request.id) ?? null);
1225
1209
  });
1226
1210
  /** Both first reads and post-write refreshes subtract live totals through the same path. */
1227
- const refreshRecordedTime = Effect.fn("ReconcileService.refreshRecordedTime")(function* (period, previous, options) {
1211
+ const refreshRecordedTime = Effect.fn("ReconcileService.refreshRecordedTime")(function* (period, cached, options,
1212
+ /** A Jira write state the caller already resolved, so one read never verifies twice. */
1213
+ pinnedJiraState,
1214
+ /**
1215
+ * A Jira tally taken under `pinnedJiraState` earlier in the same read, with the issue keys it
1216
+ * was required to cover. Reused only when it covers every key this refresh needs.
1217
+ */
1218
+ preReadJira) {
1219
+ const ignoredTickets = new Set((yield* config.get).sessionIgnoredTickets);
1220
+ const ignored = [...new Map([
1221
+ ...ignoredCredits(cached.sessionEvidence ?? [], ignoredTickets),
1222
+ ...cached.ignored.filter((entry) => ignoredTickets.has(entry.ticketKey))
1223
+ ].map((entry) => [`${entry.ticketKey}:${entry.day}`, entry])).values()];
1224
+ const previous = {
1225
+ ...cached,
1226
+ attributed: cached.attributed.filter((row) => !ignoredTickets.has(row.ticketKey)),
1227
+ withheld: cached.withheld.filter((row) => !ignoredTickets.has(row.ticketKey)),
1228
+ ignoredTickets: [...ignoredTickets].sort(),
1229
+ ignored
1230
+ };
1228
1231
  const { sides } = previous;
1232
+ const writeBlocked = {};
1229
1233
  const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1230
1234
  const clockifySnapshot = sides.clockify ? yield* clockifyWriteSnapshot : null;
1231
1235
  if (clockifySnapshot !== null) {
1232
- yield* sourceLedger.assertNoLegacyClockify(clockifySnapshot.legacyScope).pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1236
+ yield* sourceLedger.assertNoLegacyClockify(clockifySnapshot.legacyScope).pipe(Effect.catchTag("SourceLedgerError", (cause) => {
1237
+ if (cause.reason !== "review-required")
1238
+ return Effect.fail(cause);
1239
+ writeBlocked.clockify = cause.message;
1240
+ return Effect.void;
1241
+ }), Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1233
1242
  }
1234
1243
  const clockifyScope = clockifySnapshot?.scope ?? null;
1235
- const jiraState = sides.jira ? yield* jiraWriteState : undefined;
1244
+ const jiraState = sides.jira ? pinnedJiraState ?? (yield* jiraWriteState) : undefined;
1236
1245
  const jiraSnapshot = jiraState?.snapshot ?? null;
1237
1246
  // Compatibility reads may still render Jira totals, but only one verified snapshot may grant
1238
1247
  // durable source authority. Independent Clockify work remains executable while Jira is held.
@@ -1259,10 +1268,31 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1259
1268
  ...(options?.jiraIssueKeys ?? [])
1260
1269
  ])
1261
1270
  ];
1271
+ // Covered means read directly or found holding my time; anything else is read fresh, so reuse
1272
+ // is an optimization the result never depends on.
1273
+ const covered = new Set([
1274
+ ...(preReadJira?.requiredKeys ?? []),
1275
+ ...(preReadJira?.tally ?? []).map((row) => row.ticketKey)
1276
+ ]);
1277
+ // Keys the pre-read did not cover — usually tickets the agent or a split chose — are read on
1278
+ // their own and merged. They held none of my time in the pre-read's search, so their rows
1279
+ // cannot duplicate it. If the top-up fails, the refresh falls back to a full read.
1280
+ const missingKeys = jiraIssueKeys.filter((key) => !covered.has(key));
1281
+ const topUp = preReadJira === undefined || pinnedJiraState === undefined || missingKeys.length === 0
1282
+ ? undefined
1283
+ : yield* Effect.result(jiraTally(period, missingKeys, jiraSnapshot, "required-only"));
1284
+ const jiraPreRead = preReadJira === undefined || pinnedJiraState === undefined
1285
+ ? undefined
1286
+ : missingKeys.length === 0
1287
+ ? preReadJira.tally
1288
+ : topUp?._tag === "Success"
1289
+ ? [...preReadJira.tally, ...topUp.success]
1290
+ : undefined;
1262
1291
  const [{ recorded, unlinkedClockify }, excludedDays] = yield* Effect.all([
1263
1292
  readRecorded(period, {
1264
1293
  sides,
1265
1294
  jiraIssueKeys,
1295
+ jiraPreRead,
1266
1296
  clockifySnapshot: clockifySnapshot ?? undefined,
1267
1297
  jiraSnapshot,
1268
1298
  tolerateUnavailableJira: jiraState?.availability !== "verified"
@@ -1329,19 +1359,23 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1329
1359
  scope,
1330
1360
  fromMs: period.from.getTime(),
1331
1361
  toMs: period.to.getTime()
1332
- }, observed.map((entry) => ({ entryId: entry.id, startMs: entry.startMs })), verified, provider === "clockify" ? clockifySnapshot?.legacyScope : undefined).pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1362
+ }, observed.map((entry) => ({ entryId: entry.id, startMs: entry.startMs })), verified, provider === "clockify" ? clockifySnapshot?.legacyScope : undefined).pipe(Effect.catchTag("SourceLedgerError", (cause) => {
1363
+ if (cause.reason !== "review-required")
1364
+ return Effect.fail(cause);
1365
+ writeBlocked[provider] = cause.message;
1366
+ return Effect.void;
1367
+ }), Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1333
1368
  }
1334
1369
  const verifiedLedger = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1335
- if (previous.attributed.length > 0 &&
1336
- entries.some((entry) => {
1370
+ if (previous.attributed.length > 0) {
1371
+ for (const entry of entries) {
1337
1372
  const scope = entry.source === "clockify" ? clockifyScope : jiraScope;
1338
- return scope !== null && entry.description !== null &&
1373
+ if (scope !== null && entry.description !== null &&
1339
1374
  SourceConsumption.markers(entry.description).length > 0 &&
1340
- !verifiedLedger.bindings.some((binding) => binding.provider === entry.source && binding.entryId === entry.id && binding.scope === scope);
1341
- })) {
1342
- return yield* new ReconcileError({
1343
- message: "An unlinked source marker needs private manual review before session writes"
1344
- });
1375
+ !verifiedLedger.bindings.some((binding) => binding.provider === entry.source && binding.entryId === entry.id && binding.scope === scope)) {
1376
+ writeBlocked[entry.source] = "An unlinked source marker needs private manual review before session writes";
1377
+ }
1378
+ }
1345
1379
  }
1346
1380
  const sourceEntries = verifiedLedger.bindings.filter((binding) => binding.provider === "clockify" ? binding.scope === clockifyScope : binding.scope === jiraScope).flatMap((binding) => {
1347
1381
  const entry = entries.find((candidate) => candidate.source === binding.provider && candidate.id === binding.entryId);
@@ -1357,6 +1391,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1357
1391
  const executableRecorded = recordedForExecution(recorded, jiraState?.availability);
1358
1392
  return {
1359
1393
  ...previous,
1394
+ writeBlocked,
1360
1395
  proposals: buildSessionProposals(previous.attributed, executableRecorded, {
1361
1396
  minimumSeconds: MINIMUM_PROPOSAL_SECONDS,
1362
1397
  excludedDays: excludedDays.map((excluded) => excluded.day),
@@ -1366,7 +1401,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1366
1401
  day: slice.day,
1367
1402
  intervals: slice.entry === undefined ? [] : [{ entry: slice.entry }]
1368
1403
  }))
1369
- }),
1404
+ }).map((proposal) => ({ ...proposal, writeBlocked })),
1370
1405
  recorded,
1371
1406
  unlinkedClockify,
1372
1407
  sourceEntries,
@@ -1378,13 +1413,72 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1378
1413
  const proposeFromSessions = (period, options) => Effect.gen(function* () {
1379
1414
  const onProgress = options?.onProgress ?? (() => Effect.void);
1380
1415
  const cfg = yield* config.get;
1416
+ const ignoredTickets = new Set(cfg.sessionIgnoredTickets);
1417
+ const standingMap = expandedStandingMap(cfg.sessionTicketMap, home);
1381
1418
  const sessions = yield* sessionReader.read(period).pipe(Effect.mapError((error) => new ReconcileError({ message: error.message, cause: error })));
1382
1419
  yield* onProgress({ _tag: "SessionsRead", count: sessions.length });
1383
- const { attributions, attributorAvailable, attributorCalls } = yield* attributeSessions(sessions, {
1384
- standingMap: expandedStandingMap(cfg.sessionTicketMap, home),
1420
+ const sides = options?.sides ?? bothSides;
1421
+ // Only a run about Jira reads Jira to rank: a Clockify-only week makes no Jira request. The
1422
+ // verified profile's client, so a sprint is never read from a different account's site.
1423
+ // Resolved once for the whole read: ranking and the first recorded refresh must see the
1424
+ // same verified authority, and resolving twice can observe two different answers. The pin
1425
+ // never outlives the read: every write path refreshes without it and verifies afresh.
1426
+ const jiraState = sides.jira ? yield* jiraWriteState : undefined;
1427
+ const rankingSnapshot = jiraState?.snapshot ?? undefined;
1428
+ // The Jira tally is read before attribution, so ranking and the attribution prompt see who
1429
+ // logged what, and the first refresh reuses it. Branch, path and standing targets and
1430
+ // ledger-bound tickets are required reads; any other candidate the tally lacks is unlogged.
1431
+ const preReadJira = rankingSnapshot === undefined ? undefined : yield* Effect.gen(function* () {
1432
+ const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1433
+ const requiredKeys = [
1434
+ ...new Set([
1435
+ ...sessions.flatMap((session) => {
1436
+ const target = deterministicAttribution(session, { standingMap });
1437
+ return target === null || ignoredTickets.has(target.ticketKey) ? [] : [target.ticketKey];
1438
+ }),
1439
+ ...stored.bindings.filter((binding) => binding.provider === "jira" && binding.scope === rankingSnapshot.ledgerScope).map((binding) => binding.ticketKey)
1440
+ ])
1441
+ ];
1442
+ const tally = yield* Effect.result(jiraTally(period, requiredKeys, rankingSnapshot));
1443
+ return tally._tag === "Success" ? { tally: tally.success, requiredKeys } : undefined;
1444
+ });
1445
+ const ranking = rankingSnapshot !== undefined
1446
+ ? yield* readRankingFacts((jql, nextPageToken) => retryTransport(rankingSnapshot.client.searchIssuesUsingJql({
1447
+ params: {
1448
+ jql,
1449
+ maxResults: 100,
1450
+ fields: ["key"],
1451
+ ...((nextPageToken !== undefined) && { nextPageToken })
1452
+ }
1453
+ })), preReadJira?.tally)
1454
+ : {
1455
+ _tag: "Unknown",
1456
+ reason: sides.jira ? "Jira account is not verified" : "Jira is out of scope"
1457
+ };
1458
+ const ignoredBySession = new Map(sessions.flatMap((session) => {
1459
+ const original = deterministicAttribution(session, { standingMap });
1460
+ return original === null || !ignoredTickets.has(original.ticketKey)
1461
+ ? []
1462
+ : [[session.sessionId, original.ticketKey]];
1463
+ }));
1464
+ const { attributions: primaryAttributions, attributorAvailable, attributorCalls } = yield* attributeSessions(sessions.map((session) => ({
1465
+ ...session,
1466
+ candidateKeys: session.candidateKeys.filter((key) => !ignoredTickets.has(key))
1467
+ })), {
1468
+ standingMap,
1469
+ ignoredTickets,
1385
1470
  confidenceFloor: cfg.sessionConfidenceFloor,
1386
1471
  mode: options?.attribution ?? "full",
1387
- onProgress
1472
+ onProgress,
1473
+ candidateFact: candidateFact(ranking)
1474
+ });
1475
+ // A split is a guess from mention counts: a deterministic read reports the session unplaced
1476
+ // instead, so an unattended watch never downgrades a branch-placed row to `split`.
1477
+ const attributions = primaryAttributions.map((attribution, index) => {
1478
+ const session = sessions[index];
1479
+ return session === undefined || options?.attribution === "deterministic" ?
1480
+ attribution :
1481
+ splitSessionAttribution(session, attribution, ranking._tag === "Known" && jiraState?.availability === "verified" ? ranking.sprint : undefined, ignoredTickets);
1388
1482
  });
1389
1483
  // Windows first, then attribution, then sharing — in that order, because time is divided
1390
1484
  // between distinct Issue Keys, not between sessions. Two sessions on one ticket must union
@@ -1397,27 +1491,43 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1397
1491
  observedAtMs: Math.min(period.to.getTime(), nowMs),
1398
1492
  boundsBySession: new Map(sessions.flatMap((session) => session.boundedAtMs === null ? [] : [[session.sessionId, session.boundedAtMs]]))
1399
1493
  });
1400
- const split = splitCredits(windows, attributions, {
1494
+ const attributionBySession = new Map(attributions.map((attribution) => [attribution.sessionId, attribution]));
1495
+ const ignored = ignoredCredits(windows.map((window) => ({
1496
+ ...window,
1497
+ ticketKey: ignoredBySession.get(window.sessionId) ?? null
1498
+ })), ignoredTickets);
1499
+ const eligibleWindows = windows.filter((window) => {
1500
+ const key = attributionBySession.get(window.sessionId)?.ticketKey;
1501
+ return !ignoredBySession.has(window.sessionId) ||
1502
+ (attributionBySession.get(window.sessionId)?.targets?.length ?? 0) > 0 ||
1503
+ (key !== null && key !== undefined && !ignoredTickets.has(key));
1504
+ });
1505
+ const split = splitCredits(eligibleWindows, attributions, {
1401
1506
  cwdBySession: new Map(sessions.map((session) => [session.sessionId, session.cwd])),
1402
- dwellSeconds: cfg.sessionDwellSeconds
1507
+ dwellSeconds: cfg.sessionDwellSeconds,
1508
+ priority: ticketPriority(ranking),
1509
+ ignoredTickets
1403
1510
  });
1404
- const sides = options?.sides ?? bothSides;
1405
1511
  yield* onProgress({ _tag: "ReadingRecordedTime", sides });
1406
1512
  return yield* refreshRecordedTime(period, {
1407
1513
  attributed: split.attributed,
1408
- sessionEvidence: windows.map((window) => ({
1409
- ...window,
1410
- ticketKey: attributions.find((attribution) => attribution.sessionId === window.sessionId)?.ticketKey ?? null
1411
- })),
1514
+ sessionEvidence: windows.flatMap((window) => {
1515
+ const attribution = attributionBySession.get(window.sessionId);
1516
+ const keys = attribution?.targets?.map((target) => target.ticketKey) ??
1517
+ [attribution?.ticketKey ?? ignoredBySession.get(window.sessionId) ?? null];
1518
+ return keys.map((ticketKey) => ({ ...window, ticketKey }));
1519
+ }),
1412
1520
  sides,
1413
1521
  withheld: split.withheld,
1414
1522
  unattributed: split.unattributed,
1523
+ ignored,
1524
+ ignoredTickets: [...ignoredTickets].sort(),
1415
1525
  attributorAvailable,
1416
1526
  sessionCount: sessions.length,
1417
1527
  sessionRootCount: cfg.sessionRoots.length,
1418
1528
  attributorCalls,
1419
1529
  digests: new Map(sessions.map((session) => [session.sessionId, session.digest]))
1420
- });
1530
+ }, undefined, jiraState, preReadJira);
1421
1531
  });
1422
1532
  return {
1423
1533
  compare,