@knpkv/jira-clockify 1.3.0 → 1.4.1
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.
- package/README.md +245 -1
- package/dist/package.json +36 -12
- package/dist/src/agent/agentSettings.d.ts +18 -0
- package/dist/src/agent/agentSettings.d.ts.map +1 -0
- package/dist/src/agent/agentSettings.js +23 -0
- package/dist/src/agent/agentSettings.js.map +1 -0
- package/dist/src/agent/schedule.d.ts +20 -0
- package/dist/src/agent/schedule.d.ts.map +1 -0
- package/dist/src/agent/schedule.js +189 -0
- package/dist/src/agent/schedule.js.map +1 -0
- package/dist/src/agent/sessions.d.ts +427 -0
- package/dist/src/agent/sessions.d.ts.map +1 -0
- package/dist/src/agent/sessions.js +868 -0
- package/dist/src/agent/sessions.js.map +1 -0
- package/dist/src/agent/sourceConsumption.d.ts +79 -0
- package/dist/src/agent/sourceConsumption.d.ts.map +1 -0
- package/dist/src/agent/sourceConsumption.js +191 -0
- package/dist/src/agent/sourceConsumption.js.map +1 -0
- package/dist/src/agent/watch.d.ts +76 -0
- package/dist/src/agent/watch.d.ts.map +1 -0
- package/dist/src/agent/watch.js +45 -0
- package/dist/src/agent/watch.js.map +1 -0
- package/dist/src/agent/writePlanning.d.ts +154 -0
- package/dist/src/agent/writePlanning.d.ts.map +1 -0
- package/dist/src/agent/writePlanning.js +210 -0
- package/dist/src/agent/writePlanning.js.map +1 -0
- package/dist/src/bin.d.ts +3 -0
- package/dist/src/bin.d.ts.map +1 -0
- package/dist/src/bin.js +9 -3
- package/dist/src/bin.js.map +1 -1
- package/dist/src/cli/agentWrite.d.ts +164 -0
- package/dist/src/cli/agentWrite.d.ts.map +1 -0
- package/dist/src/cli/agentWrite.js +362 -0
- package/dist/src/cli/agentWrite.js.map +1 -0
- package/dist/src/cli/auth.d.ts +8 -0
- package/dist/src/cli/auth.d.ts.map +1 -0
- package/dist/src/cli/auth.js +10 -10
- package/dist/src/cli/auth.js.map +1 -1
- package/dist/src/cli/calendar.d.ts +42 -0
- package/dist/src/cli/calendar.d.ts.map +1 -0
- package/dist/src/cli/calendar.js +189 -0
- package/dist/src/cli/calendar.js.map +1 -0
- package/dist/src/cli/config.d.ts +12 -0
- package/dist/src/cli/config.d.ts.map +1 -0
- package/dist/src/cli/config.js +163 -21
- package/dist/src/cli/config.js.map +1 -1
- package/dist/src/cli/fetchTicket.d.ts +51 -0
- package/dist/src/cli/fetchTicket.d.ts.map +1 -0
- package/dist/src/cli/fetchTicket.js +18 -2
- package/dist/src/cli/fetchTicket.js.map +1 -1
- package/dist/src/cli/fuzzySelect.d.ts +24 -0
- package/dist/src/cli/fuzzySelect.d.ts.map +1 -0
- package/dist/src/cli/layers.d.ts +40 -0
- package/dist/src/cli/layers.d.ts.map +1 -0
- package/dist/src/cli/layers.js +57 -13
- package/dist/src/cli/layers.js.map +1 -1
- package/dist/src/cli/list.d.ts +8 -0
- package/dist/src/cli/list.d.ts.map +1 -0
- package/dist/src/cli/list.js +2 -2
- package/dist/src/cli/list.js.map +1 -1
- package/dist/src/cli/reconcile.d.ts +129 -0
- package/dist/src/cli/reconcile.d.ts.map +1 -0
- package/dist/src/cli/reconcile.js +635 -46
- package/dist/src/cli/reconcile.js.map +1 -1
- package/dist/src/cli/root.d.ts +15 -0
- package/dist/src/cli/root.d.ts.map +1 -0
- package/dist/src/cli/root.js +4 -3
- package/dist/src/cli/root.js.map +1 -1
- package/dist/src/cli/runtimeFailure.d.ts +10 -0
- package/dist/src/cli/runtimeFailure.d.ts.map +1 -0
- package/dist/src/cli/runtimeFailure.js +17 -0
- package/dist/src/cli/runtimeFailure.js.map +1 -0
- package/dist/src/cli/setup.d.ts +10 -0
- package/dist/src/cli/setup.d.ts.map +1 -0
- package/dist/src/cli/setup.js +7 -7
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/timer/discard.d.ts +4 -0
- package/dist/src/cli/timer/discard.d.ts.map +1 -0
- package/dist/src/cli/timer/discard.js +2 -2
- package/dist/src/cli/timer/discard.js.map +1 -1
- package/dist/src/cli/timer/edit.d.ts +22 -0
- package/dist/src/cli/timer/edit.d.ts.map +1 -0
- package/dist/src/cli/timer/edit.js +62 -42
- package/dist/src/cli/timer/edit.js.map +1 -1
- package/dist/src/cli/timer/index.d.ts +12 -0
- package/dist/src/cli/timer/index.d.ts.map +1 -0
- package/dist/src/cli/timer/log.d.ts +16 -0
- package/dist/src/cli/timer/log.d.ts.map +1 -0
- package/dist/src/cli/timer/log.js +6 -6
- package/dist/src/cli/timer/log.js.map +1 -1
- package/dist/src/cli/timer/start.d.ts +21 -0
- package/dist/src/cli/timer/start.d.ts.map +1 -0
- package/dist/src/cli/timer/start.js +9 -9
- package/dist/src/cli/timer/start.js.map +1 -1
- package/dist/src/cli/timer/status.d.ts +22 -0
- package/dist/src/cli/timer/status.d.ts.map +1 -0
- package/dist/src/cli/timer/status.js +3 -3
- package/dist/src/cli/timer/status.js.map +1 -1
- package/dist/src/cli/timer/stop.d.ts +48 -0
- package/dist/src/cli/timer/stop.d.ts.map +1 -0
- package/dist/src/cli/timer/stop.js +19 -19
- package/dist/src/cli/timer/stop.js.map +1 -1
- package/dist/src/cli/timer.d.ts +11 -0
- package/dist/src/cli/timer.d.ts.map +1 -0
- package/dist/src/cli/timer.js +1 -1
- package/dist/src/cli/timer.js.map +1 -1
- package/dist/src/cli/watch.d.ts +54 -0
- package/dist/src/cli/watch.d.ts.map +1 -0
- package/dist/src/cli/watch.js +412 -0
- package/dist/src/cli/watch.js.map +1 -0
- package/dist/src/cli/watchLease.d.ts +99 -0
- package/dist/src/cli/watchLease.d.ts.map +1 -0
- package/dist/src/cli/watchLease.js +204 -0
- package/dist/src/cli/watchLease.js.map +1 -0
- package/dist/src/cli/writerGuard.d.ts +37 -0
- package/dist/src/cli/writerGuard.d.ts.map +1 -0
- package/dist/src/cli/writerGuard.js +92 -0
- package/dist/src/cli/writerGuard.js.map +1 -0
- package/dist/src/index.d.ts +46 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +46 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/main.d.ts +4 -0
- package/dist/src/main.d.ts.map +1 -0
- package/dist/src/services/AgentSessionReader.d.ts +121 -0
- package/dist/src/services/AgentSessionReader.d.ts.map +1 -0
- package/dist/src/services/AgentSessionReader.js +392 -0
- package/dist/src/services/AgentSessionReader.js.map +1 -0
- package/dist/src/services/ClockifyAuth.d.ts +47 -0
- package/dist/src/services/ClockifyAuth.d.ts.map +1 -0
- package/dist/src/services/ClockifyAuth.js +2 -2
- package/dist/src/services/ClockifyAuth.js.map +1 -1
- package/dist/src/services/CodexTranscript.d.ts +112 -0
- package/dist/src/services/CodexTranscript.d.ts.map +1 -0
- package/dist/src/services/CodexTranscript.js +96 -0
- package/dist/src/services/CodexTranscript.js.map +1 -0
- package/dist/src/services/ConfigService.d.ts +99 -0
- package/dist/src/services/ConfigService.d.ts.map +1 -0
- package/dist/src/services/ConfigService.js +79 -7
- package/dist/src/services/ConfigService.js.map +1 -1
- package/dist/src/services/HomeDirectory.d.ts +17 -0
- package/dist/src/services/HomeDirectory.d.ts.map +1 -0
- package/dist/src/services/HomeDirectory.js +1 -1
- package/dist/src/services/IssueFacts.d.ts +127 -0
- package/dist/src/services/IssueFacts.d.ts.map +1 -0
- package/dist/src/services/IssueFacts.js +277 -0
- package/dist/src/services/IssueFacts.js.map +1 -0
- package/dist/src/services/ProviderDeletion.d.ts +19 -0
- package/dist/src/services/ProviderDeletion.d.ts.map +1 -0
- package/dist/src/services/ProviderDeletion.js +37 -0
- package/dist/src/services/ProviderDeletion.js.map +1 -0
- package/dist/src/services/ReconcileService.d.ts +378 -0
- package/dist/src/services/ReconcileService.d.ts.map +1 -0
- package/dist/src/services/ReconcileService.js +1270 -79
- package/dist/src/services/ReconcileService.js.map +1 -1
- package/dist/src/services/SavedEntries.d.ts +40 -0
- package/dist/src/services/SavedEntries.d.ts.map +1 -0
- package/dist/src/services/SavedEntries.js +209 -0
- package/dist/src/services/SavedEntries.js.map +1 -0
- package/dist/src/services/SessionAttributor.d.ts +114 -0
- package/dist/src/services/SessionAttributor.d.ts.map +1 -0
- package/dist/src/services/SessionAttributor.js +240 -0
- package/dist/src/services/SessionAttributor.js.map +1 -0
- package/dist/src/services/SourceLedger.d.ts +104 -0
- package/dist/src/services/SourceLedger.d.ts.map +1 -0
- package/dist/src/services/SourceLedger.js +302 -0
- package/dist/src/services/SourceLedger.js.map +1 -0
- package/dist/src/services/StateWriter.d.ts +39 -0
- package/dist/src/services/StateWriter.d.ts.map +1 -0
- package/dist/src/services/TicketService.d.ts +63 -0
- package/dist/src/services/TicketService.d.ts.map +1 -0
- package/dist/src/services/TimerService.d.ts +145 -0
- package/dist/src/services/TimerService.d.ts.map +1 -0
- package/dist/src/services/TimerService.js +113 -80
- package/dist/src/services/TimerService.js.map +1 -1
- package/dist/src/services/internal/JiraWorklogPost.d.ts +7 -0
- package/dist/src/services/internal/JiraWorklogPost.d.ts.map +1 -0
- package/dist/src/services/internal/JiraWorklogPost.js +32 -0
- package/dist/src/services/internal/JiraWorklogPost.js.map +1 -0
- package/dist/src/testing/fakeHeadless.d.ts +318 -0
- package/dist/src/testing/fakeHeadless.d.ts.map +1 -0
- package/dist/src/testing/fakeHeadless.js +1149 -0
- package/dist/src/testing/fakeHeadless.js.map +1 -0
- package/dist/src/tui/App.d.ts +6 -0
- package/dist/src/tui/App.d.ts.map +1 -0
- package/dist/src/tui/App.js +1 -1
- package/dist/src/tui/App.js.map +1 -1
- package/dist/src/tui/atoms/runtime.d.ts +8 -0
- package/dist/src/tui/atoms/runtime.d.ts.map +1 -0
- package/dist/src/tui/atoms/runtime.js +1 -1
- package/dist/src/tui/atoms/runtime.js.map +1 -1
- package/dist/src/tui/atoms/tickets.d.ts +3 -0
- package/dist/src/tui/atoms/tickets.d.ts.map +1 -0
- package/dist/src/tui/atoms/timer.d.ts +11 -0
- package/dist/src/tui/atoms/timer.d.ts.map +1 -0
- package/dist/src/tui/atoms/timer.js +1 -1
- package/dist/src/tui/atoms/timer.js.map +1 -1
- package/dist/src/tui/atoms/ui.d.ts +11 -0
- package/dist/src/tui/atoms/ui.d.ts.map +1 -0
- package/dist/src/tui/atoms/ui.js +1 -1
- package/dist/src/tui/atoms/ui.js.map +1 -1
- package/dist/src/tui/components/BigTimer.d.ts +2 -0
- package/dist/src/tui/components/BigTimer.d.ts.map +1 -0
- package/dist/src/tui/components/Footer.d.ts +2 -0
- package/dist/src/tui/components/Footer.d.ts.map +1 -0
- package/dist/src/tui/components/Header.d.ts +2 -0
- package/dist/src/tui/components/Header.d.ts.map +1 -0
- package/dist/src/tui/components/Header.js +1 -1
- package/dist/src/tui/components/Header.js.map +1 -1
- package/dist/src/tui/components/PopupInput.d.ts +18 -0
- package/dist/src/tui/components/PopupInput.d.ts.map +1 -0
- package/dist/src/tui/components/PopupMessage.d.ts +20 -0
- package/dist/src/tui/components/PopupMessage.d.ts.map +1 -0
- package/dist/src/tui/components/TicketList.d.ts +2 -0
- package/dist/src/tui/components/TicketList.d.ts.map +1 -0
- package/dist/src/tui/components/TicketList.js +1 -1
- package/dist/src/tui/components/TicketList.js.map +1 -1
- package/dist/src/tui/components/TicketRow.d.ts +8 -0
- package/dist/src/tui/components/TicketRow.d.ts.map +1 -0
- package/dist/src/tui/components/TimerDisplay.d.ts +2 -0
- package/dist/src/tui/components/TimerDisplay.d.ts.map +1 -0
- package/dist/src/tui/components/index.d.ts +12 -0
- package/dist/src/tui/components/index.d.ts.map +1 -0
- package/dist/src/tui/context/theme.d.ts +15 -0
- package/dist/src/tui/context/theme.d.ts.map +1 -0
- package/dist/src/tui/hooks/useElapsedTimer.d.ts +6 -0
- package/dist/src/tui/hooks/useElapsedTimer.d.ts.map +1 -0
- package/dist/src/tui/hooks/useElapsedTimer.js +1 -1
- package/dist/src/tui/hooks/useElapsedTimer.js.map +1 -1
- package/dist/src/tui/hooks/useTerminalSize.d.ts +5 -0
- package/dist/src/tui/hooks/useTerminalSize.d.ts.map +1 -0
- package/dist/src/utils/hints.d.ts +12 -0
- package/dist/src/utils/hints.d.ts.map +1 -0
- package/dist/src/utils/hints.js +12 -0
- package/dist/src/utils/hints.js.map +1 -0
- package/dist/src/utils/time.d.ts +104 -0
- package/dist/src/utils/time.d.ts.map +1 -0
- package/dist/src/utils/time.js +74 -8
- package/dist/src/utils/time.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +36 -18
- package/skills/jcf/SKILL.md +93 -1
|
@@ -2,23 +2,28 @@
|
|
|
2
2
|
* `jcf sync reconcile` — compare Clockify time against Jira worklogs over a period and
|
|
3
3
|
* fill gaps one row at a time.
|
|
4
4
|
*
|
|
5
|
+
* With `--agent claude` the same command switches to a different *source of evidence*: local
|
|
6
|
+
* Claude Code Agent Sessions become Proposed Worklogs for the time neither side recorded. Agent
|
|
7
|
+
* Sessions are never a third side of the reconciliation — see ADR-0006.
|
|
8
|
+
*
|
|
5
9
|
* @module
|
|
6
10
|
*/
|
|
7
|
-
import { Console, Effect, Option } from "effect";
|
|
8
|
-
import { Argument as Args, Command, Flag as Options, Prompt } from "effect/
|
|
11
|
+
import { Console, Data, Effect, Option, Runtime } from "effect";
|
|
12
|
+
import { Argument as Args, Command, Flag as Options, Prompt } from "effect/cli";
|
|
13
|
+
import * as Terminal from "effect/Terminal";
|
|
14
|
+
import { ConfigService } from "../services/ConfigService.js";
|
|
15
|
+
import { isOwnedByMe, IssueFacts } from "../services/IssueFacts.js";
|
|
9
16
|
import { ReconcileService } from "../services/ReconcileService.js";
|
|
10
|
-
import { formatDuration } from "../utils/time.js";
|
|
11
|
-
import {
|
|
17
|
+
import { formatDuration, localDay, nextLocalMidnight } from "../utils/time.js";
|
|
18
|
+
import { applyProposal, clip, entryDescription, keepGoing, proposalTargets, writeOutcomeLines } from "./agentWrite.js";
|
|
19
|
+
import { earliestStart, formatSpanBounds, formatSpanRanges, renderDayCalendar } from "./calendar.js";
|
|
20
|
+
import { fetchTicketByKey, NOT_LOGGED_IN_HINT } from "./fetchTicket.js";
|
|
21
|
+
import * as WatchLease from "./watchLease.js";
|
|
22
|
+
import * as WriterGuard from "./writerGuard.js";
|
|
12
23
|
// Differences under a minute are noise (Jira floors worklogs to 60s), so don't flag them.
|
|
13
24
|
const TOLERANCE_SECONDS = 60;
|
|
14
25
|
/** Start of a local calendar day `YYYY-MM-DD` (00:00 local). */
|
|
15
26
|
const startOfDay = (day) => new Date(`${day}T00:00:00`);
|
|
16
|
-
const localDay = (date) => {
|
|
17
|
-
const y = date.getFullYear();
|
|
18
|
-
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
19
|
-
const d = String(date.getDate()).padStart(2, "0");
|
|
20
|
-
return `${y}-${m}-${d}`;
|
|
21
|
-
};
|
|
22
27
|
const isYmd = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
23
28
|
/**
|
|
24
29
|
* Resolve the reconcile window from flags (local-day aligned, half-open `[from, to)`):
|
|
@@ -26,7 +31,10 @@ const isYmd = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
|
26
31
|
*/
|
|
27
32
|
export const resolvePeriod = (opts) => {
|
|
28
33
|
const today = new Date();
|
|
29
|
-
|
|
34
|
+
// The next local midnight, not 24 elapsed hours after this one. A spring-forward day is 23 hours
|
|
35
|
+
// long, so `+24h` reached into the following day; a fall-back day is 25, so it stopped an hour
|
|
36
|
+
// short and dropped 23:00–24:00 from the day the user asked for.
|
|
37
|
+
const endExclusive = (day) => new Date(nextLocalMidnight(startOfDay(day).getTime()));
|
|
30
38
|
if (opts.since !== undefined || opts.until !== undefined) {
|
|
31
39
|
const fromDay = opts.since ?? localDay(today);
|
|
32
40
|
const toDay = opts.until ?? localDay(today);
|
|
@@ -37,7 +45,8 @@ export const resolvePeriod = (opts) => {
|
|
|
37
45
|
return { from: startOfDay(fromDay), to: endExclusive(toDay) };
|
|
38
46
|
}
|
|
39
47
|
if (opts.week) {
|
|
40
|
-
const
|
|
48
|
+
const todayStart = startOfDay(localDay(today));
|
|
49
|
+
const from = new Date(todayStart.getFullYear(), todayStart.getMonth(), todayStart.getDate() - 6);
|
|
41
50
|
return { from, to: endExclusive(localDay(today)) };
|
|
42
51
|
}
|
|
43
52
|
return { from: startOfDay(localDay(today)), to: endExclusive(localDay(today)) };
|
|
@@ -50,19 +59,524 @@ export const deltaToApply = (row, direction) => {
|
|
|
50
59
|
return delta >= TOLERANCE_SECONDS ? delta : 0;
|
|
51
60
|
};
|
|
52
61
|
const sign = (n) => (n > 0 ? `+${formatDuration(n)}` : n < 0 ? `-${formatDuration(-n)}` : "0");
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Agent mode
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
/** Coding Agents whose sessions jcf can read. Codex records no git branch — see the spec. */
|
|
66
|
+
const SUPPORTED_AGENTS = ["claude"];
|
|
67
|
+
/** Wrong arguments, not a failed operation. Fails the command so a script sees a non-zero exit. */
|
|
68
|
+
export class ReconcileUsageError extends Data.TaggedError("ReconcileUsageError") {
|
|
69
|
+
[Runtime.errorReported] = false;
|
|
70
|
+
}
|
|
71
|
+
/** Flags that only mean something in agent mode, so passing one without `--agent` is an error. */
|
|
72
|
+
const AGENT_ONLY_FLAGS = [
|
|
73
|
+
{ flag: "--json", read: (o) => o.json },
|
|
74
|
+
{ flag: "--calendar", read: (o) => o.calendar }
|
|
75
|
+
];
|
|
76
|
+
export const resolveAgentMode = (options) => {
|
|
77
|
+
if (options.agent === undefined) {
|
|
78
|
+
const stray = AGENT_ONLY_FLAGS.filter((candidate) => candidate.read(options)).map((candidate) => candidate.flag);
|
|
79
|
+
if (stray.length > 0) {
|
|
80
|
+
return {
|
|
81
|
+
_tag: "UsageError",
|
|
82
|
+
message: `${stray.join(" and ")} ${stray.length === 1 ? "applies" : "apply"} to \`--agent\` runs only. Use: jcf sync reconcile --agent claude ${stray.join(" ")}`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { _tag: "Directions" };
|
|
86
|
+
}
|
|
87
|
+
if (options.direction !== undefined) {
|
|
88
|
+
return {
|
|
89
|
+
_tag: "UsageError",
|
|
90
|
+
message: `--agent cannot be combined with a direction ("${options.direction}"). --agent reads Agent Sessions ` +
|
|
91
|
+
"as evidence for both sides at once. Use: jcf sync reconcile --agent claude"
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (!SUPPORTED_AGENTS.includes(options.agent)) {
|
|
95
|
+
return {
|
|
96
|
+
_tag: "UsageError",
|
|
97
|
+
message: `Unsupported agent "${options.agent}". Supported: ${SUPPORTED_AGENTS.join(", ")}.`
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { _tag: "Agent", agent: options.agent };
|
|
101
|
+
};
|
|
102
|
+
/** How a row's evidence reads on screen, with the Coding Agent's confidence when there is one. */
|
|
103
|
+
const signalLabel = (signal, confidence) => confidence === null ? signal : `${signal} ${confidence.toFixed(2)}`;
|
|
104
|
+
// `formatDuration` is 3-7 characters wide (`59s` through `34m 27s`), so pad to its maximum or the
|
|
105
|
+
// columns stop lining up and the report stops being scannable — which is the whole point of it.
|
|
106
|
+
const DURATION_WIDTH = 7;
|
|
107
|
+
const duration = (seconds) => formatDuration(seconds).padStart(DURATION_WIDTH);
|
|
108
|
+
/**
|
|
109
|
+
* Jira issue facts by key, for the rows about to be shown.
|
|
110
|
+
*
|
|
111
|
+
* An Issue Key alone is not enough to judge a proposal — `PROJ-5663` says nothing about whether that
|
|
112
|
+
* afternoon's work belongs to it, and an issue assigned to someone else is a strong hint that the
|
|
113
|
+
* attribution is wrong. One request per *distinct* key, so a week of rows on the same ticket costs
|
|
114
|
+
* one lookup.
|
|
115
|
+
*
|
|
116
|
+
* Every failure mode degrades to "no facts": a missing Jira login, a deleted issue, or a network
|
|
117
|
+
* error must not stop a run whose Clockify half is still perfectly writable.
|
|
118
|
+
*/
|
|
119
|
+
const resolveTickets = (keys) => Effect.gen(function* () {
|
|
120
|
+
const distinct = [...new Set(keys)];
|
|
121
|
+
const results = yield* Effect.all(distinct.map((key) => fetchTicketByKey(key).pipe(Effect.map((result) => ({ key, result })))), { concurrency: 4 });
|
|
122
|
+
return new Map(results.flatMap(({ key, result }) => result._tag === "Found"
|
|
123
|
+
? [[key, { summary: result.ticket.summary, assignee: result.ticket.assignee }]]
|
|
124
|
+
: []));
|
|
125
|
+
});
|
|
126
|
+
/** Use the ownership answer for absence of assignment, never the optional display name. */
|
|
127
|
+
const assigneeLabel = (facts, assignment) => assignment === "unassigned" ? "unassigned" : facts.assignee;
|
|
128
|
+
/** The summary with the known assignee, which makes a misattributed row stand out. */
|
|
129
|
+
const ticketLine = (facts, width, assignment) => {
|
|
130
|
+
const label = assigneeLabel(facts, assignment);
|
|
131
|
+
return `${clip(facts.summary, width)}${label === null ? "" : ` · ${label}`}`;
|
|
132
|
+
};
|
|
133
|
+
/** Keep a summary from wrapping the line it annotates. */
|
|
134
|
+
const SUMMARY_WIDTH = 64;
|
|
135
|
+
/**
|
|
136
|
+
* Indent for a reported row's summary, which goes on a line of its own.
|
|
137
|
+
*
|
|
138
|
+
* Underneath rather than inline: the numeric columns have to stay aligned for the list to be
|
|
139
|
+
* scannable, and an issue title is far too variable in width to sit between them.
|
|
140
|
+
*/
|
|
141
|
+
const CONTINUATION = " ".repeat(14);
|
|
142
|
+
/** Inline in a yes/no confirmation, which has to stay on one line. */
|
|
143
|
+
const CONFIRM_SUMMARY_WIDTH = 44;
|
|
144
|
+
/**
|
|
145
|
+
* What the picker draws before a choice's title: `" " + checkbox + " "`. Every line of a title has
|
|
146
|
+
* to fit inside the width that leaves, and the prompt appends a space and the description after it.
|
|
147
|
+
*/
|
|
148
|
+
const CHOICE_PREFIX_WIDTH = 4;
|
|
149
|
+
/**
|
|
150
|
+
* Aligns a choice's second line under the first. The picker renders a choice as
|
|
151
|
+
* `" " + checkbox + " " + title`, so the title itself starts at column 4.
|
|
152
|
+
*/
|
|
153
|
+
const CHOICE_CONTINUATION = " ".repeat(6);
|
|
154
|
+
/** Enough of a title to recognise the issue; below this, clipping tells you nothing. */
|
|
155
|
+
const MIN_CHOICE_SUMMARY = 18;
|
|
156
|
+
/** One `HH:MM-HH:MM`: the least that can be said about *when* the blocks were. */
|
|
157
|
+
const MIN_RANGE_TAIL = 11;
|
|
158
|
+
/**
|
|
159
|
+
* Everything known about one proposal, as the two lines of a picker choice.
|
|
160
|
+
*
|
|
161
|
+
* All of it belongs *here* rather than in a report printed above: the picker owns the screen from the
|
|
162
|
+
* moment it opens, so anything printed before it has already scrolled past by the time there is a
|
|
163
|
+
* decision to make — and a choice's `description` renders only while its row is highlighted, which
|
|
164
|
+
* makes it the wrong home for anything that matters on every row.
|
|
165
|
+
*
|
|
166
|
+
* Two lines rather than one because the numeric columns have to stay aligned to be scannable and an
|
|
167
|
+
* issue title is far too variable in width to sit between them. A newline inside a title is safe: the
|
|
168
|
+
* prompt counts the rows it has to erase from the same choice text it renders, so the second line is
|
|
169
|
+
* accounted for on redraw.
|
|
170
|
+
*
|
|
171
|
+
* The second line is packed to the terminal's width, with the summary — the only elastic part — given
|
|
172
|
+
* whatever the fixed facts leave over. Neither line may reach the last column: the prompt appends a
|
|
173
|
+
* space and the (empty) description after the title, and a line that fills the row wraps onto the
|
|
174
|
+
* next one, costing a screen row and ragging the list.
|
|
175
|
+
*/
|
|
176
|
+
const choiceLines = (options) => {
|
|
177
|
+
const { assignment, proposal, tickets, width } = options;
|
|
178
|
+
const bounds = formatSpanBounds(proposal.blocks);
|
|
179
|
+
const when = bounds === "" ? proposal.day : `${proposal.day} ${bounds}`;
|
|
180
|
+
// Clipped like the detail line is. The fields are padded to line the columns up, not bounded — a
|
|
181
|
+
// long Issue Key with unequal gaps ("+10h Clockify, +9h Jira") already passes 80 columns, and the
|
|
182
|
+
// prompt only counts the title lines it was given. The terminal soft-wraps the surplus, the erase
|
|
183
|
+
// count is then one short, and every later row and redraw sits a line out of place while the user
|
|
184
|
+
// is choosing what to write.
|
|
185
|
+
const head = clip(`${when} ${proposal.ticketKey.padEnd(12)} ${proposalTargets(proposal).padEnd(17)}` +
|
|
186
|
+
` [${signalLabel(proposal.signal, proposal.confidence)}]`, width - 1 - CHOICE_PREFIX_WIDTH);
|
|
187
|
+
const facts = tickets.get(proposal.ticketKey);
|
|
188
|
+
// A credited total below the wall-clock total means this key was worked on alongside others and
|
|
189
|
+
// the overlap was split. Saying so keeps the smaller number from looking like a bug.
|
|
190
|
+
const shared = proposal.activeSeconds - proposal.sessionSeconds;
|
|
191
|
+
// What each side already holds, shown only when it is not zero: that is the whole explanation for
|
|
192
|
+
// an amount smaller than the credited total, and noise otherwise.
|
|
193
|
+
const held = proposal.clockifySeconds === 0 && proposal.jiraSeconds === 0
|
|
194
|
+
? []
|
|
195
|
+
: [`has Clockify ${formatDuration(proposal.clockifySeconds)}, Jira ${formatDuration(proposal.jiraSeconds)}`];
|
|
196
|
+
// Who owns the issue and what the sides already hold: short, and each one can change the answer,
|
|
197
|
+
// so the summary yields room to them rather than the other way round.
|
|
198
|
+
const owner = facts === undefined ? null : assigneeLabel(facts, assignment);
|
|
199
|
+
const qualifiers = [
|
|
200
|
+
...(owner === null ? [] : [owner]),
|
|
201
|
+
...held,
|
|
202
|
+
...(shared >= 60 ? [`${formatDuration(shared)} shared`] : [])
|
|
203
|
+
];
|
|
204
|
+
// That the credited total covers several blocks rather than one stretch is worth a place of its
|
|
205
|
+
// own; *which* blocks is the detail, and the only part worth losing to a narrow terminal.
|
|
206
|
+
const blocks = proposal.blocks.length > 1 ? [`${proposal.blocks.length} blocks`] : [];
|
|
207
|
+
const separator = " · ";
|
|
208
|
+
const usable = width - 1 - CHOICE_CONTINUATION.length;
|
|
209
|
+
// Measured on the joined text rather than summed part by part, so what the summary is given is
|
|
210
|
+
// exactly what is left: an estimate one character out puts the ellipsis mid-word on the tail.
|
|
211
|
+
const reserved = [...qualifiers, ...blocks].join(separator);
|
|
212
|
+
const room = usable - (reserved === "" ? 0 : reserved.length + separator.length);
|
|
213
|
+
const kept = [
|
|
214
|
+
...(facts === undefined ? [] : [clip(facts.summary, Math.max(MIN_CHOICE_SUMMARY, room))]),
|
|
215
|
+
...(reserved === "" ? [] : [reserved])
|
|
216
|
+
].join(separator);
|
|
217
|
+
// The block ranges go on only if a whole `HH:MM-HH:MM` fits after them. Appending them to a line
|
|
218
|
+
// with no room left would spend the last few columns on an ellipsis and take a word with it.
|
|
219
|
+
const ranges = usable - kept.length - separator.length;
|
|
220
|
+
const detail = proposal.blocks.length > 1 && ranges >= MIN_RANGE_TAIL
|
|
221
|
+
? `${kept}${separator}${clip(formatSpanRanges(proposal.blocks), ranges)}`
|
|
222
|
+
: kept;
|
|
223
|
+
return detail === "" ? [head] : [head, `${CHOICE_CONTINUATION}${clip(detail, usable)}`];
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* Rows shown at once. Each is two lines, so this is half of what the terminal can hold — enough to
|
|
227
|
+
* compare a morning's worth of proposals without the list scrolling its own header away.
|
|
228
|
+
*/
|
|
229
|
+
const CHOICES_PER_PAGE = 8;
|
|
230
|
+
/**
|
|
231
|
+
* The width to lay rows out for.
|
|
232
|
+
*
|
|
233
|
+
* Capped because a full-screen-width row on a very wide terminal is harder to read than a clipped
|
|
234
|
+
* one, and floored at the conventional 80 for anything narrower than a row can usefully be — a pipe
|
|
235
|
+
* reports no width at all, and below 60 columns nothing fits either way.
|
|
236
|
+
*/
|
|
237
|
+
const layoutWidth = (columns) => (columns >= 60 ? Math.min(columns, 160) : 80);
|
|
238
|
+
/**
|
|
239
|
+
* The `--json` payload: exactly one value, carrying everything the human report shows so an
|
|
240
|
+
* agent reading it is never worse informed than a person.
|
|
241
|
+
*
|
|
242
|
+
* Reporting only: it creates no Clockify entry and no Jira worklog. It does still refresh the local
|
|
243
|
+
* timer-state cache from Clockify, because knowing a Timer is running is what lets the report
|
|
244
|
+
* exclude that day.
|
|
245
|
+
*/
|
|
246
|
+
export const agentReportJson = (options) => {
|
|
247
|
+
// `summary` and `assignee` are null when Jira could not be reached or the issue is gone — never
|
|
248
|
+
// omitted, so a consumer can tell "unknown" from "not looked up".
|
|
249
|
+
const withSummary = (row) => {
|
|
250
|
+
const first = earliestStart(row.blocks);
|
|
251
|
+
const last = row.blocks.length === 0
|
|
252
|
+
? null
|
|
253
|
+
: row.blocks.reduce((latest, block) => Math.max(latest, block.endMs), -Infinity);
|
|
254
|
+
return {
|
|
255
|
+
...row,
|
|
256
|
+
summary: options.tickets.get(row.ticketKey)?.summary ?? null,
|
|
257
|
+
assignee: options.tickets.get(row.ticketKey)?.assignee ?? null,
|
|
258
|
+
// The outer bounds of the work item, so a consumer never has to fold `blocks` itself.
|
|
259
|
+
startedAt: first?.toISOString() ?? null,
|
|
260
|
+
endedAt: last === null ? null : new Date(last).toISOString()
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
return {
|
|
264
|
+
mode: "agent",
|
|
265
|
+
agent: options.agent,
|
|
266
|
+
from: localDay(options.period.from),
|
|
267
|
+
to: localDay(new Date(options.period.to.getTime() - 1)),
|
|
268
|
+
sessionCount: options.report.sessionCount,
|
|
269
|
+
sessionRootCount: options.report.sessionRootCount,
|
|
270
|
+
attributorAvailable: options.report.attributorAvailable,
|
|
271
|
+
attributorCalls: options.report.attributorCalls,
|
|
272
|
+
proposals: options.report.proposals.map(withSummary),
|
|
273
|
+
withheld: options.report.withheld.map(withSummary),
|
|
274
|
+
ownershipWithheld: options.ownershipWithheld.map(withSummary),
|
|
275
|
+
unattributed: options.report.unattributed,
|
|
276
|
+
excludedDays: options.report.excludedDays
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
/** The tail of a path, for naming a session by somewhere recognisable rather than by a UUID. */
|
|
280
|
+
const lastSegments = (path, count = 2) => path.split("/").filter(Boolean).slice(-count).join("/");
|
|
281
|
+
/** How one finished Coding Agent call reads. */
|
|
282
|
+
const outcomeLabel = (outcome) => {
|
|
283
|
+
switch (outcome._tag) {
|
|
284
|
+
case "Placed":
|
|
285
|
+
return `${outcome.ticketKey} (${outcome.confidence.toFixed(2)})`;
|
|
286
|
+
case "Declined":
|
|
287
|
+
return "none of the candidates";
|
|
288
|
+
case "Unavailable":
|
|
289
|
+
return `unavailable — ${outcome.message}`;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* Progress, always on stderr: it is not part of the report and never part of the JSON value.
|
|
294
|
+
*
|
|
295
|
+
* One line per completed Coding Agent call. Written as whole lines rather than an in-place counter
|
|
296
|
+
* because there is no way to tell a terminal from a pipe without reaching for host APIs, and a
|
|
297
|
+
* carriage-return spinner in a log file is worse than a few extra lines in a terminal.
|
|
298
|
+
*/
|
|
299
|
+
const reportProgress = (agent, progress) => {
|
|
300
|
+
switch (progress._tag) {
|
|
301
|
+
case "AgentActivity":
|
|
302
|
+
return progress.kind === "status"
|
|
303
|
+
? Console.error(` Batch ${progress.batch}/${progress.batches}: ${progress.text}`)
|
|
304
|
+
: Effect.void;
|
|
305
|
+
case "ReadingRecordedTime":
|
|
306
|
+
return Console.error(" Reading recorded time and checking running timers.");
|
|
307
|
+
case "SessionsRead":
|
|
308
|
+
return Console.error(` Read ${progress.count} in-scope session(s).`);
|
|
309
|
+
case "AttributingSessions":
|
|
310
|
+
return Console.error(` Asking ${agent} about ${progress.count} session(s) no branch or path could place` +
|
|
311
|
+
`, batched into ${progress.calls} call(s) — this is the slow part:`);
|
|
312
|
+
case "SessionAttributed":
|
|
313
|
+
return Console.error(` ${String(progress.done).padStart(String(progress.total).length)}/${progress.total}` +
|
|
314
|
+
` ${progress.gitBranch ?? "(no branch)"} @ ${lastSegments(progress.cwd)}` +
|
|
315
|
+
` -> ${outcomeLabel(progress.outcome)}`);
|
|
316
|
+
case "SessionsAttributed":
|
|
317
|
+
return Console.error(` ...${progress.placed} placed, ${progress.declined} declined` +
|
|
318
|
+
(progress.unavailable > 0 ? `, ${progress.unavailable} unavailable` : "") + ".");
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
/** Group the day's rows so each day gets one grid, in day order. */
|
|
322
|
+
const calendarByDay = (proposals) => {
|
|
323
|
+
const days = new Map();
|
|
324
|
+
for (const proposal of proposals) {
|
|
325
|
+
const rows = days.get(proposal.day) ?? [];
|
|
326
|
+
rows.push({ ticketKey: proposal.ticketKey, spans: proposal.blocks });
|
|
327
|
+
days.set(proposal.day, rows);
|
|
328
|
+
}
|
|
329
|
+
return [...days.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
330
|
+
};
|
|
331
|
+
const runAgentMode = (options) => Effect.gen(function* () {
|
|
332
|
+
const svc = yield* ReconcileService;
|
|
333
|
+
const config = yield* ConfigService;
|
|
334
|
+
const issues = yield* IssueFacts;
|
|
335
|
+
// In --json mode every human-facing line goes to stderr so stdout holds one JSON value.
|
|
336
|
+
const say = options.json ? Console.error : Console.log;
|
|
337
|
+
// Progress goes to stderr in both modes: it is not part of the JSON value, and reading
|
|
338
|
+
// transcripts then waking a Coding Agent takes long enough that silence reads as a hang.
|
|
339
|
+
const report = yield* svc.proposeFromSessions(options.period, {
|
|
340
|
+
onProgress: (progress) => reportProgress(options.agent, progress)
|
|
341
|
+
}).pipe(Effect.catch((error) => Console.error(`Agent reconcile failed: ${error.message}`).pipe(Effect.as(null))));
|
|
342
|
+
// A failed run must not look like an empty one. Under `--json` that distinction is the whole
|
|
343
|
+
// contract: silence on stdout with a zero exit reads as "no unlogged work", and the skill tells
|
|
344
|
+
// an agent to act on exactly that. So the message goes to stderr and the command fails.
|
|
345
|
+
if (report === null) {
|
|
346
|
+
return yield* new ReconcileUsageError({ message: "Could not read Agent Sessions." });
|
|
347
|
+
}
|
|
348
|
+
const ownershipSettings = yield* config.get;
|
|
349
|
+
const ownershipFacts = ownershipSettings.sessionOwnership === "assigned"
|
|
350
|
+
? (yield* issues.lookup(report.proposals.map((proposal) => proposal.ticketKey))).facts
|
|
351
|
+
: new Map();
|
|
352
|
+
const ownership = {
|
|
353
|
+
facts: ownershipFacts,
|
|
354
|
+
mode: ownershipSettings.sessionOwnership,
|
|
355
|
+
overrides: ownershipSettings.sessionOwnershipOverrides
|
|
356
|
+
};
|
|
357
|
+
const proposals = report.proposals.filter((proposal) => isOwnedByMe(proposal.ticketKey, ownership));
|
|
358
|
+
const notOwned = report.proposals.filter((proposal) => !isOwnedByMe(proposal.ticketKey, ownership));
|
|
359
|
+
const offeredReport = { ...report, proposals };
|
|
360
|
+
// Looked up for every row that names an Issue Key, including the withheld ones — a row you
|
|
361
|
+
// cannot identify is a row you cannot judge, whether or not it is offered.
|
|
362
|
+
const tickets = yield* resolveTickets([
|
|
363
|
+
...report.proposals.map((proposal) => proposal.ticketKey),
|
|
364
|
+
...report.withheld.map((entry) => entry.ticketKey)
|
|
365
|
+
]);
|
|
366
|
+
if (options.json) {
|
|
367
|
+
yield* Console.log(JSON.stringify(agentReportJson({
|
|
368
|
+
...options,
|
|
369
|
+
report: offeredReport,
|
|
370
|
+
tickets,
|
|
371
|
+
ownershipWithheld: notOwned.map((proposal) => ({
|
|
372
|
+
...proposal,
|
|
373
|
+
reason: ownership.facts.get(proposal.ticketKey)?.assignment === "unassigned"
|
|
374
|
+
? "unassigned"
|
|
375
|
+
: "other-assignee"
|
|
376
|
+
}))
|
|
377
|
+
}), null, 2));
|
|
63
378
|
return;
|
|
64
379
|
}
|
|
65
|
-
const
|
|
380
|
+
const fromDay = localDay(options.period.from);
|
|
381
|
+
const toDay = localDay(new Date(options.period.to.getTime() - 1));
|
|
382
|
+
yield* say(`Reconcile from ${options.agent} Agent Sessions (${fromDay === toDay ? fromDay : `${fromDay}..${toDay}`})`);
|
|
383
|
+
if (report.sessionCount === 0) {
|
|
384
|
+
yield* say(" No in-scope Agent Sessions in this period.");
|
|
385
|
+
if (report.sessionRootCount === 0) {
|
|
386
|
+
yield* say(" Nothing is opted in yet. Add a Session Root: jcf config set session-root <dir>");
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// Proposals are *not* listed here. They are the picker's rows, and printing them twice pushes the
|
|
391
|
+
// rows that are only ever reported — withheld, unattributed, skipped — off the top of the screen.
|
|
392
|
+
for (const entry of report.withheld) {
|
|
393
|
+
const facts = tickets.get(entry.ticketKey);
|
|
394
|
+
yield* say(` ${entry.day} ${entry.ticketKey.padEnd(12)} ${duration(entry.seconds)}` +
|
|
395
|
+
` not offered — confidence ${entry.confidence?.toFixed(2) ?? "unknown"} is below the floor`);
|
|
396
|
+
if (facts !== undefined) {
|
|
397
|
+
yield* say(`${CONTINUATION}${ticketLine(facts, SUMMARY_WIDTH, ownership.facts.get(entry.ticketKey)?.assignment)}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
for (const entry of notOwned) {
|
|
401
|
+
const fact = ownership.facts.get(entry.ticketKey);
|
|
402
|
+
const owner = fact?.assignment === "unassigned"
|
|
403
|
+
? "unassigned"
|
|
404
|
+
: `assigned to ${fact?.assignee ?? "somebody else"}`;
|
|
405
|
+
yield* say(` ${entry.day} ${entry.ticketKey.padEnd(12)} ${duration(entry.sessionSeconds)}` +
|
|
406
|
+
` not offered — ${owner}`);
|
|
407
|
+
const facts = tickets.get(entry.ticketKey);
|
|
408
|
+
if (facts !== undefined)
|
|
409
|
+
yield* say(`${CONTINUATION}${ticketLine(facts, SUMMARY_WIDTH, fact?.assignment)}`);
|
|
410
|
+
}
|
|
411
|
+
for (const entry of report.unattributed) {
|
|
412
|
+
yield* say(` ${entry.day} ${"(unattributed)".padEnd(12)} ${duration(entry.seconds)}` +
|
|
413
|
+
` ${entry.sessionCount} session(s) no signal could place`);
|
|
414
|
+
}
|
|
415
|
+
if (options.calendar) {
|
|
416
|
+
for (const [day, rows] of calendarByDay(proposals)) {
|
|
417
|
+
yield* say("");
|
|
418
|
+
for (const line of renderDayCalendar({ day, rows }))
|
|
419
|
+
yield* say(line);
|
|
420
|
+
}
|
|
421
|
+
if (proposals.length > 0)
|
|
422
|
+
yield* say("");
|
|
423
|
+
}
|
|
424
|
+
for (const excluded of report.excludedDays) {
|
|
425
|
+
yield* say(` ${excluded.day} skipped — ${excluded.reason}`);
|
|
426
|
+
}
|
|
427
|
+
if (!report.attributorAvailable) {
|
|
428
|
+
yield* say(" A Coding Agent was needed but unavailable; those sessions are listed as unattributed.");
|
|
429
|
+
}
|
|
430
|
+
if (proposals.length === 0) {
|
|
431
|
+
// Withheld, unattributed and skipped time is not recorded time. Claiming both sides hold
|
|
432
|
+
// everything while those rows sit just above would tell the operator there is nothing to do.
|
|
433
|
+
const needsAttention = report.withheld.length > 0 || report.unattributed.length > 0 ||
|
|
434
|
+
report.excludedDays.length > 0 || !report.attributorAvailable;
|
|
435
|
+
if (needsAttention) {
|
|
436
|
+
yield* say(" Nothing written — the rows above still need you before they can be recorded.");
|
|
437
|
+
}
|
|
438
|
+
else if (notOwned.length === 0) {
|
|
439
|
+
yield* say(" Nothing to propose — both sides already hold everything these sessions account for.");
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const clockifyTotal = proposals.reduce((sum, p) => sum + p.clockifyDelta, 0);
|
|
444
|
+
const jiraTotal = proposals.reduce((sum, p) => sum + p.jiraDelta, 0);
|
|
445
|
+
yield* say(`\n Would add Clockify ${formatDuration(clockifyTotal)}, Jira ${formatDuration(jiraTotal)}` +
|
|
446
|
+
` across ${proposals.length} row(s).`);
|
|
447
|
+
const terminal = yield* Terminal.Terminal;
|
|
448
|
+
const width = layoutWidth(yield* terminal.columns);
|
|
449
|
+
// Rows carried alongside their proposal rather than in a parallel array indexed back into: the
|
|
450
|
+
// picker and the no-TTY fallback both need the pair, and an index is one refactor away from
|
|
451
|
+
// silently pointing at the wrong row.
|
|
452
|
+
const choiceRows = proposals.map((proposal) => ({
|
|
453
|
+
proposal,
|
|
454
|
+
lines: choiceLines({
|
|
455
|
+
proposal,
|
|
456
|
+
tickets,
|
|
457
|
+
assignment: ownership.facts.get(proposal.ticketKey)?.assignment,
|
|
458
|
+
width
|
|
459
|
+
})
|
|
460
|
+
}));
|
|
461
|
+
// One picker rather than a row-by-row interrogation: these rows are one reconstruction of one
|
|
462
|
+
// period, and seeing them together is what lets a wrong attribution stand out against the
|
|
463
|
+
// others. Everything starts checked, so the common "all of it" case is a single Enter.
|
|
464
|
+
const chosen = yield* Prompt.MultiSelect({
|
|
465
|
+
message: " Space toggles a row, Enter writes the checked ones:",
|
|
466
|
+
maxPerPage: CHOICES_PER_PAGE,
|
|
467
|
+
choices: choiceRows.map((row) => ({
|
|
468
|
+
title: row.lines.join("\n"),
|
|
469
|
+
value: row.proposal,
|
|
470
|
+
selected: true
|
|
471
|
+
}))
|
|
472
|
+
}).pipe(
|
|
473
|
+
// A terminal that cannot be read (no TTY, or Ctrl-C) ends the run having written nothing.
|
|
474
|
+
// The rows go out as a plain report instead: piping this command must still say what it found.
|
|
475
|
+
Effect.catch((error) => Effect.forEach(choiceRows.flatMap((row) => row.lines), (line) => say(` ${line}`)).pipe(Effect.andThen(say(` Nothing written: ${error.message}`)), Effect.as(null))));
|
|
476
|
+
if (chosen === null)
|
|
477
|
+
return;
|
|
478
|
+
if (chosen.length === 0) {
|
|
479
|
+
yield* say(" Nothing selected — nothing written.");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// Asked only about the rows that are actually being written, and only once for all of them —
|
|
483
|
+
// describing a row the user just unchecked would be paid for and thrown away.
|
|
484
|
+
// On stderr with the rest of the progress: it says what the wait is for, and a wait is not part
|
|
485
|
+
// of the report a pipe should receive.
|
|
486
|
+
yield* Console.error(` Describing ${chosen.length} row(s) with ${options.agent}…`);
|
|
487
|
+
const notes = yield* svc.describeProposals({
|
|
488
|
+
proposals: chosen,
|
|
489
|
+
digests: report.digests,
|
|
490
|
+
summaries: new Map([...tickets].map(([key, facts]) => [key, facts.summary]))
|
|
491
|
+
});
|
|
492
|
+
yield* Effect.acquireUseRelease(WatchLease.acquire({ intervalSeconds: 300 }), (lease) => {
|
|
493
|
+
if (lease._tag !== "Held") {
|
|
494
|
+
return say(lease._tag === "Taken"
|
|
495
|
+
? " Nothing written: another JCF writer holds the machine guard."
|
|
496
|
+
: ` Nothing written: ${lease.reason}.`);
|
|
497
|
+
}
|
|
498
|
+
const guarded = Effect.gen(function* () {
|
|
499
|
+
// Review and description generation are unbounded. Take the writer guard, then re-tally
|
|
500
|
+
// inside it so no watch, browser tab or second reconcile can authorize the same gap.
|
|
501
|
+
const refreshed = yield* svc.refreshRecordedTime(options.period, report);
|
|
502
|
+
const currentSettings = yield* config.get;
|
|
503
|
+
const currentFacts = currentSettings.sessionOwnership === "assigned"
|
|
504
|
+
? (yield* issues.lookup(chosen.map((proposal) => proposal.ticketKey))).facts
|
|
505
|
+
: new Map();
|
|
506
|
+
const currentOwnership = {
|
|
507
|
+
facts: currentFacts,
|
|
508
|
+
mode: currentSettings.sessionOwnership,
|
|
509
|
+
overrides: currentSettings.sessionOwnershipOverrides
|
|
510
|
+
};
|
|
511
|
+
for (const [index, selected] of chosen.entries()) {
|
|
512
|
+
if (!isOwnedByMe(selected.ticketKey, currentOwnership)) {
|
|
513
|
+
const fact = currentOwnership.facts.get(selected.ticketKey);
|
|
514
|
+
const owner = fact?.assignment === "unassigned"
|
|
515
|
+
? "unassigned"
|
|
516
|
+
: `assigned to ${fact?.assignee ?? "somebody else"}`;
|
|
517
|
+
yield* say(` ${selected.day} ${selected.ticketKey} skipped — ${owner}`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const excluded = refreshed.excludedDays.find(({ day }) => day === selected.day);
|
|
521
|
+
const proposal = refreshed.proposals.find((candidate) => candidate.ticketKey === selected.ticketKey && candidate.day === selected.day);
|
|
522
|
+
if (excluded !== undefined) {
|
|
523
|
+
yield* say(` ${selected.day} ${selected.ticketKey} skipped — ${excluded.reason}`);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (proposal === undefined) {
|
|
527
|
+
yield* say(` ${selected.day} ${selected.ticketKey} already logged — nothing written`);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const approved = {
|
|
531
|
+
...proposal,
|
|
532
|
+
clockifyDelta: Math.min(selected.clockifyDelta, proposal.clockifyDelta),
|
|
533
|
+
jiraDelta: Math.min(selected.jiraDelta, proposal.jiraDelta)
|
|
534
|
+
};
|
|
535
|
+
const description = entryDescription({
|
|
536
|
+
summary: tickets.get(approved.ticketKey)?.summary ?? null,
|
|
537
|
+
note: notes[index] ?? null
|
|
538
|
+
});
|
|
539
|
+
yield* say(` ${approved.day} ${approved.ticketKey} ${proposalTargets(approved)}`);
|
|
540
|
+
// Printed before the write, and in full: this text lands in two systems other people read,
|
|
541
|
+
// so it should never be a surprise found later in Clockify.
|
|
542
|
+
yield* say(` ${description}`);
|
|
543
|
+
const written = yield* applyProposal(svc, approved, description, refreshed.sourceScopes ?? {
|
|
544
|
+
clockify: null,
|
|
545
|
+
jira: null
|
|
546
|
+
}, undefined, refreshed.jiraAvailability);
|
|
547
|
+
yield* Effect.forEach(writeOutcomeLines(written), (line) => say(` ${line}`));
|
|
548
|
+
if (!keepGoing(written))
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
return WriterGuard.withExistingWriterGuard(lease, guarded);
|
|
553
|
+
}, (lease) => lease._tag === "Held" ? WatchLease.releaseGuard(lease) : Effect.void);
|
|
554
|
+
});
|
|
555
|
+
export const reconcile = Command.make("reconcile", {
|
|
556
|
+
direction: Args.String("direction").pipe(Args.optional),
|
|
557
|
+
week: Options.Boolean("week").pipe(Options.withDescription("Reconcile the last 7 days (default: today)"), Options.withDefault(false)),
|
|
558
|
+
day: Options.Boolean("day").pipe(Options.withDescription("Reconcile today (the default)"), Options.withDefault(false)),
|
|
559
|
+
since: Options.String("since").pipe(Options.withDescription("Start of a custom window, YYYY-MM-DD"), Options.optional),
|
|
560
|
+
until: Options.String("until").pipe(Options.withDescription("End of a custom window (inclusive), YYYY-MM-DD"), Options.optional),
|
|
561
|
+
agent: Options.String("agent").pipe(Options.withDescription("Propose worklogs from a Coding Agent's sessions instead of comparing sides (claude)"), Options.optional),
|
|
562
|
+
json: Options.Boolean("json").pipe(Options.withDescription("With --agent: one JSON value on stdout; creates no Clockify entry or Jira worklog"), Options.withDefault(false)),
|
|
563
|
+
calendar: Options.Boolean("calendar").pipe(Options.withDescription("With --agent: draw an hour-by-hour grid of when the time was credited"), Options.withDefault(false))
|
|
564
|
+
}, ({ agent, calendar, direction, json, since, until, week }) => Effect.gen(function* () {
|
|
565
|
+
const mode = resolveAgentMode({
|
|
566
|
+
agent: Option.isSome(agent) ? agent.value : undefined,
|
|
567
|
+
direction: Option.isSome(direction) ? direction.value : undefined,
|
|
568
|
+
json,
|
|
569
|
+
calendar
|
|
570
|
+
});
|
|
571
|
+
if (mode._tag === "UsageError") {
|
|
572
|
+
// Printed here rather than left to the runtime: a usage mistake should read as advice, not
|
|
573
|
+
// as a stack trace. The failure still propagates so the process exits non-zero, and the
|
|
574
|
+
// binary disables runtime error reporting so this is not also printed a second time.
|
|
575
|
+
yield* Console.error(mode.message);
|
|
576
|
+
return yield* new ReconcileUsageError({ message: mode.message });
|
|
577
|
+
}
|
|
578
|
+
// The window is resolved the same way in both modes, so `--day/--week/--since/--until`
|
|
579
|
+
// mean exactly one thing regardless of where the evidence comes from.
|
|
66
580
|
const period = resolvePeriod({
|
|
67
581
|
week,
|
|
68
582
|
since: Option.isSome(since) ? since.value : undefined,
|
|
@@ -72,6 +586,15 @@ export const reconcile = Command.make("reconcile", {
|
|
|
72
586
|
yield* Console.log(period.error);
|
|
73
587
|
return;
|
|
74
588
|
}
|
|
589
|
+
if (mode._tag === "Agent") {
|
|
590
|
+
return yield* runAgentMode({ agent: mode.agent, period, json, calendar });
|
|
591
|
+
}
|
|
592
|
+
const dir = Option.isSome(direction) ? direction.value : "clockify-to-jira";
|
|
593
|
+
if (dir !== "clockify-to-jira" && dir !== "jira-to-clockify") {
|
|
594
|
+
yield* Console.log("Usage: jcf sync reconcile [clockify-to-jira|jira-to-clockify] [--day|--week|--since|--until]");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const directionTag = dir;
|
|
75
598
|
const svc = yield* ReconcileService;
|
|
76
599
|
const rows = yield* svc.compare(period).pipe(Effect.catch((e) => Console.log(`Reconcile failed: ${e.message}`).pipe(Effect.as(null))));
|
|
77
600
|
if (rows === null)
|
|
@@ -83,49 +606,115 @@ export const reconcile = Command.make("reconcile", {
|
|
|
83
606
|
yield* Console.log(" No time logged on either side for this period.");
|
|
84
607
|
return;
|
|
85
608
|
}
|
|
609
|
+
// Same reason as agent mode: an Issue Key alone does not say whether a discrepancy is worth
|
|
610
|
+
// fixing. One lookup per distinct key, and a missing one costs the name, never the run.
|
|
611
|
+
const tickets = yield* resolveTickets(rows.map((row) => row.ticketKey));
|
|
86
612
|
// Report every bucket, marking the gap relative to the chosen target.
|
|
87
613
|
for (const row of rows) {
|
|
88
614
|
const delta = row.clockifySeconds - row.jiraSeconds;
|
|
89
615
|
const mark = Math.abs(delta) < TOLERANCE_SECONDS ? "=" : "Δ";
|
|
90
616
|
yield* Console.log(` ${row.day} ${row.ticketKey.padEnd(12)} Clockify ${formatDuration(row.clockifySeconds).padStart(6)}` +
|
|
91
617
|
` Jira ${formatDuration(row.jiraSeconds).padStart(6)} ${mark} ${sign(delta)}`);
|
|
618
|
+
const facts = tickets.get(row.ticketKey);
|
|
619
|
+
if (facts !== undefined)
|
|
620
|
+
yield* Console.log(`${CONTINUATION}${ticketLine(facts, SUMMARY_WIDTH, undefined)}`);
|
|
92
621
|
}
|
|
93
622
|
const fixable = rows.filter((r) => deltaToApply(r, directionTag) > 0);
|
|
94
623
|
if (fixable.length === 0) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
624
|
+
const target = directionTag === "clockify-to-jira" ? "Jira" : "Clockify";
|
|
625
|
+
const other = directionTag === "clockify-to-jira" ? "Clockify" : "Jira";
|
|
626
|
+
const reverse = directionTag === "clockify-to-jira"
|
|
627
|
+
? "jira-to-clockify"
|
|
628
|
+
: "clockify-to-jira";
|
|
629
|
+
// A direction only ever asks "is the target short?". Saying "in sync" when the *other*
|
|
630
|
+
// side is short reads as "everything matches", which is exactly wrong — and the reverse
|
|
631
|
+
// run is the thing the user actually wants next.
|
|
632
|
+
const reverseShortfall = rows.reduce((total, row) => total + deltaToApply(row, reverse), 0);
|
|
633
|
+
yield* Console.log(` ${target} is not short of ${other} — nothing to add in this direction.`);
|
|
634
|
+
if (reverseShortfall > 0) {
|
|
635
|
+
yield* Console.log(` ${other} is short by ${formatDuration(reverseShortfall)}. This is not "in sync";` +
|
|
636
|
+
` run: jcf sync reconcile ${reverse}`);
|
|
637
|
+
}
|
|
98
638
|
return;
|
|
99
639
|
}
|
|
100
640
|
const target = directionTag === "clockify-to-jira" ? "Jira" : "Clockify";
|
|
101
641
|
yield* Console.log(`\n ${fixable.length} row(s) where ${target} is short — confirm each fix:`);
|
|
642
|
+
const approved = [];
|
|
102
643
|
for (const row of fixable) {
|
|
103
644
|
const delta = deltaToApply(row, directionTag);
|
|
104
|
-
const
|
|
105
|
-
|
|
645
|
+
const facts = tickets.get(row.ticketKey);
|
|
646
|
+
const ticket = facts === undefined
|
|
647
|
+
? row.ticketKey
|
|
648
|
+
: `${row.ticketKey} (${clip(facts.summary, CONFIRM_SUMMARY_WIDTH)})`;
|
|
649
|
+
const apply = yield* Prompt.Confirm({
|
|
650
|
+
message: ` Add ${formatDuration(delta)} to ${target} for ${ticket} on ${row.day}?`,
|
|
106
651
|
initial: true
|
|
107
652
|
});
|
|
108
653
|
if (!apply)
|
|
109
654
|
continue;
|
|
110
|
-
|
|
111
|
-
const outcome = yield* svc.applyToJira(row.ticketKey, row.day, delta, row.clockifyDescription ?? undefined);
|
|
112
|
-
if (outcome._tag === "Posted") {
|
|
113
|
-
yield* Console.log(` ✓ posted to Jira`);
|
|
114
|
-
}
|
|
115
|
-
else if (outcome._tag === "NotLoggedIn") {
|
|
116
|
-
yield* Console.log(` ✗ ${NOT_LOGGED_IN_HINT}`);
|
|
117
|
-
return; // no point continuing — every Jira write will fail
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
yield* Console.log(` ✗ ${outcome.message}`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
124
|
-
const ok = yield* svc.applyToClockify(row.ticketKey, row.day, delta).pipe(Effect.catch((e) => Console.log(` ✗ ${e.message}`).pipe(Effect.as(false))));
|
|
125
|
-
if (ok)
|
|
126
|
-
yield* Console.log(` ✓ created Clockify entry`);
|
|
127
|
-
}
|
|
655
|
+
approved.push({ row, delta });
|
|
128
656
|
}
|
|
657
|
+
if (approved.length === 0)
|
|
658
|
+
return;
|
|
659
|
+
yield* Effect.acquireUseRelease(WatchLease.acquire({ intervalSeconds: 300 }), (lease) => {
|
|
660
|
+
if (lease._tag !== "Held") {
|
|
661
|
+
return Console.log(lease._tag === "Taken"
|
|
662
|
+
? " Nothing written: another JCF writer holds the machine guard."
|
|
663
|
+
: ` Nothing written: ${lease.reason}.`);
|
|
664
|
+
}
|
|
665
|
+
const guarded = Effect.gen(function* () {
|
|
666
|
+
// Confirmation can take arbitrarily long. Admit the whole approved batch first, then
|
|
667
|
+
// re-read under the guard so another writer cannot fill a gap between tally and write.
|
|
668
|
+
const comparison = yield* svc.compareDirection(period).pipe(Effect.catch((e) => Console.log(`Reconcile failed: ${e.message}`).pipe(Effect.as(null))));
|
|
669
|
+
if (comparison === null)
|
|
670
|
+
return;
|
|
671
|
+
for (const selection of approved) {
|
|
672
|
+
const row = comparison.rows.find((candidate) => candidate.ticketKey === selection.row.ticketKey && candidate.day === selection.row.day);
|
|
673
|
+
if (row === undefined) {
|
|
674
|
+
yield* Console.log(` ${selection.row.ticketKey} ${selection.row.day}: already logged — nothing written`);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const remaining = Math.min(selection.delta, deltaToApply(row, directionTag));
|
|
678
|
+
if (remaining === 0) {
|
|
679
|
+
yield* Console.log(` ${selection.row.ticketKey} ${selection.row.day}: already logged — nothing written`);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
const directionSource = yield* comparison.resolveSource(row, directionTag, remaining).pipe(Effect.catch((error) => Console.log(` ✗ ${error.message}`).pipe(Effect.as(null))));
|
|
683
|
+
if (directionSource === null)
|
|
684
|
+
continue;
|
|
685
|
+
if (directionSource._tag === "Refused") {
|
|
686
|
+
yield* Console.log(` ✗ ${directionSource.message}`);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const startedAt = directionSource._tag === "Bound" ? directionSource.startedAt : undefined;
|
|
690
|
+
const source = directionSource._tag === "Bound" ? directionSource.source : undefined;
|
|
691
|
+
const writableSeconds = source?.seconds ?? remaining;
|
|
692
|
+
if (directionTag === "clockify-to-jira") {
|
|
693
|
+
const outcome = yield* svc.applyToJira(selection.row.ticketKey, selection.row.day, writableSeconds, row.clockifyDescription ?? selection.row.clockifyDescription ?? undefined, startedAt, source);
|
|
694
|
+
if (outcome._tag === "Posted") {
|
|
695
|
+
yield* Console.log(` ✓ posted to Jira`);
|
|
696
|
+
}
|
|
697
|
+
else if (outcome._tag === "NotLoggedIn") {
|
|
698
|
+
yield* Console.log(` ✗ ${NOT_LOGGED_IN_HINT}`);
|
|
699
|
+
return; // no point continuing — every Jira write will fail
|
|
700
|
+
}
|
|
701
|
+
else if (outcome._tag === "VerificationUnavailable") {
|
|
702
|
+
yield* Console.log(` ✗ the Jira account could not be verified; refresh before writing`);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
yield* Console.log(` ✗ ${outcome.message}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
const ok = yield* svc.applyToClockify(selection.row.ticketKey, selection.row.day, writableSeconds, undefined, startedAt, source).pipe(Effect.catch((e) => Console.log(` ✗ ${e.message}`).pipe(Effect.as(false))));
|
|
711
|
+
if (ok)
|
|
712
|
+
yield* Console.log(` ✓ created Clockify entry`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
return WriterGuard.withExistingWriterGuard(lease, guarded);
|
|
717
|
+
}, (lease) => lease._tag === "Held" ? WatchLease.releaseGuard(lease) : Effect.void);
|
|
129
718
|
}));
|
|
130
719
|
export const sync = Command.make("sync", {}, () => Console.log("Usage: jcf sync reconcile")).pipe(Command.withDescription("Sync workflow commands"), Command.withSubcommands([reconcile]));
|
|
131
720
|
//# sourceMappingURL=reconcile.js.map
|