@workit-dev/core 0.0.1 → 1.0.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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,288 @@
1
+ // The one seam between Workit and YouTrack (#263). Every Workit YouTrack
2
+ // operation (issue read and create, comments, work items, the marker
3
+ // read-back) goes through `ytrack … --json` (BrainerVirus/youtrack-cli), which
4
+ // keeps its token in the OS keyring. Workit never reads, writes or stores a
5
+ // YouTrack token.
6
+ //
7
+ // ytrack exits 0 ok, 1 failed, 2 cancelled, 4 authentication required. A
8
+ // missing ytrack is `unavailable` with the install as the unblock; exit 4 is
9
+ // `unavailable` with `ytrack auth login --host <host>`.
10
+ import {
11
+ YTRACK_EXIT,
12
+ YTRACK_MISSING,
13
+ ytrackDetail,
14
+ ytrackLogin,
15
+ ytrackMissingUnblock,
16
+ ytrackRunner,
17
+ type YtrackRun,
18
+ } from "./youtrack-ytrack";
19
+ import { youTrackBaseUrl } from "./youtrack";
20
+
21
+ export const YT_WRITE_GATE = "WORKIT_YT_WRITE";
22
+
23
+ type Obj = Record<string, unknown>;
24
+
25
+ export type IssueComment = {
26
+ id: string;
27
+ text: string;
28
+ author: string | null;
29
+ created: string | null;
30
+ };
31
+ export type BackendKind = "ytrack";
32
+ export type IssueView = {
33
+ issueId: string;
34
+ summary: string;
35
+ description: string | null;
36
+ state: string | null;
37
+ url: string | null;
38
+ fields: { name: string; value: unknown }[];
39
+ comments: IssueComment[];
40
+ source: BackendKind;
41
+ };
42
+ export type Created = {
43
+ issueId: string;
44
+ url: string | null;
45
+ source: BackendKind;
46
+ };
47
+ export type CreateInput = {
48
+ project: string;
49
+ summary: string;
50
+ description?: string;
51
+ };
52
+ export type WorkItemInput = { minutes: number; text: string; dateMs: number };
53
+
54
+ type FailCode = "unavailable" | "failed" | "not_found" | "blocked";
55
+ export type IssueOutcome<T> =
56
+ | { ok: true; data: T }
57
+ | { ok: false; code: FailCode; error: string; unblock?: string };
58
+
59
+ /** A write's result; `outcome` says whether it surely did not land. */
60
+ export type WriteResult =
61
+ | { ok: true; data: Obj }
62
+ | {
63
+ ok: false;
64
+ error: string;
65
+ outcome: "not_applied" | "unknown";
66
+ unblock?: string;
67
+ };
68
+
69
+ /** The texts on an issue; `complete` is false when older items may be missing. */
70
+ export type Texts = { texts: string[]; complete: boolean };
71
+
72
+ export interface YouTrackBackend {
73
+ readonly kind: BackendKind;
74
+ showIssue(issueId: string): Promise<IssueOutcome<IssueView>>;
75
+ createIssue(input: CreateInput): Promise<IssueOutcome<Created>>;
76
+ addComment(issueId: string, text: string): Promise<WriteResult>;
77
+ addWorkItem(issueId: string, item: WorkItemInput): Promise<WriteResult>;
78
+ /** Comment or work item texts, for the marker read-back; null when unreadable. */
79
+ texts(issueId: string, where: "comments" | "workItems"): Promise<Texts | null>;
80
+ }
81
+
82
+ const isObj = (value: unknown): value is Obj =>
83
+ typeof value === "object" && value !== null && !Array.isArray(value);
84
+ const str = (value: unknown): string | null => (typeof value === "string" ? value : null);
85
+
86
+ /** A field value as one readable scalar: an enum's name, a user's login, a period's text. */
87
+ function plainValue(value: unknown): unknown {
88
+ if (Array.isArray(value)) return value.map(plainValue);
89
+ if (!isObj(value)) return value ?? null;
90
+ for (const key of ["name", "login", "presentation", "text", "fullName"])
91
+ if (typeof value[key] === "string") return value[key];
92
+ return null;
93
+ }
94
+
95
+ const isoTime = (value: unknown): string | null =>
96
+ typeof value === "number" ? new Date(value).toISOString() : str(value);
97
+
98
+ function comments(raw: unknown): IssueComment[] {
99
+ if (!Array.isArray(raw)) return [];
100
+ return raw
101
+ .filter((item): item is Obj => isObj(item) && item.deleted !== true)
102
+ .map((item) => ({
103
+ id: String(item.id ?? ""),
104
+ text: str(item.text) ?? "",
105
+ author: isObj(item.author)
106
+ ? (str(item.author.login) ?? str(item.author.fullName))
107
+ : str(item.author),
108
+ created: isoTime(item.created),
109
+ }));
110
+ }
111
+
112
+ function fields(raw: unknown): { name: string; value: unknown }[] {
113
+ if (!Array.isArray(raw)) return [];
114
+ return raw
115
+ .filter((field): field is Obj => isObj(field) && typeof field.name === "string")
116
+ .map((field) => ({ name: field.name as string, value: plainValue(field.value) }));
117
+ }
118
+
119
+ /** Normalize `ytrack issue view --json …` into one shape. */
120
+ function issueView(raw: Obj): IssueView {
121
+ const list = fields(raw.customFields);
122
+ const issueId = String(raw.idReadable ?? "");
123
+ const stateField = str(list.find((field) => field.name === "State")?.value);
124
+ return {
125
+ issueId,
126
+ summary: str(raw.summary) ?? "",
127
+ description: str(raw.description),
128
+ state: str(plainValue(raw.state)) ?? stateField,
129
+ url: str(raw.url),
130
+ fields: list,
131
+ comments: comments(raw.comments),
132
+ source: "ytrack",
133
+ };
134
+ }
135
+
136
+ /** YYYY-MM-DD of a YouTrack work date (midnight UTC). */
137
+ const workDay = (dateMs: number): string => new Date(dateMs).toISOString().slice(0, 10);
138
+
139
+ // ---------------------------------------------------------------- ytrack
140
+
141
+ const VIEW_FIELDS = "idReadable,summary,description,state,url,customFields,comments";
142
+
143
+ type Ran = { ok: true; json: unknown } | { ok: false; status: number | null; error: string };
144
+ type RanFailure = Extract<Ran, { ok: false }>;
145
+
146
+ function ranJson(run: YtrackRun, args: string[], input?: string): Ran {
147
+ const out = run(args, input);
148
+ const what = `ytrack ${args.slice(0, 2).join(" ")}`;
149
+ if (out.missing) return { ok: false, status: null, error: `${what}: ${YTRACK_MISSING}` };
150
+ if (out.status !== YTRACK_EXIT.ok) {
151
+ const detail = ytrackDetail(out);
152
+ return {
153
+ ok: false,
154
+ status: out.status,
155
+ error: `${what} failed (exit ${out.status ?? "signal"})${detail ? `: ${detail}` : ""}`,
156
+ };
157
+ }
158
+ try {
159
+ return { ok: true, json: JSON.parse(out.stdout) as unknown };
160
+ } catch {
161
+ return { ok: false, status: out.status, error: `${what} printed invalid JSON` };
162
+ }
163
+ }
164
+
165
+ /** The unblock for a failure that sent nothing: install ytrack, or log in to `host`. */
166
+ const unblockFor = (ran: RanFailure, host: string | null): { unblock?: string } =>
167
+ ran.status === null
168
+ ? { unblock: ytrackMissingUnblock(host) }
169
+ : ran.status === YTRACK_EXIT.auth
170
+ ? { unblock: ytrackLogin(host) }
171
+ : {};
172
+
173
+ const readFailure = (ran: RanFailure, host: string | null): IssueOutcome<never> => ({
174
+ ok: false,
175
+ code: ran.status === null || ran.status === YTRACK_EXIT.auth ? "unavailable" : "failed",
176
+ error: ran.error,
177
+ ...unblockFor(ran, host),
178
+ });
179
+
180
+ /**
181
+ * A failed ytrack write: auth (4), cancel (2) and a missing binary sent
182
+ * nothing; any other failure may have landed, so the caller reads back.
183
+ */
184
+ const writeFailure = (ran: RanFailure, host: string | null): WriteResult => ({
185
+ ok: false,
186
+ error: ran.error,
187
+ outcome:
188
+ ran.status === YTRACK_EXIT.auth || ran.status === YTRACK_EXIT.cancelled || ran.status === null
189
+ ? "not_applied"
190
+ : "unknown",
191
+ ...unblockFor(ran, host),
192
+ });
193
+
194
+ /** The backend over `run`; `host` (youtrack.json's baseUrl) names the login in an unblock. */
195
+ export function ytrackBackend(run: YtrackRun, host: string | null = null): YouTrackBackend {
196
+ return {
197
+ kind: "ytrack",
198
+ async showIssue(issueId) {
199
+ const ran = ranJson(run, ["issue", "view", issueId, "--json", VIEW_FIELDS]);
200
+ if (!ran.ok) return readFailure(ran, host);
201
+ if (!isObj(ran.json)) return { ok: false, code: "failed", error: "ytrack printed no issue" };
202
+ return { ok: true, data: issueView(ran.json) };
203
+ },
204
+ async createIssue(input) {
205
+ const args = ["issue", "create", "--project", input.project, "--summary", input.summary];
206
+ if (input.description !== undefined) args.push("--description-file", "-");
207
+ args.push("--json", "idReadable,url");
208
+ const ran = ranJson(run, args, input.description);
209
+ if (!ran.ok) return readFailure(ran, host);
210
+ const json = isObj(ran.json) ? ran.json : {};
211
+ return {
212
+ ok: true,
213
+ data: { issueId: String(json.idReadable ?? ""), url: str(json.url), source: "ytrack" },
214
+ };
215
+ },
216
+ async addComment(issueId, text) {
217
+ const ran = ranJson(
218
+ run,
219
+ ["issue", "comment", issueId, "--body-file", "-", "--json", "id,url"],
220
+ text,
221
+ );
222
+ if (!ran.ok) return writeFailure(ran, host);
223
+ return {
224
+ ok: true,
225
+ data: { issueId, ...(isObj(ran.json) ? ran.json : {}) },
226
+ };
227
+ },
228
+ async addWorkItem(issueId, item) {
229
+ const ran = ranJson(run, [
230
+ "work-item",
231
+ "add",
232
+ issueId,
233
+ "--duration",
234
+ String(item.minutes),
235
+ "--date",
236
+ workDay(item.dateMs),
237
+ "--text",
238
+ item.text,
239
+ "--json",
240
+ "id,date,duration",
241
+ ]);
242
+ if (!ran.ok) return writeFailure(ran, host);
243
+ const json = isObj(ran.json) ? ran.json : {};
244
+ return {
245
+ ok: true,
246
+ data: { issueId, workItemId: json.id, minutes: item.minutes, dateMs: item.dateMs },
247
+ };
248
+ },
249
+ async texts(issueId, where) {
250
+ const ran =
251
+ where === "comments"
252
+ ? ranJson(run, ["issue", "view", issueId, "--json", "comments"])
253
+ : ranJson(run, ["work-item", "list", issueId, "--json", "id,text"]);
254
+ if (!ran.ok) return null;
255
+ const list = where === "comments" && isObj(ran.json) ? ran.json.comments : ran.json;
256
+ if (!Array.isArray(list)) return null;
257
+ // ytrack lists every item (no page cap) and leaves deleted comments out.
258
+ return {
259
+ texts: list.flatMap((item) =>
260
+ isObj(item) && typeof item.text === "string" ? [item.text] : [],
261
+ ),
262
+ complete: true,
263
+ };
264
+ },
265
+ };
266
+ }
267
+
268
+ // ---------------------------------------------------------------- selection
269
+
270
+ /** ytrack from `env`'s PATH, aimed at youtrack.json's baseUrl when set. */
271
+ export function youTrackBackend(env: NodeJS.ProcessEnv = process.env): YouTrackBackend {
272
+ const host = youTrackBaseUrl();
273
+ return ytrackBackend(ytrackRunner(env, host), host);
274
+ }
275
+
276
+ /** null when YouTrack writes are allowed, else the refusal naming WORKIT_YT_WRITE. */
277
+ export function youTrackWriteGate(
278
+ env: NodeJS.ProcessEnv,
279
+ what = "a YouTrack write",
280
+ ): Extract<IssueOutcome<never>, { ok: false }> | null {
281
+ if (env[YT_WRITE_GATE] === "1") return null;
282
+ return {
283
+ ok: false,
284
+ code: "blocked",
285
+ error: `${what} requires ${YT_WRITE_GATE}=1 (refusing to mutate the tracker)`,
286
+ unblock: `set ${YT_WRITE_GATE}=1 for this command once the user asked for the write`,
287
+ };
288
+ }
@@ -0,0 +1,260 @@
1
+ import path from "node:path";
2
+ import { fail, ok, type Result } from "../core";
3
+ import { configDir } from "./config";
4
+ import { configDir as workitConfigDir, userHome } from "./workit-paths.mjs";
5
+ import {
6
+ context as legacyContext,
7
+ meetingWorkItemText,
8
+ parseDuration as legacyParseDuration,
9
+ youTrackWorkDateMs,
10
+ } from "./youtrack";
11
+ import {
12
+ youTrackBackend,
13
+ youTrackWriteGate,
14
+ type WriteResult,
15
+ type YouTrackBackend,
16
+ } from "./youtrack-backend";
17
+
18
+ export const ISSUE_RE = /^[A-Z]+-\d+$/;
19
+ export const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
20
+
21
+ // The config dir comes from core/workit-paths.mjs; default env (no args) routes
22
+ // through configDir(), an explicit env resolves with `home` as its HOME.
23
+ export const configPath = (env: NodeJS.ProcessEnv = process.env, home = userHome(env)) =>
24
+ path.join(
25
+ env === process.env ? configDir() : workitConfigDir({ ...env, HOME: home }),
26
+ "youtrack.json",
27
+ );
28
+
29
+ type MaybePromise<T> = T | Promise<T>;
30
+ export type LegacyValue = Record<string, unknown> | void;
31
+ export type NotApplied = {
32
+ ok: false;
33
+ error: string;
34
+ outcome: "not_applied";
35
+ unblock?: string;
36
+ };
37
+
38
+ export type YouTrackOperations = {
39
+ context(input: Record<string, unknown>): MaybePromise<LegacyValue>;
40
+ parseDuration(text: string, workspaceRoot: string): MaybePromise<LegacyValue>;
41
+ postComment(issueId: string, markdown: string, workspaceRoot?: string): MaybePromise<LegacyValue>;
42
+ logTime(input: Record<string, unknown>): MaybePromise<LegacyValue>;
43
+ };
44
+
45
+ export const unwrap = (value: LegacyValue) => {
46
+ if (!value) return {};
47
+ if (value.error) throw new Error(String(value.error));
48
+ if (value.ok === false) throw new Error(String(value.error ?? "YouTrack operation failed"));
49
+ return (value.data as Record<string, unknown> | undefined) ?? value;
50
+ };
51
+
52
+ /**
53
+ * A write through ytrack (#263), as the legacy value shape, behind the
54
+ * WORKIT_YT_WRITE gate. A failure that surely did not land (gate, missing
55
+ * ytrack, logged out) stays a not_applied value with its unblock; any other
56
+ * failure throws, so callers report an unknown outcome.
57
+ */
58
+ async function ytrackWrite(
59
+ write: (backend: YouTrackBackend) => Promise<WriteResult>,
60
+ ): Promise<LegacyValue> {
61
+ const gate = youTrackWriteGate(process.env);
62
+ if (gate)
63
+ return {
64
+ ok: false,
65
+ error: gate.error,
66
+ outcome: "not_applied",
67
+ unblock: gate.unblock,
68
+ };
69
+ const result = await write(youTrackBackend());
70
+ if (result.ok) return unwrap({ ok: true, data: result.data });
71
+ if (result.outcome === "not_applied")
72
+ return {
73
+ ok: false,
74
+ error: result.error,
75
+ outcome: "not_applied",
76
+ ...(result.unblock ? { unblock: result.unblock } : {}),
77
+ };
78
+ throw new Error(result.error);
79
+ }
80
+
81
+ export const defaultOperations: YouTrackOperations = {
82
+ context: (input) => legacyContext(input as never),
83
+ parseDuration: (text, workspaceRoot) => legacyParseDuration(text, workspaceRoot),
84
+ postComment: (issueId, markdown) =>
85
+ ytrackWrite((backend) => backend.addComment(issueId, markdown)),
86
+ logTime: (input) =>
87
+ ytrackWrite(async (backend) => {
88
+ const date = youTrackWorkDateMs(
89
+ input.dateMs != null ? String(input.dateMs) : String(input.date ?? "auto"),
90
+ );
91
+ if ("error" in date) return { ok: false, error: date.error, outcome: "not_applied" };
92
+ return backend.addWorkItem(String(input.issueId), {
93
+ minutes: Number(input.minutes),
94
+ text: typeof input.text === "string" ? input.text : "workit",
95
+ dateMs: date.data.dateMs,
96
+ });
97
+ }),
98
+ };
99
+
100
+ type PostInput = {
101
+ confirmed: boolean;
102
+ issueId: string;
103
+ markdown: string;
104
+ minutes?: number;
105
+ dateMs?: number;
106
+ workspace_root?: string;
107
+ };
108
+
109
+ type PostData = {
110
+ issueId: string;
111
+ postedComment: boolean;
112
+ loggedMinutes: number;
113
+ outcome?: "unknown" | "not_applied";
114
+ instructions?: string;
115
+ retry?: "youtrack.update" | "youtrack.time";
116
+ unblock?: string;
117
+ };
118
+
119
+ const notApplied = (value: LegacyValue): value is NotApplied =>
120
+ value?.ok === false && value.outcome === "not_applied";
121
+
122
+ export async function postUpdate(
123
+ input: PostInput,
124
+ operations: Pick<YouTrackOperations, "postComment" | "logTime"> = defaultOperations,
125
+ ): Promise<Result<PostData>> {
126
+ if (input.confirmed !== true) return fail("confirmed: true required");
127
+ if (!ISSUE_RE.test(input.issueId)) return fail("invalid issueId");
128
+ if (!input.markdown?.trim()) return fail("markdown required");
129
+ if (input.minutes != null && input.minutes <= 0) return fail("minutes must be positive");
130
+
131
+ try {
132
+ const comment = await operations.postComment(
133
+ input.issueId,
134
+ input.markdown,
135
+ input.workspace_root,
136
+ );
137
+ if (notApplied(comment))
138
+ return fail(comment.error, {
139
+ issueId: input.issueId,
140
+ postedComment: false,
141
+ loggedMinutes: 0,
142
+ outcome: "not_applied",
143
+ retry: "youtrack.update",
144
+ ...(comment.unblock ? { unblock: comment.unblock } : {}),
145
+ });
146
+ unwrap(comment);
147
+ } catch (error) {
148
+ return fail(message(error), {
149
+ issueId: input.issueId,
150
+ postedComment: false,
151
+ loggedMinutes: 0,
152
+ outcome: "unknown",
153
+ instructions: "Check YouTrack comments manually; do not retry while the outcome is unknown.",
154
+ });
155
+ }
156
+
157
+ if (input.minutes != null) {
158
+ try {
159
+ const time = await operations.logTime({
160
+ issueId: input.issueId,
161
+ minutes: input.minutes,
162
+ ...(input.dateMs === undefined ? {} : { dateMs: input.dateMs }),
163
+ text: "workit update",
164
+ workspace_root: input.workspace_root,
165
+ });
166
+ if (notApplied(time))
167
+ return fail(time.error, {
168
+ issueId: input.issueId,
169
+ postedComment: true,
170
+ loggedMinutes: 0,
171
+ outcome: "not_applied",
172
+ retry: "youtrack.time",
173
+ ...(time.unblock ? { unblock: time.unblock } : {}),
174
+ });
175
+ unwrap(time);
176
+ } catch (error) {
177
+ return fail(message(error), {
178
+ issueId: input.issueId,
179
+ postedComment: true,
180
+ loggedMinutes: 0,
181
+ outcome: "unknown",
182
+ instructions:
183
+ "Check YouTrack time entries manually; do not retry while the outcome is unknown.",
184
+ });
185
+ }
186
+ }
187
+
188
+ return ok({
189
+ issueId: input.issueId,
190
+ postedComment: true,
191
+ loggedMinutes: input.minutes ?? 0,
192
+ });
193
+ }
194
+
195
+ export async function logTimeUpdate(
196
+ input: Record<string, unknown>,
197
+ operation: Pick<YouTrackOperations, "logTime"> = defaultOperations,
198
+ ): Promise<Result<Record<string, unknown>>> {
199
+ try {
200
+ const value = await operation.logTime(input);
201
+ if (notApplied(value))
202
+ return fail(value.error, {
203
+ issueId: input.issueId,
204
+ loggedMinutes: 0,
205
+ outcome: "not_applied",
206
+ retry: "youtrack.time",
207
+ ...(value.unblock ? { unblock: value.unblock } : {}),
208
+ });
209
+ return ok(unwrap(value));
210
+ } catch (error) {
211
+ return fail(message(error), {
212
+ issueId: input.issueId,
213
+ loggedMinutes: 0,
214
+ outcome: "unknown",
215
+ instructions:
216
+ "Check YouTrack time entries manually; do not retry while the outcome is unknown.",
217
+ });
218
+ }
219
+ }
220
+
221
+ export function normalizeContext(value: LegacyValue, mode?: string): LegacyValue {
222
+ if (!value || mode !== "meetings" || value.error) return value;
223
+ // Operations may return the legacy `{ data }` envelope; normalize inside it.
224
+ if (value.data && typeof value.data === "object" && !Array.isArray(value.data)) {
225
+ const inner = normalizeContext(value.data as Record<string, unknown>, mode);
226
+ return inner && inner.error
227
+ ? { ...value, ...inner, data: undefined }
228
+ : { ...value, data: inner };
229
+ }
230
+ const config = value.config as Record<string, unknown> | undefined;
231
+ const { meetingIssues: _meetingIssues, ...singleMeetingConfig } = config ?? {};
232
+ const options = Array.isArray(value.meetingOptions)
233
+ ? (value.meetingOptions as Array<Record<string, unknown>>)
234
+ : [];
235
+ // The meeting issue comes only from youtrack.json — there is no built-in one.
236
+ const configured = typeof config?.meetingIssue === "string" ? config.meetingIssue : "";
237
+ const resolved = typeof value.issueId === "string" ? value.issueId : "";
238
+ const firstOption = typeof options[0]?.issue === "string" ? options[0].issue : "";
239
+ const issue = configured || resolved || firstOption;
240
+ if (!issue)
241
+ return {
242
+ ...value,
243
+ error: "no meeting issue configured — set meetingIssue in youtrack.json",
244
+ requiresIssueInput: true,
245
+ };
246
+ const selected = options.find((option) => option.issue === issue) ?? {
247
+ key: "general",
248
+ issue,
249
+ label: issue,
250
+ workItemText: meetingWorkItemText(config),
251
+ };
252
+ return {
253
+ ...value,
254
+ config: singleMeetingConfig,
255
+ meetingOptions: [selected],
256
+ requiresMeetingChoice: false,
257
+ issueId: issue,
258
+ workItemText: meetingWorkItemText(config, selected),
259
+ };
260
+ }
@@ -0,0 +1,73 @@
1
+ // Idempotent YouTrack writes for `workit youtrack note|time|meeting` (S16
2
+ // review M4). Each comment or work item carries a deterministic marker,
3
+ // `<!-- workit-action:<sha256> -->`, derived from the request. Before writing,
4
+ // the issue is read back and a write whose marker is already present is
5
+ // skipped (`already_done`); after a write with an unknown outcome (a timeout
6
+ // after the server applied it), the read-back settles it instead of a retry
7
+ // posting a duplicate.
8
+ import { sha256 } from "./task-contract";
9
+ import { youTrackBaseUrl } from "./youtrack";
10
+ import { youTrackBackend, type YouTrackBackend } from "./youtrack-backend";
11
+
12
+ export type MarkerWhere = "comments" | "workItems";
13
+
14
+ /**
15
+ * The marker for one request. It names youtrack.json's baseUrl (read without
16
+ * the token file) so two instances never share a marker; with ytrack and no
17
+ * baseUrl it names ytrack's default host.
18
+ */
19
+ export const youTrackMarker = (request: Record<string, unknown>): string =>
20
+ `<!-- workit-action:${sha256({ ...request, baseUrl: youTrackBaseUrl() ?? "ytrack:default" })} -->`;
21
+
22
+ export const withMarker = (text: string, marker: string): string =>
23
+ `${text}${text ? "\n\n" : ""}${marker}`;
24
+
25
+ /** true/false when the read-back is conclusive, null when it is not. */
26
+ export async function markerPresent(
27
+ issueId: string,
28
+ marker: string,
29
+ where: MarkerWhere,
30
+ backend: YouTrackBackend = youTrackBackend(),
31
+ ): Promise<boolean | null> {
32
+ const read = await backend.texts(issueId, where);
33
+ if (!read) return null;
34
+ if (read.texts.some((text) => text.includes(marker))) return true;
35
+ // A partial read may hide an older match: only a hit is conclusive then.
36
+ return read.complete ? false : null;
37
+ }
38
+
39
+ type WriteResult = { ok: boolean; error?: string | null; data?: unknown };
40
+
41
+ export type OnceOutcome =
42
+ | {
43
+ status: "done" | "already_done" | "settled";
44
+ data?: unknown;
45
+ /** The pre-write read-back could not rule out an earlier copy (>1 page). */
46
+ precheck?: "inconclusive";
47
+ }
48
+ | { status: "failed" | "unknown"; error: string; data?: unknown };
49
+
50
+ /** Write at most once: skip when the marker is there, read back an unknown outcome. */
51
+ export async function writeOnce(
52
+ issueId: string,
53
+ marker: string,
54
+ where: MarkerWhere,
55
+ write: () => Promise<WriteResult>,
56
+ backend: YouTrackBackend = youTrackBackend(),
57
+ ): Promise<OnceOutcome> {
58
+ const before = await markerPresent(issueId, marker, where, backend);
59
+ if (before === true) return { status: "already_done" };
60
+ const precheck = before === null ? { precheck: "inconclusive" as const } : {};
61
+ const result = await write();
62
+ if (result.ok) return { status: "done", data: result.data, ...precheck };
63
+ const outcome = (result.data as { outcome?: unknown } | undefined)?.outcome;
64
+ if (outcome === "not_applied")
65
+ return { status: "failed", error: result.error ?? "not applied", data: result.data };
66
+ const settled = await markerPresent(issueId, marker, where, backend);
67
+ if (settled === true) return { status: "settled", ...precheck };
68
+ return {
69
+ status: settled === false ? "failed" : "unknown",
70
+ error: result.error ?? "YouTrack write failed",
71
+ data: result.data,
72
+ };
73
+ }