@knpkv/jira-clockify 1.4.0 → 1.5.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 (84) hide show
  1. package/README.md +72 -18
  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/cli/agentWrite.d.ts.map +1 -1
  16. package/dist/src/cli/agentWrite.js +12 -5
  17. package/dist/src/cli/agentWrite.js.map +1 -1
  18. package/dist/src/cli/auth.d.ts +39 -2
  19. package/dist/src/cli/auth.d.ts.map +1 -1
  20. package/dist/src/cli/auth.js +29 -5
  21. package/dist/src/cli/auth.js.map +1 -1
  22. package/dist/src/cli/config.d.ts +2 -2
  23. package/dist/src/cli/config.d.ts.map +1 -1
  24. package/dist/src/cli/config.js +34 -6
  25. package/dist/src/cli/config.js.map +1 -1
  26. package/dist/src/cli/layers.d.ts.map +1 -1
  27. package/dist/src/cli/layers.js +2 -2
  28. package/dist/src/cli/layers.js.map +1 -1
  29. package/dist/src/cli/reconcile.d.ts +6 -0
  30. package/dist/src/cli/reconcile.d.ts.map +1 -1
  31. package/dist/src/cli/reconcile.js +2 -0
  32. package/dist/src/cli/reconcile.js.map +1 -1
  33. package/dist/src/cli/setup.d.ts +1 -1
  34. package/dist/src/cli/setup.d.ts.map +1 -1
  35. package/dist/src/services/AgentSessionReader.d.ts +26 -12
  36. package/dist/src/services/AgentSessionReader.d.ts.map +1 -1
  37. package/dist/src/services/AgentSessionReader.js +163 -69
  38. package/dist/src/services/AgentSessionReader.js.map +1 -1
  39. package/dist/src/services/CodexTranscript.d.ts +50 -13
  40. package/dist/src/services/CodexTranscript.d.ts.map +1 -1
  41. package/dist/src/services/CodexTranscript.js +49 -13
  42. package/dist/src/services/CodexTranscript.js.map +1 -1
  43. package/dist/src/services/ConfigService.d.ts +2 -0
  44. package/dist/src/services/ConfigService.d.ts.map +1 -1
  45. package/dist/src/services/ConfigService.js +4 -1
  46. package/dist/src/services/ConfigService.js.map +1 -1
  47. package/dist/src/services/ProviderSnapshots.d.ts +41 -0
  48. package/dist/src/services/ProviderSnapshots.d.ts.map +1 -0
  49. package/dist/src/services/ProviderSnapshots.js +81 -0
  50. package/dist/src/services/ProviderSnapshots.js.map +1 -0
  51. package/dist/src/services/ReconcileService.d.ts +11 -3
  52. package/dist/src/services/ReconcileService.d.ts.map +1 -1
  53. package/dist/src/services/ReconcileService.js +212 -109
  54. package/dist/src/services/ReconcileService.js.map +1 -1
  55. package/dist/src/services/SavedEntries.d.ts +24 -4
  56. package/dist/src/services/SavedEntries.d.ts.map +1 -1
  57. package/dist/src/services/SavedEntries.js +335 -19
  58. package/dist/src/services/SavedEntries.js.map +1 -1
  59. package/dist/src/services/SessionAttributor.d.ts +12 -0
  60. package/dist/src/services/SessionAttributor.d.ts.map +1 -1
  61. package/dist/src/services/SessionAttributor.js +13 -1
  62. package/dist/src/services/SessionAttributor.js.map +1 -1
  63. package/dist/src/services/SessionLine.d.ts +29 -0
  64. package/dist/src/services/SessionLine.d.ts.map +1 -0
  65. package/dist/src/services/SessionLine.js +8 -0
  66. package/dist/src/services/SessionLine.js.map +1 -0
  67. package/dist/src/services/SourceLedger.d.ts +84 -1
  68. package/dist/src/services/SourceLedger.d.ts.map +1 -1
  69. package/dist/src/services/SourceLedger.js +178 -12
  70. package/dist/src/services/SourceLedger.js.map +1 -1
  71. package/dist/src/services/TicketRanking.d.ts +39 -0
  72. package/dist/src/services/TicketRanking.d.ts.map +1 -0
  73. package/dist/src/services/TicketRanking.js +87 -0
  74. package/dist/src/services/TicketRanking.js.map +1 -0
  75. package/dist/src/services/internal/retryTransport.d.ts +9 -0
  76. package/dist/src/services/internal/retryTransport.d.ts.map +1 -0
  77. package/dist/src/services/internal/retryTransport.js +15 -0
  78. package/dist/src/services/internal/retryTransport.js.map +1 -0
  79. package/dist/src/testing/fakeHeadless.d.ts +11 -0
  80. package/dist/src/testing/fakeHeadless.d.ts.map +1 -1
  81. package/dist/src/testing/fakeHeadless.js +66 -3
  82. package/dist/src/testing/fakeHeadless.js.map +1 -1
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/package.json +7 -7
@@ -21,36 +21,38 @@
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";
24
+ import { ClockifyApi } from "@knpkv/clockify-api-client";
25
+ import { JiraApiClient } from "@knpkv/jira-api-client";
26
26
  import { JiraAuth } from "@knpkv/jira-cli/JiraAuth";
27
27
  import * as Clock from "effect/Clock";
28
28
  import * as Context from "effect/Context";
29
29
  import * as Data from "effect/Data";
30
30
  import * as Effect from "effect/Effect";
31
31
  import * as FileSystem from "effect/FileSystem";
32
- import * as HttpClient from "effect/http/HttpClient";
33
32
  import * as HttpClientRequest from "effect/http/HttpClientRequest";
34
33
  import * as HttpClientResponse from "effect/http/HttpClientResponse";
35
34
  import * as Layer from "effect/Layer";
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";
43
41
  import { localDay, nextLocalMidnight, splitIntervalByLocalDay } from "../utils/time.js";
44
42
  import { AgentSessionReader } from "./AgentSessionReader.js";
45
- import { ClockifyAuth } from "./ClockifyAuth.js";
46
43
  import { ConfigService } from "./ConfigService.js";
47
44
  import { HomeDirectory } from "./HomeDirectory.js";
48
45
  import { postJiraWorklog } from "./internal/JiraWorklogPost.js";
46
+ import { retryTransport } from "./internal/retryTransport.js";
49
47
  import { readJiraDeletionEvidence } from "./ProviderDeletion.js";
48
+ import { make as makeProviderSnapshots } from "./ProviderSnapshots.js";
50
49
  import { jiraWorklogDescription } from "./SavedEntries.js";
51
50
  import { SessionAttributor } from "./SessionAttributor.js";
52
51
  import { SourceLedger } from "./SourceLedger.js";
52
+ import { candidateFact, readRankingFacts, ticketPriority } from "./TicketRanking.js";
53
53
  import { TimerService } from "./TimerService.js";
54
+ /** Concurrent Jira worklog reads per tally. Bounded so a large week does not trip rate limits. */
55
+ const JIRA_WORKLOG_READ_CONCURRENCY = 8;
54
56
  export { bothSides } from "../agent/sessions.js";
55
57
  const mergeIntervals = (intervals) => {
56
58
  const sorted = [...intervals].sort((left, right) => left.startMs - right.startMs || left.endMs - right.endMs);
@@ -137,6 +139,23 @@ const batchRequests = (requests) => {
137
139
  batches.push(current);
138
140
  return batches;
139
141
  };
142
+ /** Raw ignored presence, unioned per ticket/day so parallel sessions on one key never duplicate it. */
143
+ const ignoredCredits = (evidence, ignoredTickets) => {
144
+ const windowsByTicket = new Map();
145
+ for (const window of evidence) {
146
+ if (window.ticketKey === null || !ignoredTickets.has(window.ticketKey))
147
+ continue;
148
+ windowsByTicket.set(window.ticketKey, [...(windowsByTicket.get(window.ticketKey) ?? []), ...window.spans]);
149
+ }
150
+ return [...windowsByTicket].flatMap(([ticketKey, spans]) => {
151
+ const secondsByDay = new Map();
152
+ for (const span of mergeSpansWithinDays(spans)) {
153
+ const day = localDay(new Date(span.startMs));
154
+ secondsByDay.set(day, (secondsByDay.get(day) ?? 0) + (span.endMs - span.startMs) / 1000);
155
+ }
156
+ return [...secondsByDay].map(([day, seconds]) => ({ ticketKey, day, seconds: Math.floor(seconds) }));
157
+ }).sort((a, b) => a.ticketKey.localeCompare(b.ticketKey) || a.day.localeCompare(b.day));
158
+ };
140
159
  /**
141
160
  * Provider totals that may size a write. Compatibility Jira rows remain visible, but cannot settle
142
161
  * an executable gap until the same credential and account have been verified.
@@ -307,10 +326,8 @@ const toRawWorklog = (value) => {
307
326
  };
308
327
  };
309
328
  export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
310
- const clockifyAuth = yield* ClockifyAuth;
311
329
  const jira = yield* JiraApiClient;
312
330
  const jiraAuth = yield* JiraAuth;
313
- const httpClient = yield* HttpClient.HttpClient;
314
331
  const config = yield* ConfigService;
315
332
  const fileSystem = yield* FileSystem.FileSystem;
316
333
  const path = yield* Path.Path;
@@ -320,68 +337,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
320
337
  const attributor = yield* SessionAttributor;
321
338
  const sourceLedger = yield* SourceLedger;
322
339
  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
- });
340
+ const providerSnapshots = yield* makeProviderSnapshots;
341
+ const clockifyWriteSnapshot = providerSnapshots.clockify.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
342
+ const jiraWriteState = providerSnapshots.jira;
385
343
  const jiraWriteSnapshot = jiraWriteState.pipe(Effect.map((state) => state.snapshot));
386
344
  /**
387
345
  * How many entries to ask Clockify for at a time, and how many pages to accept.
@@ -472,9 +430,9 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
472
430
  let nextStartAt = 0;
473
431
  let expectedTotal;
474
432
  for (;;) {
475
- const page = yield* client.getIssueWorklog(issueKey, {
433
+ const page = yield* retryTransport(client.getIssueWorklog(issueKey, {
476
434
  params: { startAt: nextStartAt, startedAfter, startedBefore }
477
- }).pipe(Effect.mapError((cause) => new ReconcileError({ message: `Jira worklog fetch failed for ${issueKey}: ${String(cause)}`, cause })));
435
+ })).pipe(Effect.mapError((cause) => new ReconcileError({ message: `Jira worklog fetch failed for ${issueKey}: ${String(cause)}`, cause })));
478
436
  const { maxResults, startAt, total, worklogs: entries } = page;
479
437
  if (startAt !== nextStartAt ||
480
438
  maxResults === undefined || !Number.isSafeInteger(maxResults) || maxResults < 0 ||
@@ -507,7 +465,12 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
507
465
  }
508
466
  });
509
467
  // Tally the current user's Jira worklogs in the period by (ticket, day).
510
- const jiraTally = (period, requiredIssueKeys = [], pinned = undefined) => Effect.gen(function* () {
468
+ const jiraTally = (period, requiredIssueKeys = [], pinned = undefined,
469
+ /**
470
+ * `window` searches every issue I logged in the period and adds the required keys;
471
+ * `required-only` reads just the required keys, to top up a tally that already searched.
472
+ */
473
+ scope = "window") => Effect.gen(function* () {
511
474
  // A verified snapshot pins the account, site and credential-backed client for every page.
512
475
  // Falling back is only for a side without a usable write credential; that side cannot be
513
476
  // written because its source scope remains null, but independent providers may still proceed.
@@ -542,19 +505,19 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
542
505
  // "Jira is short by the whole day" — which `watch` would then post on top of hours Jira
543
506
  // already holds. A window of more than a hundred distinct issues is an ordinary week.
544
507
  const jql = `worklogAuthor = currentUser() AND worklogDate >= "${fromDay}" AND worklogDate <= "${toDay}"`;
545
- const searchPage = (pageToken) => client.searchIssuesUsingJql({
508
+ const searchPage = (pageToken) => retryTransport(client.searchIssuesUsingJql({
546
509
  params: {
547
510
  jql,
548
511
  maxResults: 100,
549
512
  fields: ["key"],
550
513
  ...((pageToken !== undefined) && { nextPageToken: pageToken })
551
514
  }
552
- }).pipe(Effect.mapError((e) => new ReconcileError({ message: `Jira search failed: ${String(e)}`, cause: e })));
515
+ })).pipe(Effect.mapError((e) => new ReconcileError({ message: `Jira search failed: ${String(e)}`, cause: e })));
553
516
  const searched = [];
554
517
  let pageToken = undefined;
555
518
  // Bounded so a server that keeps handing back a token cannot spin here for ever; hitting the
556
519
  // bound fails the run rather than proceeding on a partial tally.
557
- for (let page = 0; page < 50; page++) {
520
+ for (let page = 0; scope === "window" && page < 50; page++) {
558
521
  const result = yield* searchPage(pageToken);
559
522
  const issues = Predicate.isObject(result) ? result["issues"] : undefined;
560
523
  // A page with no `issues` at all is not an empty page. The generated success schema makes
@@ -601,8 +564,12 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
601
564
  }
602
565
  const toMs = period.to.getTime();
603
566
  const tally = [];
604
- for (const issueKey of issueKeys) {
605
- const worklogs = yield* readJiraWorklogs(client, issueKey, lookupFromMs - 1, toMs);
567
+ // Every issue's worklogs are read before any is tallied, a few at a time: a week of forty
568
+ // tickets read one after another made every confirmation wait on forty round trips. Tallying
569
+ // stays in issue order, so the result is the same as a sequential read.
570
+ const worklogsByIssue = yield* Effect.forEach(issueKeys, (issueKey) => readJiraWorklogs(client, issueKey, lookupFromMs - 1, toMs), { concurrency: JIRA_WORKLOG_READ_CONCURRENCY });
571
+ for (const [issueIndex, issueKey] of issueKeys.entries()) {
572
+ const worklogs = worklogsByIssue[issueIndex] ?? [];
606
573
  for (const wl of worklogs) {
607
574
  // Only this user's worklogs (the JQL narrows issues, not individual worklog authors).
608
575
  const author = wl.author?.accountId;
@@ -641,9 +608,11 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
641
608
  // A side that is out is not called at all. That is the point of the option for someone who
642
609
  // tracks in one system: no Clockify workspace to configure, no Jira login to keep alive, and
643
610
  // 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([]);
611
+ const jiraSideRead = !sides.jira
612
+ ? Effect.succeed([])
613
+ : options?.jiraPreRead !== undefined
614
+ ? Effect.succeed(options.jiraPreRead)
615
+ : jiraTally(period, options?.jiraIssueKeys, options?.jiraSnapshot);
647
616
  const [clockifySide, jiraSide] = yield* Effect.all([
648
617
  sides.clockify
649
618
  ? clockifyTally(period, options?.clockifySnapshot)
@@ -1118,7 +1087,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1118
1087
  // `attributeSession` remains the single place the precedence is actually applied.
1119
1088
  const needsAgent = options.mode === "deterministic" ?
1120
1089
  [] :
1121
- sessions.filter((session) => deterministicAttribution(session, { standingMap: options.standingMap }) === null &&
1090
+ sessions.filter((session) => deterministicAttribution(session, options) === null &&
1122
1091
  session.candidateKeys.length > 0);
1123
1092
  const batches = batchRequests(needsAgent);
1124
1093
  if (needsAgent.length > 0) {
@@ -1135,7 +1104,11 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1135
1104
  const batchResults = yield* Effect.all(batches.map((batch, batchIndex) => attributor.attribute(batch.map((session) => ({
1136
1105
  sessionId: session.sessionId,
1137
1106
  candidateKeys: session.candidateKeys,
1138
- digest: session.digest
1107
+ digest: session.digest,
1108
+ candidateFacts: new Map(session.candidateKeys.flatMap((key) => {
1109
+ const fact = options.candidateFact?.(key);
1110
+ return fact === undefined ? [] : [[key, fact]];
1111
+ }))
1139
1112
  })), (activity) => options.onProgress({
1140
1113
  _tag: "AgentActivity",
1141
1114
  batch: batchIndex + 1,
@@ -1145,7 +1118,10 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1145
1118
  const bySession = new Map(answers.map((answer) => [answer.sessionId, answer.choice]));
1146
1119
  return batch.map((session) => {
1147
1120
  const declined = { _tag: "None" };
1148
- const choice = bySession.get(session.sessionId) ?? declined;
1121
+ const answer = bySession.get(session.sessionId) ?? declined;
1122
+ const choice = answer._tag === "Chosen" && options.ignoredTickets.has(answer.ticketKey)
1123
+ ? declined
1124
+ : answer;
1149
1125
  return {
1150
1126
  session,
1151
1127
  choice,
@@ -1200,7 +1176,8 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1200
1176
  const attributions = sessions.map((session) => attributeSession(session, {
1201
1177
  standingMap: options.standingMap,
1202
1178
  agentChoice: chosen.get(session.sessionId) ?? null,
1203
- confidenceFloor: options.confidenceFloor
1179
+ confidenceFloor: options.confidenceFloor,
1180
+ ignoredTickets: options.ignoredTickets
1204
1181
  }));
1205
1182
  return { attributions, attributorAvailable, attributorCalls: batches.length };
1206
1183
  });
@@ -1224,15 +1201,40 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1224
1201
  return requests.map((request) => byId.get(request.id) ?? null);
1225
1202
  });
1226
1203
  /** 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) {
1204
+ const refreshRecordedTime = Effect.fn("ReconcileService.refreshRecordedTime")(function* (period, cached, options,
1205
+ /** A Jira write state the caller already resolved, so one read never verifies twice. */
1206
+ pinnedJiraState,
1207
+ /**
1208
+ * A Jira tally taken under `pinnedJiraState` earlier in the same read, with the issue keys it
1209
+ * was required to cover. Reused only when it covers every key this refresh needs.
1210
+ */
1211
+ preReadJira) {
1212
+ const ignoredTickets = new Set((yield* config.get).sessionIgnoredTickets);
1213
+ const ignored = [...new Map([
1214
+ ...ignoredCredits(cached.sessionEvidence ?? [], ignoredTickets),
1215
+ ...cached.ignored.filter((entry) => ignoredTickets.has(entry.ticketKey))
1216
+ ].map((entry) => [`${entry.ticketKey}:${entry.day}`, entry])).values()];
1217
+ const previous = {
1218
+ ...cached,
1219
+ attributed: cached.attributed.filter((row) => !ignoredTickets.has(row.ticketKey)),
1220
+ withheld: cached.withheld.filter((row) => !ignoredTickets.has(row.ticketKey)),
1221
+ ignoredTickets: [...ignoredTickets].sort(),
1222
+ ignored
1223
+ };
1228
1224
  const { sides } = previous;
1225
+ const writeBlocked = {};
1229
1226
  const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1230
1227
  const clockifySnapshot = sides.clockify ? yield* clockifyWriteSnapshot : null;
1231
1228
  if (clockifySnapshot !== null) {
1232
- yield* sourceLedger.assertNoLegacyClockify(clockifySnapshot.legacyScope).pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1229
+ yield* sourceLedger.assertNoLegacyClockify(clockifySnapshot.legacyScope).pipe(Effect.catchTag("SourceLedgerError", (cause) => {
1230
+ if (cause.reason !== "review-required")
1231
+ return Effect.fail(cause);
1232
+ writeBlocked.clockify = cause.message;
1233
+ return Effect.void;
1234
+ }), Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1233
1235
  }
1234
1236
  const clockifyScope = clockifySnapshot?.scope ?? null;
1235
- const jiraState = sides.jira ? yield* jiraWriteState : undefined;
1237
+ const jiraState = sides.jira ? pinnedJiraState ?? (yield* jiraWriteState) : undefined;
1236
1238
  const jiraSnapshot = jiraState?.snapshot ?? null;
1237
1239
  // Compatibility reads may still render Jira totals, but only one verified snapshot may grant
1238
1240
  // durable source authority. Independent Clockify work remains executable while Jira is held.
@@ -1259,10 +1261,31 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1259
1261
  ...(options?.jiraIssueKeys ?? [])
1260
1262
  ])
1261
1263
  ];
1264
+ // Covered means read directly or found holding my time; anything else is read fresh, so reuse
1265
+ // is an optimization the result never depends on.
1266
+ const covered = new Set([
1267
+ ...(preReadJira?.requiredKeys ?? []),
1268
+ ...(preReadJira?.tally ?? []).map((row) => row.ticketKey)
1269
+ ]);
1270
+ // Keys the pre-read did not cover — usually tickets the agent or a split chose — are read on
1271
+ // their own and merged. They held none of my time in the pre-read's search, so their rows
1272
+ // cannot duplicate it. If the top-up fails, the refresh falls back to a full read.
1273
+ const missingKeys = jiraIssueKeys.filter((key) => !covered.has(key));
1274
+ const topUp = preReadJira === undefined || pinnedJiraState === undefined || missingKeys.length === 0
1275
+ ? undefined
1276
+ : yield* Effect.result(jiraTally(period, missingKeys, jiraSnapshot, "required-only"));
1277
+ const jiraPreRead = preReadJira === undefined || pinnedJiraState === undefined
1278
+ ? undefined
1279
+ : missingKeys.length === 0
1280
+ ? preReadJira.tally
1281
+ : topUp?._tag === "Success"
1282
+ ? [...preReadJira.tally, ...topUp.success]
1283
+ : undefined;
1262
1284
  const [{ recorded, unlinkedClockify }, excludedDays] = yield* Effect.all([
1263
1285
  readRecorded(period, {
1264
1286
  sides,
1265
1287
  jiraIssueKeys,
1288
+ jiraPreRead,
1266
1289
  clockifySnapshot: clockifySnapshot ?? undefined,
1267
1290
  jiraSnapshot,
1268
1291
  tolerateUnavailableJira: jiraState?.availability !== "verified"
@@ -1329,19 +1352,23 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1329
1352
  scope,
1330
1353
  fromMs: period.from.getTime(),
1331
1354
  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 })));
1355
+ }, observed.map((entry) => ({ entryId: entry.id, startMs: entry.startMs })), verified, provider === "clockify" ? clockifySnapshot?.legacyScope : undefined).pipe(Effect.catchTag("SourceLedgerError", (cause) => {
1356
+ if (cause.reason !== "review-required")
1357
+ return Effect.fail(cause);
1358
+ writeBlocked[provider] = cause.message;
1359
+ return Effect.void;
1360
+ }), Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1333
1361
  }
1334
1362
  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) => {
1363
+ if (previous.attributed.length > 0) {
1364
+ for (const entry of entries) {
1337
1365
  const scope = entry.source === "clockify" ? clockifyScope : jiraScope;
1338
- return scope !== null && entry.description !== null &&
1366
+ if (scope !== null && entry.description !== null &&
1339
1367
  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
- });
1368
+ !verifiedLedger.bindings.some((binding) => binding.provider === entry.source && binding.entryId === entry.id && binding.scope === scope)) {
1369
+ writeBlocked[entry.source] = "An unlinked source marker needs private manual review before session writes";
1370
+ }
1371
+ }
1345
1372
  }
1346
1373
  const sourceEntries = verifiedLedger.bindings.filter((binding) => binding.provider === "clockify" ? binding.scope === clockifyScope : binding.scope === jiraScope).flatMap((binding) => {
1347
1374
  const entry = entries.find((candidate) => candidate.source === binding.provider && candidate.id === binding.entryId);
@@ -1357,6 +1384,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1357
1384
  const executableRecorded = recordedForExecution(recorded, jiraState?.availability);
1358
1385
  return {
1359
1386
  ...previous,
1387
+ writeBlocked,
1360
1388
  proposals: buildSessionProposals(previous.attributed, executableRecorded, {
1361
1389
  minimumSeconds: MINIMUM_PROPOSAL_SECONDS,
1362
1390
  excludedDays: excludedDays.map((excluded) => excluded.day),
@@ -1366,7 +1394,7 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1366
1394
  day: slice.day,
1367
1395
  intervals: slice.entry === undefined ? [] : [{ entry: slice.entry }]
1368
1396
  }))
1369
- }),
1397
+ }).map((proposal) => ({ ...proposal, writeBlocked })),
1370
1398
  recorded,
1371
1399
  unlinkedClockify,
1372
1400
  sourceEntries,
@@ -1378,13 +1406,72 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1378
1406
  const proposeFromSessions = (period, options) => Effect.gen(function* () {
1379
1407
  const onProgress = options?.onProgress ?? (() => Effect.void);
1380
1408
  const cfg = yield* config.get;
1409
+ const ignoredTickets = new Set(cfg.sessionIgnoredTickets);
1410
+ const standingMap = expandedStandingMap(cfg.sessionTicketMap, home);
1381
1411
  const sessions = yield* sessionReader.read(period).pipe(Effect.mapError((error) => new ReconcileError({ message: error.message, cause: error })));
1382
1412
  yield* onProgress({ _tag: "SessionsRead", count: sessions.length });
1383
- const { attributions, attributorAvailable, attributorCalls } = yield* attributeSessions(sessions, {
1384
- standingMap: expandedStandingMap(cfg.sessionTicketMap, home),
1413
+ const sides = options?.sides ?? bothSides;
1414
+ // Only a run about Jira reads Jira to rank: a Clockify-only week makes no Jira request. The
1415
+ // verified profile's client, so a sprint is never read from a different account's site.
1416
+ // Resolved once for the whole read: ranking and the first recorded refresh must see the
1417
+ // same verified authority, and resolving twice can observe two different answers. The pin
1418
+ // never outlives the read: every write path refreshes without it and verifies afresh.
1419
+ const jiraState = sides.jira ? yield* jiraWriteState : undefined;
1420
+ const rankingSnapshot = jiraState?.snapshot ?? undefined;
1421
+ // The Jira tally is read before attribution, so ranking and the attribution prompt see who
1422
+ // logged what, and the first refresh reuses it. Branch, path and standing targets and
1423
+ // ledger-bound tickets are required reads; any other candidate the tally lacks is unlogged.
1424
+ const preReadJira = rankingSnapshot === undefined ? undefined : yield* Effect.gen(function* () {
1425
+ const stored = yield* sourceLedger.read.pipe(Effect.mapError((cause) => new ReconcileError({ message: cause.message, cause })));
1426
+ const requiredKeys = [
1427
+ ...new Set([
1428
+ ...sessions.flatMap((session) => {
1429
+ const target = deterministicAttribution(session, { standingMap });
1430
+ return target === null || ignoredTickets.has(target.ticketKey) ? [] : [target.ticketKey];
1431
+ }),
1432
+ ...stored.bindings.filter((binding) => binding.provider === "jira" && binding.scope === rankingSnapshot.ledgerScope).map((binding) => binding.ticketKey)
1433
+ ])
1434
+ ];
1435
+ const tally = yield* Effect.result(jiraTally(period, requiredKeys, rankingSnapshot));
1436
+ return tally._tag === "Success" ? { tally: tally.success, requiredKeys } : undefined;
1437
+ });
1438
+ const ranking = rankingSnapshot !== undefined
1439
+ ? yield* readRankingFacts((jql, nextPageToken) => retryTransport(rankingSnapshot.client.searchIssuesUsingJql({
1440
+ params: {
1441
+ jql,
1442
+ maxResults: 100,
1443
+ fields: ["key"],
1444
+ ...((nextPageToken !== undefined) && { nextPageToken })
1445
+ }
1446
+ })), preReadJira?.tally)
1447
+ : {
1448
+ _tag: "Unknown",
1449
+ reason: sides.jira ? "Jira account is not verified" : "Jira is out of scope"
1450
+ };
1451
+ const ignoredBySession = new Map(sessions.flatMap((session) => {
1452
+ const original = deterministicAttribution(session, { standingMap });
1453
+ return original === null || !ignoredTickets.has(original.ticketKey)
1454
+ ? []
1455
+ : [[session.sessionId, original.ticketKey]];
1456
+ }));
1457
+ const { attributions: primaryAttributions, attributorAvailable, attributorCalls } = yield* attributeSessions(sessions.map((session) => ({
1458
+ ...session,
1459
+ candidateKeys: session.candidateKeys.filter((key) => !ignoredTickets.has(key))
1460
+ })), {
1461
+ standingMap,
1462
+ ignoredTickets,
1385
1463
  confidenceFloor: cfg.sessionConfidenceFloor,
1386
1464
  mode: options?.attribution ?? "full",
1387
- onProgress
1465
+ onProgress,
1466
+ candidateFact: candidateFact(ranking)
1467
+ });
1468
+ // A split is a guess from mention counts: a deterministic read reports the session unplaced
1469
+ // instead, so an unattended watch never downgrades a branch-placed row to `split`.
1470
+ const attributions = primaryAttributions.map((attribution, index) => {
1471
+ const session = sessions[index];
1472
+ return session === undefined || options?.attribution === "deterministic" ?
1473
+ attribution :
1474
+ splitSessionAttribution(session, attribution, ranking._tag === "Known" && jiraState?.availability === "verified" ? ranking.sprint : undefined, ignoredTickets);
1388
1475
  });
1389
1476
  // Windows first, then attribution, then sharing — in that order, because time is divided
1390
1477
  // between distinct Issue Keys, not between sessions. Two sessions on one ticket must union
@@ -1397,27 +1484,43 @@ export const layer = Layer.effect(ReconcileService, Effect.gen(function* () {
1397
1484
  observedAtMs: Math.min(period.to.getTime(), nowMs),
1398
1485
  boundsBySession: new Map(sessions.flatMap((session) => session.boundedAtMs === null ? [] : [[session.sessionId, session.boundedAtMs]]))
1399
1486
  });
1400
- const split = splitCredits(windows, attributions, {
1487
+ const attributionBySession = new Map(attributions.map((attribution) => [attribution.sessionId, attribution]));
1488
+ const ignored = ignoredCredits(windows.map((window) => ({
1489
+ ...window,
1490
+ ticketKey: ignoredBySession.get(window.sessionId) ?? null
1491
+ })), ignoredTickets);
1492
+ const eligibleWindows = windows.filter((window) => {
1493
+ const key = attributionBySession.get(window.sessionId)?.ticketKey;
1494
+ return !ignoredBySession.has(window.sessionId) ||
1495
+ (attributionBySession.get(window.sessionId)?.targets?.length ?? 0) > 0 ||
1496
+ (key !== null && key !== undefined && !ignoredTickets.has(key));
1497
+ });
1498
+ const split = splitCredits(eligibleWindows, attributions, {
1401
1499
  cwdBySession: new Map(sessions.map((session) => [session.sessionId, session.cwd])),
1402
- dwellSeconds: cfg.sessionDwellSeconds
1500
+ dwellSeconds: cfg.sessionDwellSeconds,
1501
+ priority: ticketPriority(ranking),
1502
+ ignoredTickets
1403
1503
  });
1404
- const sides = options?.sides ?? bothSides;
1405
1504
  yield* onProgress({ _tag: "ReadingRecordedTime", sides });
1406
1505
  return yield* refreshRecordedTime(period, {
1407
1506
  attributed: split.attributed,
1408
- sessionEvidence: windows.map((window) => ({
1409
- ...window,
1410
- ticketKey: attributions.find((attribution) => attribution.sessionId === window.sessionId)?.ticketKey ?? null
1411
- })),
1507
+ sessionEvidence: windows.flatMap((window) => {
1508
+ const attribution = attributionBySession.get(window.sessionId);
1509
+ const keys = attribution?.targets?.map((target) => target.ticketKey) ??
1510
+ [attribution?.ticketKey ?? ignoredBySession.get(window.sessionId) ?? null];
1511
+ return keys.map((ticketKey) => ({ ...window, ticketKey }));
1512
+ }),
1412
1513
  sides,
1413
1514
  withheld: split.withheld,
1414
1515
  unattributed: split.unattributed,
1516
+ ignored,
1517
+ ignoredTickets: [...ignoredTickets].sort(),
1415
1518
  attributorAvailable,
1416
1519
  sessionCount: sessions.length,
1417
1520
  sessionRootCount: cfg.sessionRoots.length,
1418
1521
  attributorCalls,
1419
1522
  digests: new Map(sessions.map((session) => [session.sessionId, session.digest]))
1420
- });
1523
+ }, undefined, jiraState, preReadJira);
1421
1524
  });
1422
1525
  return {
1423
1526
  compare,