@brainervirus/workit-core 2.1.2 → 2.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -65,9 +65,6 @@ export function previewConversion(input: ConversionInput): ConversionPreview {
65
65
  if (config.locale !== undefined) {
66
66
  mappings.push({ key: "locale", from: config.locale, to: config.locale });
67
67
  }
68
- if (config.timezone !== undefined) {
69
- mappings.push({ key: "timezone", from: config.timezone, to: config.timezone });
70
- }
71
68
  if (config.branchPolicy !== undefined && isConfigObject(config.branchPolicy)) {
72
69
  const bp = config.branchPolicy as ToolkitConfig["branchPolicy"] & {
73
70
  allowed?: string[];
@@ -142,7 +139,7 @@ export function applyConversionConfig(
142
139
  const next: Record<string, unknown> = { ...raw };
143
140
 
144
141
  for (const mapping of preview.mappings) {
145
- if (mapping.key === "locale" || mapping.key === "timezone") {
142
+ if (mapping.key === "locale") {
146
143
  next[mapping.key] = mapping.to;
147
144
  }
148
145
  if (mapping.key === "branchPolicy" && !resolutions["branchPolicy.allowed"]) {
@@ -26,7 +26,6 @@ export type BranchPreset = "gitflow" | "github-flow" | "trunk-based" | "custom";
26
26
  export type ToolkitConfig = {
27
27
  locale: string;
28
28
  localeOptions: string[];
29
- timezone: string;
30
29
  branchPolicy: { preset: BranchPreset; allowed: string[]; protected: string[] };
31
30
  commitPolicy: { preset: CommitFlavorPreset; pattern?: string };
32
31
  };
@@ -131,7 +130,6 @@ export const LOCALE_RE = /^[a-z]{2,3}(-(?:[A-Z]{2}|[0-9]{3}))?$/;
131
130
  const DEFAULTS: ToolkitConfig = {
132
131
  locale: "en",
133
132
  localeOptions: ["en", "es-CL", "es-MX", "es-AR", "pt-BR"],
134
- timezone: "America/Santiago",
135
133
  branchPolicy: {
136
134
  preset: "gitflow",
137
135
  allowed: [...PRESETS.gitflow.allowed],
@@ -177,6 +175,8 @@ const parseConfigResult = (raw: string | null, file: string): ReaderResult<Toolk
177
175
  if (!isConfigObject(parsed)) {
178
176
  return { status: "malformed", path: file, error: `${file} is not a JSON object` };
179
177
  }
178
+ // Only known keys are read: legacy fields such as the removed global
179
+ // `timezone` are tolerated and ignored, never an error.
180
180
  const input = parsed as Partial<ToolkitConfig>;
181
181
  const locale = LOCALE_RE.test(String(input.locale ?? ""))
182
182
  ? (input.locale as string)
@@ -199,7 +199,6 @@ const parseConfigResult = (raw: string | null, file: string): ReaderResult<Toolk
199
199
  localeOptions: Array.isArray(input.localeOptions)
200
200
  ? input.localeOptions
201
201
  : DEFAULTS.localeOptions,
202
- timezone: input.timezone ?? DEFAULTS.timezone,
203
202
  // RL-02/CA-23: the persisted preset is authoritative; derived allowed /
204
203
  // protected fields always reset from PRESETS via the one shared merge.
205
204
  branchPolicy: mergePreset(
@@ -246,7 +245,6 @@ export const readConfigFromDir = (dir: string): ToolkitConfig => {
246
245
  export type ConfigInput = {
247
246
  locale?: string;
248
247
  localeOptions?: string[];
249
- timezone?: string;
250
248
  preset?: BranchPreset;
251
249
  allowed?: string[];
252
250
  protectedNames?: string[];
@@ -259,7 +257,6 @@ export type ConfigInput = {
259
257
  export const mergeConfigValues = (input: ConfigInput, current: ToolkitConfig): ToolkitConfig => ({
260
258
  locale: input.locale ?? current.locale,
261
259
  localeOptions: input.localeOptions ?? current.localeOptions,
262
- timezone: input.timezone ?? current.timezone,
263
260
  branchPolicy: mergePreset(input.preset ?? current.branchPolicy.preset, input, current),
264
261
  commitPolicy: input.commitPolicy ?? current.commitPolicy,
265
262
  });
@@ -479,12 +479,28 @@ export const actionProposalQuestion = (
479
479
  : proposal;
480
480
  };
481
481
 
482
+ export type WorkDateInfo = {
483
+ localDate: string;
484
+ timezone: string;
485
+ timezoneSource: "youtrack.json" | "process";
486
+ };
487
+
482
488
  export type ResolvedExternalAction = {
483
489
  request: ExternalActionRequest;
484
490
  descriptorPayload: unknown;
485
491
  marker?: string;
492
+ /** Display-only YouTrack work-item date context. Deliberately outside
493
+ * descriptorPayload: the descriptor is the hashed approval binding and must
494
+ * not depend on the process timezone of whoever resolves it. */
495
+ workDate?: WorkDateInfo;
486
496
  };
487
497
 
498
+ const workDateOf = (data: WorkDateInfo): WorkDateInfo => ({
499
+ localDate: data.localDate,
500
+ timezone: data.timezone,
501
+ timezoneSource: data.timezoneSource,
502
+ });
503
+
488
504
  export type HostingReadEvidence = {
489
505
  outcome: "succeeded" | "unknown";
490
506
  evidenceDigest: string;
@@ -1707,6 +1723,7 @@ const resolveExternalActionAtRoot = (
1707
1723
  return success(null, null, {
1708
1724
  request,
1709
1725
  marker,
1726
+ workDate: workDateOf(date.data),
1710
1727
  descriptorPayload: {
1711
1728
  ...request.payload,
1712
1729
  resolved: {
@@ -1750,6 +1767,7 @@ const resolveExternalActionAtRoot = (
1750
1767
  return success(null, null, {
1751
1768
  request,
1752
1769
  marker,
1770
+ workDate: workDateOf(date.data),
1753
1771
  descriptorPayload: {
1754
1772
  ...request.payload,
1755
1773
  resolved: {
package/src/core/init.ts CHANGED
@@ -42,39 +42,28 @@ export const resolveInitProvider = (cwd?: string): string | null => {
42
42
  };
43
43
 
44
44
  // Port of scripts/init/apply.sh write_youtrack_json — env overrides honored.
45
- const youtrackJsonContent = (dir: string): Record<string, any> => ({
46
- baseUrl: process.env.WORKFLOW_YT_BASE_URL ?? "https://enghouseamg.youtrack.cloud",
47
- tokenFile: process.env.WORKFLOW_YT_TOKEN_FILE ?? path.join(dir, "youtrack.token"),
48
- timezone: process.env.WORKFLOW_YT_TIMEZONE ?? "America/Santiago",
49
- locale: "es-CL",
50
- defaultMention: process.env.WORKFLOW_YT_MENTION ?? "Alejandra.Flores",
51
- greetings: { morning: "buenos días", afternoon: "buenas tardes" },
52
- greetingCutoff: "12:00",
53
- meetingIssue: process.env.WORKFLOW_YT_MEETING_ISSUE ?? "IRPT-12",
54
- meetingIssues: {
55
- general: {
56
- issue: process.env.WORKFLOW_YT_MEETING_ISSUE ?? "IRPT-12",
57
- label: "General meetings (Reuniones internas Team IRP)",
58
- workItemText: "Reuniones",
59
- },
60
- web: {
61
- issue: process.env.WORKFLOW_YT_WEB_MEETING_ISSUE ?? "NSXFT-21",
62
- label: "Web meetings",
63
- workItemText: "Reuniones web",
64
- url: "https://enghouseamg.youtrack.cloud/projects/NSXFT/issues/NSXFT-21",
45
+ // Neutral draft (same shape as the setup wizard's): no organization URL, issue
46
+ // IDs, mentions, greetings or timezone. The work-item date uses the process
47
+ // timezone unless the user adds an explicit `timezone` to youtrack.json.
48
+ const youtrackJsonContent = (dir: string): Record<string, any> => {
49
+ const meetingIssue = process.env.WORKFLOW_YT_MEETING_ISSUE;
50
+ return {
51
+ baseUrl: process.env.WORKFLOW_YT_BASE_URL ?? "https://youtrack.example.com",
52
+ tokenFile: process.env.WORKFLOW_YT_TOKEN_FILE ?? path.join(dir, "youtrack.token"),
53
+ ...(meetingIssue
54
+ ? {
55
+ meetingIssue,
56
+ meetingIssues: { general: { issue: meetingIssue, label: "General meetings" } },
57
+ }
58
+ : {}),
59
+ tokenDefaults: {
60
+ name: "workit",
61
+ description: "OpenCode workit — /wk-issue-update and /wk-meetings",
62
+ scopes: ["YouTrack"],
63
+ profileTab: "account-security",
65
64
  },
66
- },
67
- commentHeader: "# Actualización",
68
- attachmentsHeaderImages: "## Adjunto capturas",
69
- attachmentsHeaderFiles: "## Archivos adjuntos",
70
- attachmentsHeaderMixed: "## Adjuntos",
71
- tokenDefaults: {
72
- name: "workit",
73
- description: "OpenCode workit — /wk-issue-update and /wk-meetings",
74
- scopes: ["YouTrack"],
75
- profileTab: "account-security",
76
- },
77
- });
65
+ };
66
+ };
78
67
 
79
68
  const vcsJsonContent = (): Record<string, any> => {
80
69
  // Explicit provider at init: env wins, else the checkout's origin remote
@@ -157,8 +146,6 @@ export function initApplyData(
157
146
  baseUrl: base,
158
147
  meetingIssue: meeting,
159
148
  meetingIssueUrl: base && meeting ? `${base}/issue/${meeting}` : null,
160
- defaultMention: cfg.defaultMention,
161
- timezone: cfg.timezone,
162
149
  locale: cfg.locale,
163
150
  tokenCreate,
164
151
  timeLogging: {
package/src/core/setup.ts CHANGED
@@ -84,7 +84,6 @@ export function parseList(raw: string): string[] {
84
84
  export type SetupPreviewInput = {
85
85
  platforms?: string[];
86
86
  locale: string;
87
- timezone: string;
88
87
  branchPreset: BranchPreset;
89
88
  branchAllowed: string;
90
89
  branchProtected: string;
@@ -143,8 +142,6 @@ export type SetupPreview = {
143
142
  const YT_OVERRIDES: { envKey: string; affects: string }[] = [
144
143
  { envKey: "WORKFLOW_YT_BASE_URL", affects: "youtrack.json baseUrl" },
145
144
  { envKey: "WORKFLOW_YT_TOKEN_FILE", affects: "youtrack.json tokenFile" },
146
- { envKey: "WORKFLOW_YT_TIMEZONE", affects: "youtrack.json timezone" },
147
- { envKey: "WORKFLOW_YT_MENTION", affects: "youtrack.json defaultMention" },
148
145
  { envKey: "WORKFLOW_YT_MEETING_ISSUE", affects: "youtrack.json meetingIssue" },
149
146
  { envKey: "WORKFLOW_YT_WEB_MEETING_ISSUE", affects: "youtrack.json web meeting issue" },
150
147
  ];
@@ -182,7 +179,6 @@ function youtrackDraft(values: SetupPreviewInput, tokenPath: string): Record<str
182
179
  return {
183
180
  baseUrl: values.baseUrl.replace(/\/+$/, ""),
184
181
  tokenFile: tokenPath,
185
- timezone: values.timezone,
186
182
  locale: values.locale,
187
183
  tokenDefaults: {
188
184
  name: "workit",
@@ -265,7 +261,6 @@ export function buildSetupPreview(
265
261
  value: {
266
262
  locale: values.locale,
267
263
  localeOptions: current.localeOptions,
268
- timezone: values.timezone,
269
264
  ...(values.commitPolicy === undefined ? {} : { commitPolicy: values.commitPolicy }),
270
265
  branchPolicy: mergePreset(
271
266
  values.branchPreset,
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { configDir } from "./config";
4
4
  import { assetRoot } from "./package-root";
5
5
 
6
- export type TemplateName = "issue-update" | "greeting" | "headers";
6
+ export type TemplateName = "issue-update" | "headers";
7
7
 
8
8
  const repoRoot = assetRoot();
9
9
 
@@ -6,6 +6,7 @@ import { configDir, isConfigObject } from "./config";
6
6
  import {
7
7
  context as legacyContext,
8
8
  logTime as legacyLogTime,
9
+ meetingWorkItemText,
9
10
  parseDuration as legacyParseDuration,
10
11
  postUpdate as legacyPostUpdate,
11
12
  verifyYouTrackToken,
@@ -206,18 +207,35 @@ export async function logTimeUpdate(
206
207
  }
207
208
 
208
209
  export function normalizeContext(value: LegacyValue, mode?: string): LegacyValue {
209
- if (!value || mode !== "meetings") return value;
210
+ if (!value || mode !== "meetings" || value.error) return value;
211
+ // Operations may return the legacy `{ data }` envelope; normalize inside it.
212
+ if (value.data && typeof value.data === "object" && !Array.isArray(value.data)) {
213
+ const inner = normalizeContext(value.data as Record<string, unknown>, mode);
214
+ return inner && inner.error
215
+ ? { ...value, ...inner, data: undefined }
216
+ : { ...value, data: inner };
217
+ }
210
218
  const config = value.config as Record<string, unknown> | undefined;
211
- const issue = String(config?.meetingIssue || "IRPT-12");
212
219
  const { meetingIssues: _meetingIssues, ...singleMeetingConfig } = config ?? {};
213
220
  const options = Array.isArray(value.meetingOptions)
214
221
  ? (value.meetingOptions as Array<Record<string, unknown>>)
215
222
  : [];
223
+ // The meeting issue comes only from youtrack.json — there is no built-in one.
224
+ const configured = typeof config?.meetingIssue === "string" ? config.meetingIssue : "";
225
+ const resolved = typeof value.issueId === "string" ? value.issueId : "";
226
+ const firstOption = typeof options[0]?.issue === "string" ? options[0].issue : "";
227
+ const issue = configured || resolved || firstOption;
228
+ if (!issue)
229
+ return {
230
+ ...value,
231
+ error: "no meeting issue configured — set meetingIssue in youtrack.json",
232
+ requiresIssueInput: true,
233
+ };
216
234
  const selected = options.find((option) => option.issue === issue) ?? {
217
235
  key: "general",
218
236
  issue,
219
237
  label: issue,
220
- workItemText: "Reuniones",
238
+ workItemText: meetingWorkItemText(config),
221
239
  };
222
240
  return {
223
241
  ...value,
@@ -225,6 +243,6 @@ export function normalizeContext(value: LegacyValue, mode?: string): LegacyValue
225
243
  meetingOptions: [selected],
226
244
  requiresMeetingChoice: false,
227
245
  issueId: issue,
228
- workItemText: selected.workItemText ?? "Reuniones",
246
+ workItemText: meetingWorkItemText(config, selected),
229
247
  };
230
248
  }
@@ -101,72 +101,58 @@ export function youTrackConfigLoad(): YouTrackConfigResult {
101
101
  return { data: redacted };
102
102
  }
103
103
 
104
- function tzParts(
105
- date: Date,
106
- tz: string,
107
- ): { y: string; m: string; d: string; hour: string; minute: string } {
104
+ function tzParts(date: Date, tz: string): { y: string; m: string; d: string } {
108
105
  const parts = new Intl.DateTimeFormat("en-GB", {
109
106
  timeZone: tz,
110
107
  year: "numeric",
111
108
  month: "2-digit",
112
109
  day: "2-digit",
113
- hour: "2-digit",
114
- minute: "2-digit",
115
- hourCycle: "h23",
116
110
  }).formatToParts(date);
117
111
  const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
118
- return { y: map.year, m: map.month, d: map.day, hour: map.hour, minute: map.minute };
112
+ return { y: map.year, m: map.month, d: map.day };
119
113
  }
120
114
 
121
- /** Port of scripts/youtrack/greeting.sh. */
122
- export function youTrackGreeting(configOverride?: string): {
123
- stdout: string;
124
- exitCode: number;
125
- stderr: string;
126
- } {
127
- const cfgPath = configOverride ?? youTrackConfigPath();
128
- let parsed: unknown;
129
- try {
130
- parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
131
- } catch {
132
- return {
133
- stdout: "",
134
- exitCode: 1,
135
- stderr: fs.existsSync(cfgPath)
136
- ? `${cfgPath} is not valid JSON`
137
- : `missing youtrack.json: ${cfgPath}`,
138
- };
139
- }
140
- if (!isConfigObject(parsed)) {
141
- return { stdout: "", exitCode: 1, stderr: `${cfgPath} is not a JSON object` };
142
- }
143
- const config = parsed as Record<string, any>;
144
- try {
145
- const tz = String(config.timezone ?? "America/Santiago");
146
- const now = new Date();
147
- const { y, m, d, hour, minute } = tzParts(now, tz);
148
- const cutoff = String(config.greetingCutoff ?? "12:00").split(":");
149
- const cutoffHour = Number(cutoff[0]);
150
- const cutoffMinute = Number(cutoff[1] ?? 0);
151
- const greetings = (config.greetings ?? {}) as Record<string, string>;
152
- const isMorning =
153
- Number(hour) < cutoffHour || (Number(hour) === cutoffHour && Number(minute) < cutoffMinute);
154
- const greeting = isMorning
155
- ? (greetings.morning ?? "buenos días")
156
- : (greetings.afternoon ?? "buenas tardes");
157
- const mention = String(config.defaultMention ?? "Alejandra.Flores");
158
- void y;
159
- void m;
160
- void d;
161
- return { stdout: `@${mention} Hola, ${greeting}.\n`, exitCode: 0, stderr: "" };
162
- } catch (err) {
163
- return {
164
- stdout: "",
165
- exitCode: 1,
166
- stderr: err instanceof Error ? err.message : "greeting failed",
167
- };
168
- }
169
- }
115
+ // The process timezone (honours TZ). Used when youtrack.json has no explicit
116
+ // `timezone` override; there is no hard-coded default zone.
117
+ const processTimezone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
118
+
119
+ export type WorkTimezone = { timezone: string; source: "youtrack.json" | "process" };
120
+
121
+ /** The timezone that decides a work item's calendar day: an explicit
122
+ * youtrack.json `timezone` wins, otherwise the process timezone (TZ). */
123
+ export const effectiveWorkTimezone = (
124
+ cfg: Record<string, any> | null | undefined,
125
+ ): WorkTimezone => {
126
+ const configured = cfg?.timezone;
127
+ return typeof configured === "string" && configured.trim()
128
+ ? { timezone: configured.trim(), source: "youtrack.json" }
129
+ : { timezone: processTimezone(), source: "process" };
130
+ };
131
+
132
+ /** Neutral default work-item text for meeting time; youtrack.json can set a
133
+ * global `meetingWorkItemText` or a per-meeting `workItemText`. */
134
+ export const DEFAULT_MEETING_WORK_ITEM_TEXT = "Meetings";
135
+
136
+ export const meetingWorkItemText = (
137
+ cfg: Record<string, any> | null | undefined,
138
+ item?: Record<string, any> | null,
139
+ ): string => {
140
+ if (typeof item?.workItemText === "string" && item.workItemText) return item.workItemText;
141
+ if (typeof cfg?.meetingWorkItemText === "string" && cfg.meetingWorkItemText)
142
+ return cfg.meetingWorkItemText;
143
+ return DEFAULT_MEETING_WORK_ITEM_TEXT;
144
+ };
145
+
146
+ // A YouTrack work-item date is the calendar day at UTC midnight. Computing it
147
+ // with Date.UTC keeps it independent of the process timezone (Date.parse of a
148
+ // local "T00:00:00" shifted it a day back east of UTC).
149
+ const utcMidnight = (y: number, m: number, d: number): number | null => {
150
+ const ms = Date.UTC(y, m - 1, d);
151
+ const check = new Date(ms);
152
+ return check.getUTCFullYear() === y && check.getUTCMonth() === m - 1 && check.getUTCDate() === d
153
+ ? ms
154
+ : null;
155
+ };
170
156
 
171
157
  /** Port of scripts/youtrack/parse-duration.sh. */
172
158
  export function youTrackParseDuration(
@@ -181,12 +167,21 @@ export function youTrackParseDuration(
181
167
  return { data: { minutes: total, text: String(text).trim() } };
182
168
  }
183
169
 
184
- /** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms. */
185
- export function youTrackWorkDateMs(
186
- dateRaw: string,
187
- ): { data: { dateMs: number; timezone: string; localDate: string } } | { error: string } {
170
+ /** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms.
171
+ * The calendar day comes from youtrack.json `timezone` when set (optional
172
+ * override), otherwise from the process timezone. */
173
+ export function youTrackWorkDateMs(dateRaw: string):
174
+ | {
175
+ data: {
176
+ dateMs: number;
177
+ timezone: string;
178
+ timezoneSource: WorkTimezone["source"];
179
+ localDate: string;
180
+ };
181
+ }
182
+ | { error: string } {
188
183
  const cfgPath = youTrackConfigPath();
189
- let tz = "America/Santiago";
184
+ let effective = effectiveWorkTimezone(null);
190
185
  // Missing file is a legitimate unconfigured state (reader: "missing" keeps
191
186
  // defaults); a parseable non-object is malformed and must propagate the
192
187
  // exact-path error instead of silently defaulting the timezone.
@@ -200,31 +195,30 @@ export function youTrackWorkDateMs(
200
195
  if (!isConfigObject(parsed)) {
201
196
  return { error: `${cfgPath} is not a JSON object` };
202
197
  }
203
- tz = String((parsed as Record<string, any>).timezone ?? "America/Santiago");
198
+ effective = effectiveWorkTimezone(parsed as Record<string, any>);
204
199
  }
200
+ const tz = effective.timezone;
201
+ const meta = { timezone: tz, timezoneSource: effective.source };
205
202
  const raw = dateRaw || "auto";
206
203
  try {
207
- if (raw === "auto" || !raw) {
208
- const now = new Date();
209
- const { y, m, d } = tzParts(now, tz);
210
- const dateMs = Math.floor(Date.parse(`${y}-${m}-${d}T00:00:00`) / 86400000) * 86400000;
211
- return { data: { dateMs, timezone: tz, localDate: `${y}-${m}-${d}` } };
204
+ if (raw === "auto") {
205
+ const { y, m, d } = tzParts(new Date(), tz);
206
+ const dateMs = utcMidnight(Number(y), Number(m), Number(d));
207
+ if (dateMs === null) return { error: "could not resolve date" };
208
+ return { data: { dateMs, ...meta, localDate: `${y}-${m}-${d}` } };
212
209
  }
213
210
  if (/^\d+$/.test(raw)) {
211
+ // An explicit epoch is already a work-item date (UTC midnight of the
212
+ // day); its label is that UTC calendar day so it round-trips exactly.
214
213
  const dt = new Date(Number(raw));
215
- const { y, m, d } = tzParts(dt, tz);
216
- return { data: { dateMs: Number(raw), timezone: tz, localDate: `${y}-${m}-${d}` } };
214
+ if (Number.isNaN(dt.getTime())) return { error: `invalid date: ${raw}` };
215
+ return { data: { dateMs: Number(raw), ...meta, localDate: dt.toISOString().slice(0, 10) } };
217
216
  }
218
- const [y, m, d] = raw.split("-").map(Number);
219
- const iso = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00`;
220
- const dateMs = Math.floor(Date.parse(iso) / 86400000) * 86400000;
221
- return {
222
- data: {
223
- dateMs,
224
- timezone: tz,
225
- localDate: `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
226
- },
227
- };
217
+ const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(raw);
218
+ const dateMs = match ? utcMidnight(Number(match[1]), Number(match[2]), Number(match[3])) : null;
219
+ if (!match || dateMs === null) return { error: `invalid date: ${raw} (expected YYYY-MM-DD)` };
220
+ const localDate = `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`;
221
+ return { data: { dateMs, ...meta, localDate } };
228
222
  } catch (err) {
229
223
  return { error: err instanceof Error ? err.message : "could not resolve date" };
230
224
  }
@@ -341,67 +335,6 @@ export async function youTrackApi(
341
335
  return { error: "unknown subcommand" };
342
336
  }
343
337
 
344
- /** Port of scripts/youtrack/verify-token.sh — read-only GET /api/users/me. */
345
- export async function youTrackVerifyToken(): Promise<
346
- { data: Record<string, any> } | { error: string; http_status?: number; path?: string }
347
- > {
348
- const cfgPath = youTrackConfigPath();
349
- if (!fs.existsSync(cfgPath)) return { error: "missing youtrack.json" };
350
- const creds = youTrackToken();
351
- if ("error" in creds) return { error: creds.error };
352
- const { token, base } = creds;
353
-
354
- const me = await youTrackRequest(`${base}/api/users/me?fields=id,login,name,email`, {
355
- method: "GET",
356
- token,
357
- });
358
- if (me.status !== 0) {
359
- const err =
360
- me.status === 401 || me.status === 403
361
- ? "authentication failed (401/403)"
362
- : `HTTP error: ${me.stderr.slice(0, 200)}`;
363
- return { error: err, http_status: me.status };
364
- }
365
- let user: Record<string, any>;
366
- try {
367
- user = JSON.parse(me.stdout) as Record<string, any>;
368
- } catch {
369
- return { error: "invalid JSON from YouTrack /api/users/me" };
370
- }
371
- const result: Record<string, any> = {
372
- ok: true,
373
- method: "GET /api/users/me",
374
- baseUrl: base,
375
- login: user.login,
376
- name: user.name,
377
- email: user.email,
378
- id: user.id,
379
- };
380
- const meeting = readYouTrackConfig(false);
381
- const meetingIssue = "config" in meeting ? meeting.config.meetingIssue : undefined;
382
- if (meetingIssue) {
383
- const issue = await youTrackRequest(
384
- `${base}/api/issues/${meetingIssue}?fields=id,idReadable,summary`,
385
- { method: "GET", token },
386
- );
387
- if (issue.status === 0) {
388
- try {
389
- const parsed = JSON.parse(issue.stdout) as Record<string, any>;
390
- result.meetingIssue = meetingIssue;
391
- result.meetingIssueReadable = true;
392
- result.meetingIssueSummary = parsed.summary;
393
- } catch {
394
- /* unreadable */
395
- }
396
- } else {
397
- result.meetingIssue = meetingIssue;
398
- result.meetingIssueReadable = false;
399
- result.warning = `token valid but cannot read issue ${meetingIssue}`;
400
- }
401
- }
402
- return { data: result };
403
- }
404
-
405
338
  /** Port of scripts/youtrack/token-create-url.sh — deep link to Account Security. */
406
339
  export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
407
340
  const tokenName = process.env.WORKFLOW_YT_TOKEN_NAME ?? "workit";
@@ -416,12 +349,14 @@ export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
416
349
  defaults.description ?? "OpenCode workit — /wk-issue-update and /wk-meetings",
417
350
  );
418
351
  const scopes = Array.isArray(defaults.scopes) ? defaults.scopes : ["YouTrack"];
419
- const base = String(config.baseUrl ?? "https://enghouseamg.youtrack.cloud").replace(/\/+$/, "");
352
+ // No organization default: without a configured baseUrl there is no
353
+ // account page to link to, so createUrl is null and `error` says why.
354
+ const base = String(config.baseUrl ?? "").replace(/\/+$/, "");
420
355
  const tokenFile = String(
421
356
  config.tokenFile ?? path.join(path.dirname(loaded.path), "youtrack.token"),
422
357
  );
423
358
  const tab = String(defaults.profileTab ?? "account-security");
424
- const createUrl = `${base}/users/me?${new URLSearchParams({ tab })}`;
359
+ const createUrl = base ? `${base}/users/me?${new URLSearchParams({ tab })}` : null;
425
360
  const docsUrl = "https://www.jetbrains.com/help/youtrack/cloud/manage-permanent-token.html";
426
361
  return {
427
362
  data: {
@@ -432,6 +367,9 @@ export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
432
367
  createUrl,
433
368
  docsUrl,
434
369
  prefillSupported: false,
370
+ ...(base
371
+ ? {}
372
+ : { error: `baseUrl missing in ${loaded.path} — set your YouTrack base URL first` }),
435
373
  steps: [
436
374
  "Profile → Account Security → **New token** (or open createUrl)",
437
375
  `Name: **${name}**`,
@@ -469,21 +407,18 @@ export function parseIssueRef(
469
407
 
470
408
  export type YouTrackScripts = {
471
409
  config(): Record<string, any>;
472
- greeting(): { stdout: string; exitCode: number; stderr: string };
473
410
  parseDuration(text: string): Record<string, any>;
474
411
  api(args: string[]): Record<string, any> | Promise<Record<string, any>>;
475
412
  };
476
413
 
477
414
  const defaultScripts: YouTrackScripts = {
478
415
  config: () => youTrackConfigLoad(),
479
- greeting: () => youTrackGreeting(),
480
416
  parseDuration: (text) => youTrackParseDuration(text),
481
417
  api: (args) => youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? ""),
482
418
  };
483
419
 
484
420
  const contextScripts: YouTrackScripts = {
485
421
  config: readYouTrackContextConfig,
486
- greeting: () => youTrackGreeting(youTrackReadOnlyConfigPath()),
487
422
  parseDuration: (text) => youTrackParseDuration(text),
488
423
  api: () => ({ error: "YouTrack context is read-only" }),
489
424
  };
@@ -522,7 +457,7 @@ function meetingOptionsFromConfig(cfg: any): Record<string, any>[] {
522
457
  key,
523
458
  issue: item.issue,
524
459
  label: item.label ?? item.issue,
525
- workItemText: item.workItemText ?? "Reuniones",
460
+ workItemText: meetingWorkItemText(cfg, item),
526
461
  url: item.url ?? (base && item.issue ? `${base}/issue/${item.issue}` : null),
527
462
  }));
528
463
  }
@@ -532,7 +467,7 @@ function meetingOptionsFromConfig(cfg: any): Record<string, any>[] {
532
467
  key: "general",
533
468
  issue,
534
469
  label: "General meetings",
535
- workItemText: "Reuniones",
470
+ workItemText: meetingWorkItemText(cfg),
536
471
  url: base && issue ? `${base}/issue/${issue}` : null,
537
472
  },
538
473
  ];
@@ -553,14 +488,8 @@ const readOnlyYouTrackConfig = (cfg: Record<string, any>): Record<string, any> =
553
488
  const safe: Record<string, any> = {};
554
489
  const baseUrl = publicUrl(cfg.baseUrl);
555
490
  if (baseUrl) safe.baseUrl = baseUrl;
556
- for (const key of ["timezone", "greetingCutoff", "defaultMention", "meetingIssue"])
491
+ for (const key of ["timezone", "meetingIssue", "meetingWorkItemText"])
557
492
  if (typeof cfg[key] === "string") safe[key] = cfg[key];
558
- if (cfg.greetings && typeof cfg.greetings === "object" && !Array.isArray(cfg.greetings)) {
559
- const greetings: Record<string, string> = {};
560
- for (const key of ["morning", "afternoon"])
561
- if (typeof cfg.greetings[key] === "string") greetings[key] = cfg.greetings[key];
562
- if (Object.keys(greetings).length) safe.greetings = greetings;
563
- }
564
493
  if (
565
494
  cfg.meetingIssues &&
566
495
  typeof cfg.meetingIssues === "object" &&
@@ -605,18 +534,13 @@ export function context(
605
534
  const cfg = scripts.config();
606
535
  if (cfg.error) return { error: cfg.error };
607
536
 
608
- const greeting = scripts.greeting();
609
- if (greeting.exitCode !== 0) {
610
- return { error: (greeting.stderr || greeting.stdout || "greeting failed").trim() };
611
- }
612
-
613
537
  const safeConfig = readOnlyYouTrackConfig(cfg.data);
614
538
  const meetingOptions = meetingOptionsFromConfig(safeConfig);
615
539
 
616
540
  if (mode === "meetings" && !issue_id && !issue_url && !issue_ref) {
617
541
  return {
618
542
  config: safeConfig,
619
- greeting: greeting.stdout.trim(),
543
+ workTimezone: effectiveWorkTimezone(safeConfig),
620
544
  mode: "meetings",
621
545
  requiresMeetingChoice: true,
622
546
  meetingOptions,
@@ -647,7 +571,7 @@ export function context(
647
571
 
648
572
  return {
649
573
  config: safeConfig,
650
- greeting: greeting.stdout.trim(),
574
+ workTimezone: effectiveWorkTimezone(safeConfig),
651
575
  issueId: issue,
652
576
  issueUrl,
653
577
  mode: mode ?? (selectedMeeting ? "meetings" : "task"),
@@ -757,7 +681,6 @@ export function buildDraft({
757
681
  issueId,
758
682
  projectName,
759
683
  userNotes,
760
- greeting,
761
684
  facts,
762
685
  includeProjectOpener,
763
686
  includeFacts,
@@ -765,40 +688,35 @@ export function buildDraft({
765
688
  issueId: string;
766
689
  projectName?: string;
767
690
  userNotes?: string;
768
- greeting?: string;
769
691
  facts?: any;
770
692
  includeProjectOpener?: boolean;
771
693
  includeFacts?: boolean;
772
694
  }): Record<string, any> {
695
+ // The wording lives in the editable issue-update template (config
696
+ // templates/issue-update.md overrides the bundled neutral one). Placeholders
697
+ // this build does not fill (e.g. a legacy {{greetingSection}}) render empty.
773
698
  const tpl = readTemplate("issue-update").content;
774
699
  const para = (value: string): string => (value ? `\n\n${value}` : "");
775
- const filled = tpl
776
- .replaceAll("{{greetingSection}}", para(greeting ? `${greeting}` : ""))
777
- .replaceAll(
778
- "{{projectSection}}",
779
- para(includeProjectOpener && projectName ? `Hoy estuve full con ${projectName}.` : ""),
780
- )
781
- .replaceAll("{{userNotesSection}}", para((userNotes ?? "").trim()))
782
- .replaceAll(
783
- "{{progressSection}}",
784
- para(
785
- includeFacts && facts?.progress_excerpt?.length
786
- ? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n")
787
- : "",
788
- ),
789
- )
790
- .replaceAll(
791
- "{{gitCommitsSection}}",
792
- para(
793
- includeFacts && facts?.git_commits?.length
794
- ? facts.git_commits.map((c: string) => `- ${c}`).join("\n")
795
- : "",
796
- ),
797
- );
700
+ const sections: Record<string, string> = {
701
+ "{{projectSection}}": includeProjectOpener && projectName ? `Project: ${projectName}` : "",
702
+ "{{userNotesSection}}": (userNotes ?? "").trim(),
703
+ "{{progressSection}}":
704
+ includeFacts && facts?.progress_excerpt?.length
705
+ ? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n")
706
+ : "",
707
+ "{{gitCommitsSection}}":
708
+ includeFacts && facts?.git_commits?.length
709
+ ? facts.git_commits.map((c: string) => `- ${c}`).join("\n")
710
+ : "",
711
+ };
712
+ let filled = tpl;
713
+ for (const [placeholder, value] of Object.entries(sections))
714
+ filled = filled.replaceAll(placeholder, para(value));
715
+ filled = filled.replace(/\{\{[A-Za-z0-9_]+\}\}/g, "");
798
716
  const collapsed = filled.replace(/\n{3,}/g, "\n\n").trimEnd();
799
717
  // Bare draft keeps the header's trailing blank line (matches legacy output);
800
718
  // drafts with sections end right after the last one.
801
- const markdown = collapsed === "# Actualización" ? `${collapsed}\n\n` : collapsed;
719
+ const markdown = Object.values(sections).some(Boolean) ? collapsed : `${collapsed}\n\n`;
802
720
  return { issueId, markdown };
803
721
  }
804
722
 
@@ -1,5 +1,4 @@
1
- # Actualización
2
- {{greetingSection}}
1
+ # Update
3
2
  {{projectSection}}
4
3
  {{userNotesSection}}
5
4
  {{progressSection}}
@@ -1 +0,0 @@
1
- {{greetingText}}