@brainervirus/workit-core 0.10.0 → 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 (150) hide show
  1. package/README.md +5 -3
  2. package/package.json +8 -3
  3. package/scripts/analyze-release-scope.ts +4 -1
  4. package/scripts/doctor-check.ts +3 -1
  5. package/scripts/install-codex-plugin.sh +28 -0
  6. package/scripts/install-cursor-plugin.sh +1 -0
  7. package/scripts/install-opencode-plugin.sh +1 -0
  8. package/scripts/install-pi-package.sh +56 -0
  9. package/scripts/rewrite-workspace-deps.ts +22 -5
  10. package/scripts/sync-release-manifests.ts +8 -5
  11. package/scripts/sync-runtime.sh +7 -3
  12. package/scripts/validate-cursor-marketplace.ts +16 -62
  13. package/skills/workit-babysit/SKILL.md +33 -0
  14. package/skills/workit-behavioral-tdd/SKILL.md +53 -0
  15. package/skills/workit-blast-radius/SKILL.md +31 -0
  16. package/skills/workit-challenge/SKILL.md +62 -0
  17. package/skills/workit-debug/SKILL.md +61 -0
  18. package/skills/workit-deslop/SKILL.md +36 -0
  19. package/skills/workit-diagram/SKILL.md +32 -0
  20. package/skills/workit-green-run/SKILL.md +29 -0
  21. package/skills/workit-handoff/SKILL.md +43 -0
  22. package/skills/workit-implement/SKILL.md +46 -0
  23. package/skills/workit-mockup/SKILL.md +28 -0
  24. package/skills/workit-plan/SKILL.md +66 -0
  25. package/skills/workit-review/SKILL.md +60 -0
  26. package/skills/workit-steer/SKILL.md +32 -0
  27. package/src/core/authority.ts +918 -0
  28. package/src/core/boundary.ts +29 -0
  29. package/src/core/branch.ts +26 -255
  30. package/src/core/changelog.ts +95 -18
  31. package/src/core/commit-flavors.ts +65 -0
  32. package/src/core/config-conversion.ts +183 -0
  33. package/src/core/config.ts +42 -0
  34. package/src/core/cutover.ts +721 -0
  35. package/src/core/detect-hosts.ts +107 -0
  36. package/src/core/doctor.ts +428 -18
  37. package/src/core/external-action-effects.ts +1171 -0
  38. package/src/core/external-action.ts +541 -0
  39. package/src/core/init.ts +83 -32
  40. package/src/core/methods.ts +125 -0
  41. package/src/core/policy-resolver.ts +523 -0
  42. package/src/core/pr-create.ts +7 -1
  43. package/src/core/registration.ts +69 -4
  44. package/src/core/repo-context.ts +59 -1
  45. package/src/core/setup.ts +28 -0
  46. package/src/core/skill-manifests.ts +40 -42
  47. package/src/core/support-matrix.ts +8 -3
  48. package/src/core/sync-runtime.ts +15 -3
  49. package/src/core/task-context.ts +179 -0
  50. package/src/core/task-contract.ts +1136 -0
  51. package/src/core/task-engine.ts +2562 -0
  52. package/src/core/task-evaluation.ts +679 -0
  53. package/src/core/task-store.ts +1071 -0
  54. package/src/core/tracker-issues.ts +237 -0
  55. package/src/core/uninstall.ts +67 -3
  56. package/src/core/vcs-config.ts +53 -22
  57. package/src/core/workers.ts +166 -0
  58. package/src/core/workspaces.ts +3 -1
  59. package/src/core/youtrack-tools.ts +2 -0
  60. package/src/core/youtrack.ts +145 -10
  61. package/src/core.ts +126 -0
  62. package/templates/execution-contract.md +17 -50
  63. package/templates/plan-template.md +3 -3
  64. package/templates/spec-template.md +15 -3
  65. package/templates/workit-contract.md +12 -0
  66. package/commands/wk-changelog.md +0 -2
  67. package/commands/wk-commit.md +0 -2
  68. package/commands/wk-docs-refresh.md +0 -2
  69. package/commands/wk-handoff.md +0 -2
  70. package/commands/wk-implement.md +0 -2
  71. package/commands/wk-init.md +0 -2
  72. package/commands/wk-issue-update.md +0 -2
  73. package/commands/wk-meetings.md +0 -2
  74. package/commands/wk-pr.md +0 -2
  75. package/commands/wk-release-notes.md +0 -2
  76. package/commands/wk-status.md +0 -2
  77. package/commands/wk-verify.md +0 -2
  78. package/scripts/update-superpowers.sh +0 -82
  79. package/scripts/vendor-assets.ts +0 -37
  80. package/skills/wk-changelog/SKILL.md +0 -15
  81. package/skills/wk-commit/SKILL.md +0 -16
  82. package/skills/wk-docs-refresh/SKILL.md +0 -15
  83. package/skills/wk-handoff/SKILL.md +0 -20
  84. package/skills/wk-implement/SKILL.md +0 -47
  85. package/skills/wk-init/SKILL.md +0 -31
  86. package/skills/wk-issue-update/SKILL.md +0 -27
  87. package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
  88. package/skills/wk-meetings/SKILL.md +0 -17
  89. package/skills/wk-pr/SKILL.md +0 -27
  90. package/skills/wk-release-notes/SKILL.md +0 -15
  91. package/skills/wk-status/SKILL.md +0 -16
  92. package/skills/wk-verify/SKILL.md +0 -16
  93. package/src/core/detector.ts +0 -239
  94. package/src/core/flow-state.ts +0 -3048
  95. package/src/core/handoff-context.ts +0 -136
  96. package/src/core/handoff-tools.ts +0 -133
  97. package/src/core/menu.ts +0 -70
  98. package/src/core/plan-tasks.ts +0 -43
  99. package/src/core/reminder.ts +0 -124
  100. package/src/core/sdd.ts +0 -353
  101. package/src/state.ts +0 -22
  102. package/templates/superpowers-doc-contract.md +0 -75
  103. package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
  104. package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
  105. package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
  106. package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
  107. package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
  108. package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
  109. package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
  110. package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
  111. package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
  112. package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
  113. package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
  114. package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
  115. package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
  116. package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
  117. package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -426
  118. package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
  119. package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
  120. package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
  121. package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
  122. package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
  123. package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
  124. package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
  125. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
  126. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
  127. package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
  128. package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
  129. package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
  130. package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
  131. package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
  132. package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
  133. package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
  134. package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
  135. package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
  136. package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
  137. package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
  138. package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
  139. package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
  140. package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
  141. package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
  142. package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
  143. package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
  144. package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
  145. package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
  146. package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
  147. package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
  148. package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
  149. package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
  150. package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
@@ -0,0 +1,1171 @@
1
+ import { branchSetup, resolveBranchPolicyFor, resolveCommitPolicyFor } from "./branch";
2
+ import { hostingCliAvailable, prCreate } from "./pr-create";
3
+ import {
4
+ changelogContext,
5
+ docsRefreshContext,
6
+ isSafeContextRange,
7
+ prReadyContext,
8
+ releaseNotesContext,
9
+ } from "./repo-context";
10
+ import { gitContext } from "./git";
11
+ import { fetchGitHubIssueBody, fetchGitLabIssueBody } from "./tracker-issues";
12
+ import { defaultOperations, ISSUE_RE, logTimeUpdate, postUpdate } from "./youtrack-tools";
13
+ import {
14
+ context as youTrackContext,
15
+ fetchYouTrackIssueBody,
16
+ youTrackConfigPath,
17
+ youTrackRequest,
18
+ youTrackToken,
19
+ youTrackWorkDateMs,
20
+ } from "./youtrack";
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import { failure, success, sha256, type Caller, type Result, type Ref } from "./task-contract";
24
+ import { changelogApply, changelogApplyPreview } from "./changelog";
25
+ import type { ExternalActionRequest } from "./external-action";
26
+ import { externalActionDescriptor, externalActionRequest } from "./external-action";
27
+ import { resolveInside, run as coreRun } from "../core";
28
+ import { vcsConfig } from "./vcs-config";
29
+ import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
30
+ import { assertProductWriteAllowed } from "./workers";
31
+ import { TaskStore } from "./task-store";
32
+
33
+ const run = (root: string, args: string[]) => {
34
+ return coreRun(root, "git", args);
35
+ };
36
+
37
+ const gitValue = (root: string, args: string[]): string | null => {
38
+ const result = run(root, args);
39
+ return result.exitCode === 0 && result.stdout.trim() ? result.stdout.trim() : null;
40
+ };
41
+
42
+ const gitRaw = (root: string, args: string[]): string | null => {
43
+ const result = run(root, args);
44
+ return result.exitCode === 0 ? result.stdout : null;
45
+ };
46
+
47
+ /** Return every staged path, including both sides of renames/copies. */
48
+ const stagedPaths = (root: string): string[] | null => {
49
+ const raw = gitRaw(root, ["diff", "--cached", "--name-status", "-z", "--find-renames", "HEAD"]);
50
+ if (raw === null) return null;
51
+ const tokens = raw.split("\0");
52
+ const paths: string[] = [];
53
+ for (let index = 0; index < tokens.length;) {
54
+ const status = tokens[index++];
55
+ if (!status) continue;
56
+ const rename = /^(?:R|C)\d{3}$/.test(status);
57
+ const count = rename ? 2 : 1;
58
+ for (let offset = 0; offset < count; offset += 1) {
59
+ const path = tokens[index++];
60
+ if (!path) return null;
61
+ paths.push(path);
62
+ }
63
+ }
64
+ return [...new Set(paths)];
65
+ };
66
+
67
+ const pushRemote = (root: string): string | null =>
68
+ gitValue(root, ["remote", "get-url", "--push", "origin"]) ??
69
+ gitValue(root, ["remote", "get-url", "origin"]);
70
+
71
+ type RemoteParts = { host: string; path: string; protocol: string };
72
+
73
+ const remoteParts = (value: string): RemoteParts | null => {
74
+ const raw = value.trim();
75
+ if (!raw) return null;
76
+ try {
77
+ if (!raw.includes("://")) {
78
+ const match = /^(?:[^@\s]+@)?([^:]+):(.+)$/.exec(raw);
79
+ if (!match) return null;
80
+ return {
81
+ host: match[1].toLowerCase(),
82
+ path: `/${match[2]}`.replace(/\/$/, ""),
83
+ protocol: "ssh:",
84
+ };
85
+ }
86
+ const url = new URL(raw);
87
+ return {
88
+ host: url.hostname.toLowerCase(),
89
+ path: url.pathname.replace(/\/$/, ""),
90
+ protocol: url.protocol,
91
+ };
92
+ } catch {
93
+ return null;
94
+ }
95
+ };
96
+
97
+ const credentialFreeRemote = (value: string): string | null => {
98
+ const parts = remoteParts(value);
99
+ return parts ? `${parts.protocol}//${parts.host}${parts.path}` : null;
100
+ };
101
+
102
+ const unknown = (operation: string) =>
103
+ failure("external_outcome_unknown", "external action outcome is unknown", { operation });
104
+
105
+ type ContextReadPayload = Extract<ExternalActionRequest, { operation: "context.read" }>["payload"];
106
+
107
+ /** Read one fixed repository/provider context without approval or mutation. */
108
+ export const readExternalContext = async (
109
+ root: string,
110
+ payload: ContextReadPayload,
111
+ ): Promise<Result<unknown>> => {
112
+ if (payload.range !== undefined && !isSafeContextRange(payload.range))
113
+ return failure("invalid_input", "revision range is invalid", {
114
+ fields: [{ path: "range", reason: "option-like or control characters are not allowed" }],
115
+ });
116
+ const render = (result: { stdout: string; stderr: string; exitCode: number; cwd: string }) =>
117
+ result.exitCode === 0
118
+ ? success(null, null, { kind: payload.kind, context: result.stdout })
119
+ : failure("capability_unavailable", "requested context is unavailable", {
120
+ capability: payload.kind,
121
+ });
122
+ switch (payload.kind) {
123
+ case "git": {
124
+ const context = gitContext(root);
125
+ return typeof context.exitCode === "number" && context.exitCode !== 0
126
+ ? failure("capability_unavailable", "Git context is unavailable", { capability: "git" })
127
+ : success(null, null, { kind: payload.kind, context });
128
+ }
129
+ case "pr":
130
+ return render(prReadyContext(root, payload.range));
131
+ case "changelog":
132
+ return render(changelogContext(root, payload.range));
133
+ case "release":
134
+ return render(releaseNotesContext(root, payload.range ?? "HEAD~1...HEAD"));
135
+ case "affected":
136
+ return render(docsRefreshContext(root, payload.range));
137
+ case "youtrack": {
138
+ const value = youTrackContext({
139
+ workspace_root: root,
140
+ ...(payload.specPath ? { spec_path: payload.specPath } : {}),
141
+ ...(payload.planPath ? { plan_path: payload.planPath } : {}),
142
+ ...(payload.issueId ? { issue_id: payload.issueId } : {}),
143
+ ...(payload.issueUrl ? { issue_url: payload.issueUrl } : {}),
144
+ ...(payload.issueRef ? { issue_ref: payload.issueRef } : {}),
145
+ ...(payload.mode ? { mode: payload.mode } : {}),
146
+ });
147
+ if (value && typeof value === "object" && "error" in value)
148
+ return failure("capability_unavailable", "YouTrack context is unavailable", {
149
+ capability: "youtrack",
150
+ });
151
+ if (value.requiresMeetingChoice || !value.issueId)
152
+ return success(null, null, { kind: payload.kind, context: value });
153
+ const body = await fetchYouTrackIssueBody(value.issueId);
154
+ if ("error" in body && body.kind === "request")
155
+ return failure("capability_unavailable", "YouTrack issue body is unavailable", {
156
+ capability: "youtrack",
157
+ });
158
+ return success(null, null, {
159
+ kind: payload.kind,
160
+ context: {
161
+ ...value,
162
+ ...("data" in body
163
+ ? { issueBody: body.data }
164
+ : { issueBody: null, issueBodyError: body.error }),
165
+ },
166
+ });
167
+ }
168
+ case "github_issue": {
169
+ const ref = payload.issueId ?? payload.issueUrl ?? payload.issueRef;
170
+ if (!ref)
171
+ return failure("invalid_input", "github_issue needs issueId, issueUrl, or issueRef");
172
+ const body = await fetchGitHubIssueBody(ref, root);
173
+ if ("error" in body)
174
+ return failure("capability_unavailable", `GitHub issue is unavailable: ${body.error}`, {
175
+ capability: "github_issue",
176
+ });
177
+ return success(null, null, { kind: payload.kind, context: { issueBody: body.data } });
178
+ }
179
+ case "gitlab_issue": {
180
+ const ref = payload.issueId ?? payload.issueUrl ?? payload.issueRef;
181
+ if (!ref)
182
+ return failure("invalid_input", "gitlab_issue needs issueId, issueUrl, or issueRef");
183
+ const body = await fetchGitLabIssueBody(ref, root);
184
+ if ("error" in body)
185
+ return failure("capability_unavailable", `GitLab issue is unavailable: ${body.error}`, {
186
+ capability: "gitlab_issue",
187
+ });
188
+ return success(null, null, { kind: payload.kind, context: { issueBody: body.data } });
189
+ }
190
+ }
191
+ };
192
+
193
+ const localAction = (operation: ExternalActionRequest["operation"]): boolean =>
194
+ operation === "git.branch_setup" ||
195
+ operation === "git.commit" ||
196
+ operation === "git.push" ||
197
+ operation === "hosting.pull_request" ||
198
+ operation === "changelog.apply";
199
+
200
+ export const assertLocalExternalActionWriter = (
201
+ root: string,
202
+ caller: { host: Caller["host"]; actor: string },
203
+ paths: string[] = ["."],
204
+ ): Result<null> => {
205
+ const store = new TaskStore(root);
206
+ const listed = store.listTasks();
207
+ const workspace = store.readWorkspace();
208
+ if (!listed.ok) return listed as Result<never>;
209
+ if (!workspace.ok) return workspace as Result<never>;
210
+ if (!workspace.data) return failure("not_found", "workspace not found");
211
+ const candidates = listed.data.filter(
212
+ (task) =>
213
+ task.status === "active" &&
214
+ task.workspaceId === workspace.data!.id &&
215
+ task.intent.provenance.session?.kind === "host" &&
216
+ task.intent.provenance.session.host === caller.host &&
217
+ task.intent.provenance.session.handle === caller.actor,
218
+ );
219
+ if (candidates.length !== 1)
220
+ return failure("permission_denied", "local external action requires exactly one active task");
221
+ const allowed = assertProductWriteAllowed({
222
+ task: candidates[0],
223
+ workspace: workspace.data,
224
+ caller: { host: caller.host, actor: caller.actor, workerId: null },
225
+ paths,
226
+ store,
227
+ });
228
+ return allowed.ok ? success(null, null, null) : (allowed as Result<never>);
229
+ };
230
+
231
+ const safePath = (root: string, value: string): string | null => {
232
+ try {
233
+ return resolveInside(root, value);
234
+ } catch {
235
+ return null;
236
+ }
237
+ };
238
+
239
+ export type ResolvedExternalAction = {
240
+ request: ExternalActionRequest;
241
+ descriptorPayload: unknown;
242
+ marker?: string;
243
+ };
244
+
245
+ export type HostingReadEvidence = {
246
+ outcome: "succeeded" | "unknown";
247
+ evidenceDigest: string;
248
+ observation: object;
249
+ data?: { provider: "github" | "gitlab" | "youtrack"; id: string | number };
250
+ step?: string;
251
+ };
252
+
253
+ /** Rebuild the originally approved hosting target from persisted descriptor bytes. */
254
+ export const approvedResolvedExternalAction = (content: string): Result<ResolvedExternalAction> => {
255
+ try {
256
+ const value = JSON.parse(content) as { operation?: unknown; payload?: unknown };
257
+ if (
258
+ value.operation !== "hosting.pull_request" &&
259
+ value.operation !== "changelog.apply" &&
260
+ value.operation !== "youtrack.update" &&
261
+ value.operation !== "youtrack.time" &&
262
+ value.operation !== "youtrack.meeting"
263
+ )
264
+ return failure("invalid_input", "approved action is not a reconciliable external action");
265
+ if (!value.payload || typeof value.payload !== "object")
266
+ return failure("invalid_input", "approved action payload is incomplete");
267
+ const payload = { ...(value.payload as Record<string, unknown>) };
268
+ const resolved = payload.resolved;
269
+ delete payload.resolved;
270
+ const request = externalActionRequest({ operation: value.operation, payload });
271
+ if (!request.ok || !resolved || typeof resolved !== "object")
272
+ return failure("invalid_input", "approved hosting target is incomplete");
273
+ const target = resolved as Record<string, unknown>;
274
+ if (
275
+ value.operation === "hosting.pull_request" &&
276
+ (typeof target.marker !== "string" ||
277
+ typeof target.source_branch !== "string" ||
278
+ typeof target.source_commit !== "string" ||
279
+ typeof target.remote !== "string" ||
280
+ typeof payload.target_branch !== "string")
281
+ )
282
+ return failure("invalid_input", "approved hosting target is incomplete");
283
+ if (
284
+ value.operation === "changelog.apply" &&
285
+ (typeof target.target !== "string" ||
286
+ typeof target.beforeDigest !== "string" ||
287
+ typeof target.beforeExists !== "boolean" ||
288
+ typeof target.afterDigest !== "string")
289
+ )
290
+ return failure("invalid_input", "approved changelog target is incomplete");
291
+ if (
292
+ value.operation !== "hosting.pull_request" &&
293
+ value.operation !== "changelog.apply" &&
294
+ (typeof target.marker !== "string" ||
295
+ typeof target.baseUrl !== "string" ||
296
+ typeof target.issueId !== "string" ||
297
+ !Array.isArray(target.steps))
298
+ )
299
+ return failure("invalid_input", "approved YouTrack target is incomplete");
300
+ return success(null, null, {
301
+ request: request.data,
302
+ descriptorPayload: value.payload,
303
+ marker: String(target.marker),
304
+ });
305
+ } catch {
306
+ return failure("invalid_input", "approved action descriptor is invalid");
307
+ }
308
+ };
309
+
310
+ const remoteRepo = (value: string): string | null => {
311
+ const parts = remoteParts(value);
312
+ if (!parts) return null;
313
+ const repo = parts.path.replace(/^\//, "").replace(/\.git$/, "");
314
+ return repo.includes("/") ? repo : null;
315
+ };
316
+
317
+ const remoteIdentity = (value: string): string | null => {
318
+ const parts = remoteParts(value);
319
+ return parts ? `${parts.host}${parts.path.replace(/\.git$/, "")}` : null;
320
+ };
321
+
322
+ const remoteProvider = (value: string): "github" | "gitlab" | null => {
323
+ const host = remoteParts(value)?.host;
324
+ return host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null;
325
+ };
326
+
327
+ const unknownHostingEvidence = (operation: string): Result<HostingReadEvidence> =>
328
+ failure("external_outcome_unknown", "hosting action outcome remains unknown", {
329
+ operation,
330
+ outcome: "unknown",
331
+ });
332
+
333
+ /** Fixed, read-only GitHub/GitLab lookup; callers must keep the returned observation private. */
334
+ export const readHostingAction = async (
335
+ root: string,
336
+ resolved: ResolvedExternalAction,
337
+ actionRef: Ref,
338
+ ): Promise<Result<HostingReadEvidence>> => {
339
+ if (resolved.request.operation !== "hosting.pull_request" || !resolved.marker)
340
+ return unknownHostingEvidence("hosting.pull_request");
341
+ try {
342
+ const cfg = vcsConfig("load", root);
343
+ if (
344
+ !cfg.ok ||
345
+ !cfg.tokenPath ||
346
+ !cfg.provider ||
347
+ (cfg.provider !== "github" && cfg.provider !== "gitlab")
348
+ )
349
+ return unknownHostingEvidence("hosting.pull_request");
350
+ const remote = pushRemote(root);
351
+ const repo = remote ? remoteRepo(remote) : null;
352
+ const approved = resolved.descriptorPayload as {
353
+ target_branch?: unknown;
354
+ resolved?: Record<string, unknown>;
355
+ };
356
+ const target = approved.target_branch;
357
+ const source = approved.resolved;
358
+ const branch = source?.source_branch;
359
+ const sourceCommit = source?.source_commit;
360
+ const approvedRemote = source?.remote;
361
+ const currentIdentity = remote ? remoteIdentity(remote) : null;
362
+ const approvedIdentity =
363
+ typeof approvedRemote === "string" ? remoteIdentity(approvedRemote) : null;
364
+ const apiHost =
365
+ cfg.provider === "github"
366
+ ? "github.com"
367
+ : (() => {
368
+ try {
369
+ return new URL(
370
+ cfg.gitlab?.apiUrl ?? "https://gitlab.com/api/v4",
371
+ ).hostname.toLowerCase();
372
+ } catch {
373
+ return "";
374
+ }
375
+ })();
376
+ if (
377
+ !repo ||
378
+ typeof branch !== "string" ||
379
+ typeof target !== "string" ||
380
+ typeof sourceCommit !== "string" ||
381
+ typeof approvedRemote !== "string" ||
382
+ !currentIdentity ||
383
+ currentIdentity !== approvedIdentity ||
384
+ repo !== remoteRepo(approvedRemote) ||
385
+ (remoteProvider(approvedRemote) !== null &&
386
+ remoteProvider(approvedRemote) !== cfg.provider) ||
387
+ remoteParts(approvedRemote)?.host !== apiHost
388
+ )
389
+ return unknownHostingEvidence("hosting.pull_request");
390
+ const token = fs.readFileSync(cfg.tokenPath, "utf8").trim();
391
+ const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" };
392
+ const url =
393
+ cfg.provider === "github"
394
+ ? `https://api.github.com/repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${repo.split("/")[0]}:${branch}`)}&base=${encodeURIComponent(target)}&per_page=100&page=1`
395
+ : `${cfg.gitlab?.apiUrl ?? "https://gitlab.com/api/v4"}/projects/${encodeURIComponent(repo)}/merge_requests?state=all&source_branch=${encodeURIComponent(branch)}&target_branch=${encodeURIComponent(target)}&per_page=100&page=1`;
396
+ const response = await fetch(url, {
397
+ headers: cfg.provider === "gitlab" ? { "PRIVATE-TOKEN": token } : headers,
398
+ });
399
+ if (!response.ok) return unknownHostingEvidence("hosting.pull_request");
400
+ const values: unknown = await response.json();
401
+ // A full page is intentionally treated as truncated until pagination is added.
402
+ if (!Array.isArray(values) || values.length >= 100)
403
+ return unknownHostingEvidence("hosting.pull_request");
404
+ const records: Record<string, unknown>[] = [];
405
+ for (const item of values) {
406
+ if (!item || typeof item !== "object" || Array.isArray(item))
407
+ return unknownHostingEvidence("hosting.pull_request");
408
+ const record = item as Record<string, unknown>;
409
+ const body = record.body ?? record.description;
410
+ const base = record.base;
411
+ const head = record.head;
412
+ const githubShape =
413
+ cfg.provider === "github" &&
414
+ base !== null &&
415
+ typeof base === "object" &&
416
+ !Array.isArray(base) &&
417
+ head !== null &&
418
+ typeof head === "object" &&
419
+ !Array.isArray(head) &&
420
+ typeof (base as Record<string, unknown>).ref === "string" &&
421
+ typeof (head as Record<string, unknown>).ref === "string" &&
422
+ typeof (head as Record<string, unknown>).sha === "string";
423
+ const gitlabShape =
424
+ cfg.provider === "gitlab" &&
425
+ typeof record.target_branch === "string" &&
426
+ typeof record.source_branch === "string" &&
427
+ typeof record.sha === "string";
428
+ const targetValue =
429
+ cfg.provider === "github" && githubShape
430
+ ? (base as Record<string, unknown>).ref
431
+ : record.target_branch;
432
+ const sourceValue =
433
+ cfg.provider === "github" && githubShape
434
+ ? (head as Record<string, unknown>).ref
435
+ : record.source_branch;
436
+ const shaValue =
437
+ cfg.provider === "github" && githubShape
438
+ ? (head as Record<string, unknown>).sha
439
+ : record.sha;
440
+ const idValue = record.number ?? record.iid ?? record.id;
441
+ if (
442
+ (body !== undefined && body !== null && typeof body !== "string") ||
443
+ (cfg.provider === "github" ? !githubShape : !gitlabShape) ||
444
+ typeof targetValue !== "string" ||
445
+ typeof sourceValue !== "string" ||
446
+ typeof shaValue !== "string" ||
447
+ (typeof idValue !== "string" && typeof idValue !== "number")
448
+ )
449
+ return unknownHostingEvidence("hosting.pull_request");
450
+ records.push(record);
451
+ }
452
+ const matches = records.filter((item): item is Record<string, unknown> => {
453
+ const body = String(
454
+ (item as Record<string, unknown>).body ??
455
+ (item as Record<string, unknown>).description ??
456
+ "",
457
+ );
458
+ const base = (item as Record<string, unknown>).base as Record<string, unknown> | undefined;
459
+ const head = (item as Record<string, unknown>).head as Record<string, unknown> | undefined;
460
+ const itemTarget =
461
+ cfg.provider === "github" ? base?.ref : (item as Record<string, unknown>).target_branch;
462
+ const itemSource =
463
+ cfg.provider === "github" ? head?.ref : (item as Record<string, unknown>).source_branch;
464
+ const itemSha = cfg.provider === "github" ? head?.sha : (item as Record<string, unknown>).sha;
465
+ return (
466
+ body.includes(resolved.marker!) &&
467
+ itemTarget === target &&
468
+ itemSource === branch &&
469
+ itemSha === sourceCommit
470
+ );
471
+ });
472
+ if (matches.length !== 1) return unknownHostingEvidence("hosting.pull_request");
473
+ const item = matches[0];
474
+ const id = item.number ?? item.iid ?? item.id;
475
+ if (typeof id !== "string" && typeof id !== "number")
476
+ return unknownHostingEvidence("hosting.pull_request");
477
+ const evidenceDigest = sha256({
478
+ provider: cfg.provider,
479
+ id,
480
+ marker: resolved.marker,
481
+ target,
482
+ branch,
483
+ sourceCommit,
484
+ });
485
+ const observation = {
486
+ kind: "provider_read",
487
+ actionRef,
488
+ outcome: "succeeded" as const,
489
+ evidenceDigest,
490
+ };
491
+ return success(null, null, {
492
+ outcome: "succeeded",
493
+ evidenceDigest,
494
+ observation,
495
+ data: { provider: cfg.provider, id },
496
+ });
497
+ } catch {
498
+ return unknownHostingEvidence("hosting.pull_request");
499
+ }
500
+ };
501
+
502
+ const appendMarker = (text: string, marker: string): string =>
503
+ `${text}${text ? "\n\n" : ""}${marker}`;
504
+
505
+ /** Fixed, read-only YouTrack lookup for comments and work items. */
506
+ export const readYouTrackAction = async (
507
+ root: string,
508
+ resolved: ResolvedExternalAction,
509
+ actionRef: Ref,
510
+ ): Promise<Result<HostingReadEvidence>> => {
511
+ const operation = resolved.request.operation;
512
+ if (
513
+ operation !== "youtrack.update" &&
514
+ operation !== "youtrack.time" &&
515
+ operation !== "youtrack.meeting"
516
+ )
517
+ return unknownHostingEvidence(operation);
518
+ try {
519
+ const target = resolved.descriptorPayload as { resolved?: Record<string, unknown> };
520
+ const details = target.resolved;
521
+ const issueId = details?.issueId;
522
+ const baseUrl = details?.baseUrl;
523
+ const marker = details?.marker;
524
+ if (
525
+ typeof issueId !== "string" ||
526
+ !ISSUE_RE.test(issueId) ||
527
+ typeof baseUrl !== "string" ||
528
+ typeof marker !== "string"
529
+ )
530
+ return unknownHostingEvidence(operation);
531
+ if (
532
+ !details ||
533
+ !Array.isArray(details.steps) ||
534
+ !details.steps.every((step) => typeof step === "string")
535
+ )
536
+ return unknownHostingEvidence(operation);
537
+ const creds = youTrackToken();
538
+ if ("error" in creds || creds.base !== baseUrl) return unknownHostingEvidence(operation);
539
+ const bounded = "&$top=100&$skip=0";
540
+ const read = async (path: string): Promise<unknown[] | null> => {
541
+ const result = await youTrackRequest(`${baseUrl}${path}${bounded}`, {
542
+ method: "GET",
543
+ token: creds.token,
544
+ });
545
+ if (result.status !== 0) return null;
546
+ try {
547
+ const parsed = JSON.parse(result.stdout);
548
+ return Array.isArray(parsed) && parsed.length < 100 ? parsed : null;
549
+ } catch {
550
+ return null;
551
+ }
552
+ };
553
+ const comments =
554
+ operation === "youtrack.time"
555
+ ? []
556
+ : await read(
557
+ `/api/issues/${encodeURIComponent(issueId)}/comments?fields=id,text,author(id,login),deleted,created`,
558
+ );
559
+ const workItems =
560
+ (operation === "youtrack.update" && details.steps.includes("time")) ||
561
+ operation === "youtrack.time" ||
562
+ operation === "youtrack.meeting"
563
+ ? await read(
564
+ `/api/issues/${encodeURIComponent(issueId)}/timeTracking/workItems?fields=id,text,author(id),creator(id),duration(minutes),date`,
565
+ )
566
+ : [];
567
+ if (comments === null || workItems === null) return unknownHostingEvidence(operation);
568
+ const commentText = typeof details.commentText === "string" ? details.commentText : null;
569
+ const workText = typeof details.workText === "string" ? details.workText : null;
570
+ const minutes = typeof details.minutes === "number" ? details.minutes : null;
571
+ const dateMs = typeof details.dateMs === "number" ? details.dateMs : null;
572
+ const validComment = (value: unknown): value is Record<string, unknown> => {
573
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
574
+ const item = value as Record<string, unknown>;
575
+ return (
576
+ typeof item.id === "string" &&
577
+ typeof item.text === "string" &&
578
+ (item.deleted === undefined || typeof item.deleted === "boolean") &&
579
+ (item.created === undefined || typeof item.created === "number")
580
+ );
581
+ };
582
+ const validWork = (value: unknown): value is Record<string, unknown> => {
583
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
584
+ const item = value as Record<string, unknown>;
585
+ const duration = item.duration;
586
+ return (
587
+ typeof item.id === "string" &&
588
+ typeof item.text === "string" &&
589
+ !!duration &&
590
+ typeof duration === "object" &&
591
+ !Array.isArray(duration) &&
592
+ typeof (duration as Record<string, unknown>).minutes === "number" &&
593
+ typeof item.date === "number"
594
+ );
595
+ };
596
+ if (
597
+ comments.some((value) => !validComment(value)) ||
598
+ workItems.some((value) => !validWork(value))
599
+ )
600
+ return unknownHostingEvidence(operation);
601
+ const matchingComments =
602
+ commentText === null
603
+ ? []
604
+ : comments.filter(
605
+ (value) =>
606
+ !((value as Record<string, unknown>).deleted === true) &&
607
+ (value as Record<string, unknown>).text === commentText,
608
+ );
609
+ const matchingWork =
610
+ workText === null
611
+ ? []
612
+ : workItems.filter((value) => {
613
+ const item = value as Record<string, unknown>;
614
+ return (
615
+ item.text === workText &&
616
+ (item.duration as Record<string, unknown>).minutes === minutes &&
617
+ item.date === dateMs
618
+ );
619
+ });
620
+ const needComment = commentText !== null;
621
+ // `workText` is also retained for update descriptors so the later time
622
+ // step can use the exact approved text. It is only an expected provider
623
+ // object when the approved workflow actually contains the time step.
624
+ const needWork = details.steps.includes("time") && workText !== null;
625
+ if (needComment && matchingComments.length !== 1) return unknownHostingEvidence(operation);
626
+ // An absent work item is deliberately not evidence that the time step was
627
+ // never applied: the POST may have succeeded before its response was lost.
628
+ // The persisted workflow records which step was attempted; only an exact
629
+ // provider match can reconcile it.
630
+ if (needWork && matchingWork.length !== 1) return unknownHostingEvidence(operation);
631
+ const ids = [...matchingComments, ...matchingWork].map(
632
+ (value) => (value as Record<string, unknown>).id,
633
+ );
634
+ const evidenceDigest = sha256({ operation, issueId, marker, ids, minutes, dateMs });
635
+ const observation = {
636
+ kind: "provider_read",
637
+ actionRef,
638
+ outcome: "succeeded" as const,
639
+ evidenceDigest,
640
+ };
641
+ return success(null, null, {
642
+ outcome: "succeeded",
643
+ evidenceDigest,
644
+ observation,
645
+ data: { provider: "youtrack", id: String(ids[0] ?? "") },
646
+ });
647
+ } catch {
648
+ return unknownHostingEvidence(operation);
649
+ }
650
+ };
651
+
652
+ export const readExternalAction = async (
653
+ root: string,
654
+ resolved: ResolvedExternalAction,
655
+ actionRef: Ref,
656
+ ): Promise<Result<HostingReadEvidence>> =>
657
+ resolved.request.operation === "hosting.pull_request"
658
+ ? readHostingAction(root, resolved, actionRef)
659
+ : readYouTrackAction(root, resolved, actionRef);
660
+
661
+ const youTrackBase = (): string | null => {
662
+ try {
663
+ const config = JSON.parse(fs.readFileSync(youTrackConfigPath(), "utf8")) as {
664
+ baseUrl?: unknown;
665
+ };
666
+ const value = typeof config.baseUrl === "string" ? config.baseUrl.trim() : "";
667
+ if (!value || /[\s#?]/.test(value)) return null;
668
+ const url = new URL(value);
669
+ return `${url.protocol}//${url.host}${url.pathname}`.replace(/\/$/, "");
670
+ } catch {
671
+ return null;
672
+ }
673
+ };
674
+
675
+ const youTrackWritePreflight = (): Result<null> => {
676
+ if (process.env.WORKFLOW_YT_WRITE !== "1")
677
+ return failure("capability_unavailable", "YouTrack write capability is unavailable", {
678
+ capability: "youtrack_write",
679
+ outcome: "not_started",
680
+ });
681
+ const credentials = youTrackToken();
682
+ if ("error" in credentials)
683
+ return failure("capability_unavailable", "YouTrack credentials are unavailable", {
684
+ capability: "youtrack",
685
+ outcome: "not_started",
686
+ });
687
+ return success(null, null, null);
688
+ };
689
+
690
+ export const resolveExternalActionRequest = (
691
+ root: string,
692
+ request: ExternalActionRequest,
693
+ preservedDateMs?: number,
694
+ ): Result<ResolvedExternalAction> => {
695
+ try {
696
+ switch (request.operation) {
697
+ case "git.branch_setup": {
698
+ const sdd_dir = safePath(root, request.payload.sdd_dir ?? "docs");
699
+ if (!sdd_dir) return failure("invalid_input", "sdd_dir must stay inside the workspace");
700
+ const policy = resolveBranchPolicyFor(root);
701
+ const target_branch = request.payload.target_branch ?? policy.defaultTargetBranch;
702
+ const head = gitValue(root, ["rev-parse", "HEAD"]);
703
+ if (!head) return failure("storage_error", "current Git commit could not be resolved");
704
+ const normalized = {
705
+ operation: request.operation,
706
+ payload: {
707
+ ...request.payload,
708
+ action: request.payload.action ?? "setup",
709
+ sdd_dir,
710
+ stash: request.payload.stash ?? "no",
711
+ target_branch,
712
+ },
713
+ } as ExternalActionRequest;
714
+ return success(null, null, {
715
+ request: normalized,
716
+ descriptorPayload: { ...normalized.payload, resolved: { head, target_branch } },
717
+ });
718
+ }
719
+ case "git.push": {
720
+ const branch = request.payload.branch ?? gitValue(root, ["branch", "--show-current"]);
721
+ const commit = branch ? gitValue(root, ["rev-parse", branch]) : null;
722
+ const remote = pushRemote(root);
723
+ const identity = remote ? credentialFreeRemote(remote) : null;
724
+ return branch && commit && identity
725
+ ? success(null, null, {
726
+ request: { operation: request.operation, payload: { branch } },
727
+ descriptorPayload: { branch, resolved: { branch, commit, remote: identity } },
728
+ })
729
+ : failure("invalid_input", "git push requires a resolvable current branch");
730
+ }
731
+ case "git.commit": {
732
+ const head = gitValue(root, ["rev-parse", "HEAD"]);
733
+ const staged = gitRaw(root, [
734
+ "diff",
735
+ "--cached",
736
+ "--raw",
737
+ "-z",
738
+ "--no-ext-diff",
739
+ "--no-textconv",
740
+ "HEAD",
741
+ ]);
742
+ const paths = stagedPaths(root);
743
+ if (!head || staged === null || paths === null)
744
+ return failure("storage_error", "Git state could not be resolved");
745
+ let policy: { preset: string; pattern?: string };
746
+ try {
747
+ policy = resolveCommitPolicyFor(root);
748
+ } catch (error) {
749
+ return failure(
750
+ "invalid_input",
751
+ `Commit policy config is malformed: ${error instanceof Error ? error.message : error}`,
752
+ );
753
+ }
754
+ let flavor = policy.preset;
755
+ if (flavor === "auto") {
756
+ const log = gitRaw(root, ["log", "--format=%s", "-30", "HEAD"]);
757
+ const detected = detectCommitFlavor((log ?? "").split("\n").filter(Boolean));
758
+ flavor = detected.flavor ?? "conventional";
759
+ }
760
+ if (!matchCommitFlavor(request.payload.message, flavor as CommitFlavor, policy.pattern))
761
+ return failure(
762
+ "invalid_input",
763
+ `Commit message does not match the ${flavor} flavor (commitPolicy.preset)`,
764
+ );
765
+ return success(null, null, {
766
+ request,
767
+ descriptorPayload: {
768
+ ...request.payload,
769
+ resolved: { head, staged: sha256(staged), paths },
770
+ },
771
+ });
772
+ }
773
+ case "hosting.pull_request": {
774
+ const target_branch =
775
+ request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
776
+ const source_branch = gitValue(root, ["branch", "--show-current"]);
777
+ const source_commit = gitValue(root, ["rev-parse", "HEAD"]);
778
+ const remote = pushRemote(root);
779
+ const identity = remote ? credentialFreeRemote(remote) : null;
780
+ if (
781
+ !source_branch ||
782
+ !source_commit ||
783
+ !identity ||
784
+ (remoteProvider(identity) !== null &&
785
+ remoteProvider(identity) !== String(vcsConfig("load", root).provider).toLowerCase())
786
+ )
787
+ return failure(
788
+ "capability_unavailable",
789
+ "hosting target is not bound to the configured remote provider",
790
+ { capability: "hosting.pull_request" },
791
+ );
792
+ const vcs = vcsConfig("load", root);
793
+ const policy = resolveBranchPolicyFor(root);
794
+ if (!vcs.ok || (policy.integration !== "merge" && !vcs.tokenReady))
795
+ return failure("capability_unavailable", "hosting credentials are unavailable", {
796
+ capability: "hosting.pull_request",
797
+ outcome: "not_started",
798
+ });
799
+ if (policy.integration !== "merge" && !hostingCliAvailable(String(vcs.provider)))
800
+ return failure("capability_unavailable", "hosting CLI is unavailable", {
801
+ capability: "hosting.pull_request",
802
+ outcome: "not_started",
803
+ });
804
+ const normalized = {
805
+ operation: request.operation,
806
+ payload: { ...request.payload, target_branch, babysit: request.payload.babysit ?? true },
807
+ } as ExternalActionRequest;
808
+ const marker = `<!-- workit-action:${sha256({ operation: request.operation, payload: normalized.payload, source_commit, remote: identity })} -->`;
809
+ return success(null, null, {
810
+ request: normalized,
811
+ marker,
812
+ descriptorPayload: {
813
+ ...normalized.payload,
814
+ resolved: { source_branch, source_commit, remote: identity, target_branch, marker },
815
+ },
816
+ });
817
+ }
818
+ case "changelog.apply": {
819
+ const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
820
+ if (preview.error) return failure("invalid_input", preview.error);
821
+ const target = String(preview.path);
822
+ const relative =
823
+ path.relative(path.resolve(root), target).replaceAll(path.sep, "/") || "CHANGELOG.md";
824
+ const normalized = {
825
+ operation: request.operation,
826
+ payload: {
827
+ ...request.payload,
828
+ path: relative,
829
+ entries: preview.entries,
830
+ normalize_only: preview.normalize_only,
831
+ },
832
+ } as ExternalActionRequest;
833
+ return success(null, null, {
834
+ request: normalized,
835
+ descriptorPayload: {
836
+ ...normalized.payload,
837
+ resolved: {
838
+ target,
839
+ beforeDigest: preview.beforeDigest,
840
+ beforeExists: preview.beforeExists,
841
+ afterDigest: preview.afterDigest,
842
+ },
843
+ },
844
+ });
845
+ }
846
+ case "context.read":
847
+ return success(null, null, { request, descriptorPayload: request.payload });
848
+ case "youtrack.time":
849
+ if (request.payload.dateMs === undefined)
850
+ return failure("invalid_input", "youtrack.time requires an explicit dateMs");
851
+ {
852
+ const available = youTrackWritePreflight();
853
+ if (!available.ok) return available as Result<ResolvedExternalAction>;
854
+ const baseUrl = youTrackBase();
855
+ const date = youTrackWorkDateMs(String(request.payload.dateMs));
856
+ if (!baseUrl)
857
+ return failure("capability_unavailable", "YouTrack base URL is unavailable", {
858
+ capability: "youtrack",
859
+ });
860
+ if ("error" in date)
861
+ return failure("capability_unavailable", "YouTrack work date is unavailable", {
862
+ capability: "youtrack",
863
+ });
864
+ const marker = `<!-- workit-action:${sha256({ operation: request.operation, issueId: request.payload.issueId, baseUrl, payload: request.payload, dateMs: date.data.dateMs })} -->`;
865
+ const workText = appendMarker(request.payload.text ?? "", marker);
866
+ return success(null, null, {
867
+ request,
868
+ marker,
869
+ descriptorPayload: {
870
+ ...request.payload,
871
+ resolved: {
872
+ baseUrl,
873
+ issueId: request.payload.issueId,
874
+ marker,
875
+ dateMs: date.data.dateMs,
876
+ workText,
877
+ minutes: request.payload.minutes,
878
+ steps: ["time"],
879
+ },
880
+ },
881
+ });
882
+ }
883
+ case "youtrack.update":
884
+ case "youtrack.meeting": {
885
+ const available = youTrackWritePreflight();
886
+ if (!available.ok) return available as Result<ResolvedExternalAction>;
887
+ const baseUrl = youTrackBase();
888
+ const date = youTrackWorkDateMs(
889
+ preservedDateMs === undefined ? "auto" : String(preservedDateMs),
890
+ );
891
+ if (!baseUrl)
892
+ return failure("capability_unavailable", "YouTrack base URL is unavailable", {
893
+ capability: "youtrack",
894
+ });
895
+ if ("error" in date)
896
+ return failure("capability_unavailable", "YouTrack work date is unavailable", {
897
+ capability: "youtrack",
898
+ });
899
+ const marker = `<!-- workit-action:${sha256({ operation: request.operation, issueId: request.payload.issueId, baseUrl, payload: request.payload, dateMs: date.data.dateMs })} -->`;
900
+ const text =
901
+ request.operation === "youtrack.update" ? request.payload.markdown : request.payload.text;
902
+ const annotated = appendMarker(text, marker);
903
+ const steps =
904
+ request.operation === "youtrack.update"
905
+ ? request.payload.minutes
906
+ ? ["comment", "time"]
907
+ : ["comment"]
908
+ : ["time"];
909
+ return success(null, null, {
910
+ request,
911
+ marker,
912
+ descriptorPayload: {
913
+ ...request.payload,
914
+ resolved: {
915
+ baseUrl,
916
+ issueId: request.payload.issueId,
917
+ marker,
918
+ dateMs: date.data.dateMs,
919
+ ...(request.operation === "youtrack.update" ? { commentText: annotated } : {}),
920
+ workText: annotated,
921
+ ...(request.payload.minutes !== undefined
922
+ ? { minutes: request.payload.minutes }
923
+ : {}),
924
+ steps,
925
+ },
926
+ },
927
+ });
928
+ }
929
+ default:
930
+ return success(null, null, {
931
+ request: request as never,
932
+ descriptorPayload: (request as unknown as { payload: unknown }).payload,
933
+ });
934
+ }
935
+ } catch {
936
+ return failure("storage_error", "external action target could not be resolved");
937
+ }
938
+ };
939
+
940
+ export const executeConcreteExternalAction = async (
941
+ request: ExternalActionRequest,
942
+ root: string,
943
+ marker?: string,
944
+ dateMs?: number,
945
+ step?: string,
946
+ workText?: string,
947
+ caller?: { host: Caller["host"]; actor: string },
948
+ approvedBeforeDigest?: string,
949
+ approvedBeforeExists?: boolean,
950
+ ): Promise<Result<unknown>> => {
951
+ if (localAction(request.operation)) {
952
+ if (!caller)
953
+ return failure(
954
+ "capability_unavailable",
955
+ "local external action writer authority is unavailable",
956
+ { outcome: "not_started" },
957
+ );
958
+ const paths =
959
+ request.operation === "git.commit"
960
+ ? stagedPaths(root)
961
+ : request.operation === "changelog.apply"
962
+ ? [request.payload.path ?? "CHANGELOG.md"]
963
+ : ["."];
964
+ if (paths === null)
965
+ return failure(
966
+ "capability_unavailable",
967
+ "local external action staged paths are unavailable",
968
+ { outcome: "not_started" },
969
+ );
970
+ const writer = assertLocalExternalActionWriter(root, caller, paths);
971
+ if (!writer.ok)
972
+ return failure(
973
+ "capability_unavailable",
974
+ "local external action writer authority is unavailable",
975
+ { outcome: "not_started" },
976
+ );
977
+ }
978
+ switch (request.operation) {
979
+ case "git.branch_setup": {
980
+ const result = branchSetup({
981
+ ...request.payload,
982
+ sdd_dir: request.payload.sdd_dir ?? "docs",
983
+ workspace_root: root,
984
+ });
985
+ return result.error ? unknown(request.operation) : success(null, null, result);
986
+ }
987
+ case "git.commit": {
988
+ const result = run(root, ["commit", "-m", request.payload.message]);
989
+ return result.exitCode === 0
990
+ ? success(null, null, { stdout: result.stdout.trim() })
991
+ : unknown(request.operation);
992
+ }
993
+ case "git.push": {
994
+ const result = run(root, ["push", "origin", request.payload.branch!]);
995
+ return result.exitCode === 0
996
+ ? success(null, null, { stdout: result.stdout.trim() })
997
+ : unknown(request.operation);
998
+ }
999
+ case "hosting.pull_request": {
1000
+ const body = `${request.payload.body ?? ""}${marker ? `${request.payload.body ? "\n\n" : ""}${marker}` : ""}`;
1001
+ const result = prCreate(
1002
+ {
1003
+ ...process.env,
1004
+ WF_PR_TITLE: request.payload.title,
1005
+ WF_PR_BODY: body,
1006
+ WF_PR_CONFIRMED: "true",
1007
+ WF_PR_DRAFT: request.payload.draft ? "true" : "false",
1008
+ WF_PR_TARGET: request.payload.target_branch ?? "",
1009
+ },
1010
+ root,
1011
+ );
1012
+ return result.error || result.ok === false
1013
+ ? unknown(request.operation)
1014
+ : success(null, null, {
1015
+ ...result,
1016
+ babysit: request.payload.babysit ?? true,
1017
+ babysitSkill: "workit-babysit",
1018
+ });
1019
+ }
1020
+ case "changelog.apply": {
1021
+ const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1022
+ if (
1023
+ preview.error ||
1024
+ (approvedBeforeDigest !== undefined && preview.beforeDigest !== approvedBeforeDigest) ||
1025
+ (approvedBeforeExists !== undefined && preview.beforeExists !== approvedBeforeExists)
1026
+ )
1027
+ return failure(
1028
+ "capability_unavailable",
1029
+ "approved changelog target changed before execution",
1030
+ { outcome: "not_started" },
1031
+ );
1032
+ const result = changelogApply({ ...request.payload, workspace_root: root });
1033
+ return result.error
1034
+ ? failure("storage_error", String(result.error))
1035
+ : success(null, null, result);
1036
+ }
1037
+ case "youtrack.update": {
1038
+ if (step === "comment") {
1039
+ const result = await defaultOperations.postComment(
1040
+ request.payload.issueId,
1041
+ appendMarker(request.payload.markdown, marker ?? ""),
1042
+ root,
1043
+ );
1044
+ return success(null, null, result);
1045
+ }
1046
+ if (step === "time") {
1047
+ const result = await logTimeUpdate(
1048
+ {
1049
+ confirmed: true,
1050
+ issueId: request.payload.issueId,
1051
+ minutes: request.payload.minutes,
1052
+ text: workText ?? appendMarker(request.payload.markdown, marker ?? ""),
1053
+ dateMs,
1054
+ workspace_root: root,
1055
+ },
1056
+ defaultOperations,
1057
+ );
1058
+ return result.ok ? success(null, null, result.data) : unknown(request.operation);
1059
+ }
1060
+ const result = await postUpdate(
1061
+ {
1062
+ confirmed: true,
1063
+ issueId: request.payload.issueId,
1064
+ markdown: appendMarker(request.payload.markdown, marker ?? ""),
1065
+ minutes: request.payload.minutes,
1066
+ dateMs,
1067
+ workspace_root: root,
1068
+ },
1069
+ defaultOperations,
1070
+ );
1071
+ return result.ok ? success(null, null, result.data) : unknown(request.operation);
1072
+ }
1073
+ case "youtrack.time": {
1074
+ const result = await logTimeUpdate(
1075
+ {
1076
+ confirmed: true,
1077
+ issueId: request.payload.issueId,
1078
+ minutes: request.payload.minutes,
1079
+ text: appendMarker(request.payload.text ?? "", marker ?? ""),
1080
+ dateMs: dateMs ?? request.payload.dateMs,
1081
+ workspace_root: root,
1082
+ },
1083
+ defaultOperations,
1084
+ );
1085
+ return result.ok ? success(null, null, result.data) : unknown(request.operation);
1086
+ }
1087
+ case "youtrack.meeting": {
1088
+ const result = await logTimeUpdate(
1089
+ {
1090
+ confirmed: true,
1091
+ issueId: request.payload.issueId,
1092
+ minutes: request.payload.minutes,
1093
+ text: appendMarker(request.payload.text, marker ?? ""),
1094
+ dateMs,
1095
+ workspace_root: root,
1096
+ },
1097
+ defaultOperations,
1098
+ );
1099
+ return result.ok ? success(null, null, result.data) : unknown(request.operation);
1100
+ }
1101
+ case "context.read":
1102
+ return readExternalContext(root, request.payload);
1103
+ }
1104
+ };
1105
+
1106
+ /** Re-resolve the concrete target after approval so mutable repository/config state cannot redirect it. */
1107
+ export const executeResolvedExternalAction = async (
1108
+ resolved: ResolvedExternalAction,
1109
+ root: string,
1110
+ step?: string,
1111
+ caller?: { host: Caller["host"]; actor: string },
1112
+ ): Promise<Result<unknown>> => {
1113
+ const approvedDetails =
1114
+ resolved.descriptorPayload && typeof resolved.descriptorPayload === "object"
1115
+ ? (
1116
+ resolved.descriptorPayload as {
1117
+ resolved?: { dateMs?: unknown; beforeDigest?: unknown; beforeExists?: unknown };
1118
+ }
1119
+ ).resolved
1120
+ : undefined;
1121
+ const preservedDateMs =
1122
+ (resolved.request.operation === "youtrack.update" ||
1123
+ resolved.request.operation === "youtrack.meeting") &&
1124
+ typeof approvedDetails?.dateMs === "number"
1125
+ ? approvedDetails.dateMs
1126
+ : undefined;
1127
+ const fresh = resolveExternalActionRequest(root, resolved.request, preservedDateMs);
1128
+ if (!fresh.ok)
1129
+ return failure(
1130
+ "capability_unavailable",
1131
+ "approved external action target changed before execution",
1132
+ { outcome: "not_started" },
1133
+ );
1134
+ if (
1135
+ externalActionDescriptor(resolved.request.operation, fresh.data.descriptorPayload) !==
1136
+ externalActionDescriptor(resolved.request.operation, resolved.descriptorPayload)
1137
+ )
1138
+ return failure(
1139
+ "capability_unavailable",
1140
+ "approved external action target changed before execution",
1141
+ { outcome: "not_started" },
1142
+ );
1143
+ const resolvedDetails = fresh.data.descriptorPayload as {
1144
+ resolved?: { dateMs?: unknown; beforeDigest?: unknown; beforeExists?: unknown };
1145
+ };
1146
+ const resolvedDate =
1147
+ typeof resolvedDetails.resolved?.dateMs === "number"
1148
+ ? resolvedDetails.resolved.dateMs
1149
+ : undefined;
1150
+ const workText = (fresh.data.descriptorPayload as { resolved?: { workText?: unknown } }).resolved
1151
+ ?.workText;
1152
+ const beforeDigest =
1153
+ typeof resolvedDetails.resolved?.beforeDigest === "string"
1154
+ ? resolvedDetails.resolved.beforeDigest
1155
+ : undefined;
1156
+ const beforeExists =
1157
+ typeof resolvedDetails.resolved?.beforeExists === "boolean"
1158
+ ? resolvedDetails.resolved.beforeExists
1159
+ : undefined;
1160
+ return executeConcreteExternalAction(
1161
+ fresh.data.request,
1162
+ root,
1163
+ fresh.data.marker,
1164
+ resolvedDate,
1165
+ step,
1166
+ typeof workText === "string" ? workText : undefined,
1167
+ caller,
1168
+ beforeDigest,
1169
+ beforeExists,
1170
+ );
1171
+ };