@brainervirus/workit-core 0.11.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 -3308
  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 -428
  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,237 @@
1
+ import fs from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { vcsConfig } from "./vcs-config";
4
+ import { hostingCliAvailable, parseGhIssue } from "./pr-create";
5
+
6
+ // Read-only issue fetches for context.read (GitHub + GitLab), mirroring the
7
+ // YouTrack triple shape. CLI-first: the native gh/glab CLI serves the read
8
+ // when installed (per-directory identity comes free); token files are the
9
+ // fallback and fail closed without one. No writes, no new secret files.
10
+
11
+ export type TrackerIssueBody = {
12
+ id: string;
13
+ title: string;
14
+ body: string | null;
15
+ state: string | null;
16
+ };
17
+
18
+ export type TrackerIssueFailure = {
19
+ error: string;
20
+ kind: "creds" | "request" | "input";
21
+ };
22
+
23
+ export type TrackerRequest = (
24
+ url: string,
25
+ init: { method: string; headers: Record<string, string> },
26
+ ) => Promise<{ status: number; stdout: string; stderr: string }>;
27
+
28
+ export const trackerRequest: TrackerRequest = async (url, init) => {
29
+ try {
30
+ const res = await fetch(url, { method: init.method, headers: init.headers });
31
+ const text = await res.text();
32
+ if (!res.ok) return { status: res.status, stdout: "", stderr: text.slice(0, 200) };
33
+ return { status: 0, stdout: text, stderr: "" };
34
+ } catch (err) {
35
+ return { status: 1, stdout: "", stderr: err instanceof Error ? err.message : "network error" };
36
+ }
37
+ };
38
+
39
+ /** Full repo path from an origin URL (scp-style, https, or bare); subgroups kept. */
40
+ export const repoPathFromRemote = (remote: string): string | null => {
41
+ let rest = (remote || "").trim().replace(/\/+$/, "");
42
+ if (!rest) return null;
43
+ if (rest.endsWith(".git")) rest = rest.slice(0, -4);
44
+ const scp = /^[^@/]+@[^:]+:(.+)$/.exec(rest);
45
+ if (scp) return scp[1].replace(/^\//, "") || null;
46
+ try {
47
+ const url = new URL(rest.includes("://") ? rest : `https://${rest}`);
48
+ return url.pathname.replace(/^\//, "") || null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ };
53
+
54
+ export type TrackerCli = (
55
+ args: string[],
56
+ root: string,
57
+ ) => Promise<{ status: number; stdout: string; stderr: string }> | null;
58
+
59
+ export type TrackerDeps = {
60
+ creds?: (root: string) => TrackerCreds;
61
+ request?: TrackerRequest;
62
+ remote?: (root: string) => string | null;
63
+ cli?: TrackerCli;
64
+ };
65
+
66
+ /** Native CLI runner (gh/glab) when installed; null when absent. */
67
+ const defaultCli = (bin: string, available: boolean): TrackerCli | null => {
68
+ if (!available) return null;
69
+ return (args, root) => {
70
+ const out = spawnSync(bin, args, { cwd: root, encoding: "utf8" });
71
+ return Promise.resolve({
72
+ status: out.status ?? 1,
73
+ stdout: out.stdout ?? "",
74
+ stderr: (out.error as Error | undefined)?.message ?? out.stderr ?? "",
75
+ });
76
+ };
77
+ };
78
+
79
+ const tryCliTriple = async (
80
+ cli: TrackerCli | null | undefined,
81
+ args: string[],
82
+ root: string,
83
+ id: string,
84
+ idKeys: string[],
85
+ bodyKeys: string[],
86
+ ): Promise<TrackerIssueBody | null> => {
87
+ if (!cli) return null;
88
+ const run = cli(args, root);
89
+ if (!run) return null;
90
+ const out = await run;
91
+ if (out.status !== 0) return null;
92
+ try {
93
+ return parseTriple(JSON.parse(out.stdout) as Record<string, unknown>, id, idKeys, bodyKeys);
94
+ } catch {
95
+ return null;
96
+ }
97
+ };
98
+
99
+ const originRemote = (root: string): string | null => {
100
+ const out = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" });
101
+ return out.status === 0 ? (out.stdout ?? "").trim() || null : null;
102
+ };
103
+
104
+ type TrackerCreds = { token: string; api: string } | { error: string };
105
+
106
+ const trackerCreds = (root: string, provider: "github" | "gitlab"): TrackerCreds => {
107
+ const cfg = vcsConfig("load", root) as Record<string, unknown>;
108
+ if (!cfg || cfg.provider !== provider || typeof cfg.tokenPath !== "string" || !cfg.tokenPath)
109
+ return { error: `unconfigured ${provider} provider` };
110
+ let token = "";
111
+ try {
112
+ token = fs.readFileSync(cfg.tokenPath, "utf8").trim();
113
+ } catch {
114
+ return { error: `missing ${provider} token` };
115
+ }
116
+ if (!token) return { error: `empty ${provider} token file` };
117
+ if (provider === "gitlab") {
118
+ const gitlab = cfg.gitlab as Record<string, unknown> | undefined;
119
+ const apiUrl = String(gitlab?.apiUrl ?? "https://gitlab.com/api/v4").replace(/\/+$/, "");
120
+ return { token, api: apiUrl };
121
+ }
122
+ const github = cfg.github as Record<string, unknown> | undefined;
123
+ const host = String(github?.host ?? "github.com").toLowerCase();
124
+ return {
125
+ token,
126
+ api: host === "github.com" ? "https://api.github.com" : `https://${host}/api/v3`,
127
+ };
128
+ };
129
+
130
+ const parseTriple = (
131
+ parsed: Record<string, unknown>,
132
+ id: string,
133
+ idKeys: string[],
134
+ bodyKeys: string[],
135
+ ): TrackerIssueBody | null => {
136
+ const title = parsed.title;
137
+ if (typeof title !== "string") return null;
138
+ const pick = (keys: string[]): string | null => {
139
+ for (const key of keys) {
140
+ const value = parsed[key];
141
+ if (typeof value === "string") return value;
142
+ if (typeof value === "number") return String(value);
143
+ }
144
+ return null;
145
+ };
146
+ const foundId = pick(idKeys) ?? id;
147
+ const state = typeof parsed.state === "string" ? parsed.state : null;
148
+ return { id: foundId, title, body: pick(bodyKeys), state };
149
+ };
150
+
151
+ /** Fetch a GitHub issue triple for context.read: gh CLI first, token fallback (fail-closed). */
152
+ export const fetchGitHubIssueBody = async (
153
+ ref: string,
154
+ root: string,
155
+ deps: TrackerDeps = {},
156
+ ): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
157
+ const id = parseGhIssue(ref);
158
+ if (!/^\d+$/.test(id)) return { error: `invalid GitHub issue ref "${ref}"`, kind: "input" };
159
+ const repo = repoPathFromRemote((deps.remote ?? originRemote)(root) ?? "");
160
+ if (!repo) return { error: "no origin remote to resolve the repository", kind: "input" };
161
+ const viaCli = await tryCliTriple(
162
+ deps.cli ?? defaultCli("gh", hostingCliAvailable("github")),
163
+ ["issue", "view", id, "--repo", repo, "--json", "number,title,body,state"],
164
+ root,
165
+ id,
166
+ ["number"],
167
+ ["body"],
168
+ );
169
+ if (viaCli) return { data: viaCli };
170
+ const credentials = (deps.creds ?? ((r) => trackerCreds(r, "github")))(root);
171
+ if ("error" in credentials) return { ...credentials, kind: "creds" as const };
172
+ const out = await (deps.request ?? trackerRequest)(
173
+ `${credentials.api}/repos/${repo}/issues/${id}`,
174
+ {
175
+ method: "GET",
176
+ headers: {
177
+ Authorization: `Bearer ${credentials.token}`,
178
+ Accept: "application/vnd.github+json",
179
+ },
180
+ },
181
+ );
182
+ if (out.status !== 0)
183
+ return { error: out.stderr || "GitHub issue fetch failed", kind: "request" };
184
+ try {
185
+ const triple = parseTriple(
186
+ JSON.parse(out.stdout) as Record<string, unknown>,
187
+ id,
188
+ ["number"],
189
+ ["body"],
190
+ );
191
+ if (!triple) return { error: "unexpected GitHub issue shape", kind: "request" };
192
+ return { data: triple };
193
+ } catch {
194
+ return { error: "invalid GitHub issue response", kind: "request" };
195
+ }
196
+ };
197
+
198
+ /** Fetch a GitLab issue triple for context.read: glab CLI first, token fallback (fail-closed). */
199
+ export const fetchGitLabIssueBody = async (
200
+ ref: string,
201
+ root: string,
202
+ deps: TrackerDeps = {},
203
+ ): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
204
+ const id = parseGhIssue(ref);
205
+ if (!/^\d+$/.test(id)) return { error: `invalid GitLab issue ref "${ref}"`, kind: "input" };
206
+ const repo = repoPathFromRemote((deps.remote ?? originRemote)(root) ?? "");
207
+ if (!repo) return { error: "no origin remote to resolve the project", kind: "input" };
208
+ const viaCli = await tryCliTriple(
209
+ deps.cli ?? defaultCli("glab", hostingCliAvailable("gitlab")),
210
+ ["issue", "view", id, "-R", repo, "-F", "json"],
211
+ root,
212
+ id,
213
+ ["iid"],
214
+ ["description"],
215
+ );
216
+ if (viaCli) return { data: viaCli };
217
+ const credentials = (deps.creds ?? ((r) => trackerCreds(r, "gitlab")))(root);
218
+ if ("error" in credentials) return { ...credentials, kind: "creds" as const };
219
+ const out = await (deps.request ?? trackerRequest)(
220
+ `${credentials.api}/projects/${encodeURIComponent(repo)}/issues/${id}`,
221
+ { method: "GET", headers: { "PRIVATE-TOKEN": credentials.token } },
222
+ );
223
+ if (out.status !== 0)
224
+ return { error: out.stderr || "GitLab issue fetch failed", kind: "request" };
225
+ try {
226
+ const triple = parseTriple(
227
+ JSON.parse(out.stdout) as Record<string, unknown>,
228
+ id,
229
+ ["iid"],
230
+ ["description"],
231
+ );
232
+ if (!triple) return { error: "unexpected GitLab issue shape", kind: "request" };
233
+ return { data: triple };
234
+ } catch {
235
+ return { error: "invalid GitLab issue response", kind: "request" };
236
+ }
237
+ };
@@ -11,7 +11,7 @@ import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { isWorkitPlugin } from "./registration";
13
13
 
14
- export type UninstallHost = "opencode" | "cursor";
14
+ export type UninstallHost = "opencode" | "cursor" | "codex" | "pi";
15
15
 
16
16
  export type UninstallAction =
17
17
  | { kind: "edit-json-remove"; path: string; detail: string }
@@ -56,6 +56,8 @@ type ResolvedUninstall = {
56
56
  cursorSettings: string;
57
57
  cursorMcp: string;
58
58
  cursorPluginDir: string;
59
+ codexPluginDir: string;
60
+ piConfig: string;
59
61
  };
60
62
 
61
63
  const resolveUninstallPaths = (options: UninstallPaths = {}): ResolvedUninstall => {
@@ -70,6 +72,8 @@ const resolveUninstallPaths = (options: UninstallPaths = {}): ResolvedUninstall
70
72
  cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
71
73
  cursorPluginDir:
72
74
  options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
75
+ codexPluginDir: path.join(home, ".codex", "plugins", "workit"),
76
+ piConfig: path.join(home, ".pi", "config.json"),
73
77
  };
74
78
  };
75
79
 
@@ -192,10 +196,38 @@ type JsonCleaner = (record: Record<string, unknown>) => {
192
196
  changed: boolean;
193
197
  };
194
198
 
199
+ function cleanPiConfig(config: Record<string, unknown>): {
200
+ next: Record<string, unknown>;
201
+ changed: boolean;
202
+ } {
203
+ const next = { ...config };
204
+ let changed = false;
205
+ for (const key of ["extensions", "skills"] as const) {
206
+ const value = next[key];
207
+ if (!Array.isArray(value)) continue;
208
+ const kept = value.filter((entry) => !String(entry).includes("workit"));
209
+ if (kept.length !== value.length) {
210
+ next[key] = kept;
211
+ changed = true;
212
+ }
213
+ }
214
+ const pi = isRecord(next.pi) ? { ...(next.pi as Record<string, unknown>) } : null;
215
+ if (pi && Array.isArray(pi.extensions)) {
216
+ const kept = pi.extensions.filter((entry) => !String(entry).includes("workit"));
217
+ if (kept.length !== pi.extensions.length) {
218
+ pi.extensions = kept;
219
+ next.pi = pi;
220
+ changed = true;
221
+ }
222
+ }
223
+ return { next, changed };
224
+ }
225
+
195
226
  const jsonCleanerFor = (target: string, res: ResolvedUninstall): JsonCleaner | null => {
196
227
  if (target === res.opencodeConfig) return cleanOpenCodeConfig;
197
228
  if (target === res.cursorSettings) return (r) => cleanCursorSettings(r, res.cursorPluginDir);
198
229
  if (target === res.cursorMcp) return cleanCursorMcp;
230
+ if (target === res.piConfig) return cleanPiConfig;
199
231
  return null;
200
232
  };
201
233
 
@@ -262,7 +294,34 @@ export function planUninstall(paths: UninstallPaths = {}): UninstallPlan {
262
294
  actions,
263
295
  };
264
296
 
265
- return { hosts: [opencode, cursor] };
297
+ const codexInstalled = existsSync(res.codexPluginDir);
298
+ const codex: UninstallHostPlan = {
299
+ host: "codex",
300
+ installed: codexInstalled,
301
+ actions: codexInstalled
302
+ ? [{ kind: "remove-dir", path: res.codexPluginDir, detail: "remove Codex workit plugin dir" }]
303
+ : [],
304
+ };
305
+
306
+ const piExisting = readJsonRecord(res.piConfig);
307
+ const piDirty =
308
+ piExisting.kind === "malformed" ||
309
+ (piExisting.kind === "record" && cleanPiConfig(piExisting.value).changed);
310
+ const pi: UninstallHostPlan = {
311
+ host: "pi",
312
+ installed: piDirty,
313
+ actions: piDirty
314
+ ? [
315
+ {
316
+ kind: "edit-json-remove",
317
+ path: res.piConfig,
318
+ detail: "remove workit pi extension/skills entries",
319
+ },
320
+ ]
321
+ : [],
322
+ };
323
+
324
+ return { hosts: [opencode, cursor, codex, pi] };
266
325
  }
267
326
 
268
327
  // CA-14 traversal guard: rm -rf is permitted ONLY on the exact resolved
@@ -344,7 +403,12 @@ export function applyUninstall(plan: UninstallPlan, paths: UninstallPaths = {}):
344
403
  if (action.kind === "remove-dir") {
345
404
  // Resolve before comparing so ".."/symlink tricks can never widen the rm.
346
405
  const resolved = path.resolve(action.path);
347
- if (!canonicalRemoveDirAllowed(resolved, res)) {
406
+ const allowed =
407
+ canonicalRemoveDirAllowed(resolved, res) ||
408
+ (resolved === path.resolve(res.codexPluginDir) &&
409
+ path.basename(resolved) === "workit" &&
410
+ path.basename(path.dirname(resolved)) === "plugins");
411
+ if (!allowed) {
348
412
  status = "failed";
349
413
  detail = `refusing to remove non-canonical plugin directory: ${resolved}`;
350
414
  } else if (!existsSync(resolved)) {
@@ -15,7 +15,7 @@ export const vcsConfigPath = (): string =>
15
15
  const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
16
16
 
17
17
  const vcsCwd = (cwd?: string): string =>
18
- process.env.WORKFLOW_WORKSPACE_ROOT ?? cwd ?? process.cwd();
18
+ cwd ?? process.env.WORKFLOW_WORKSPACE_ROOT ?? process.cwd();
19
19
 
20
20
  // RL-03b: the origin remote is the ground truth for PR creation. A stale global
21
21
  // provider (e.g. gitlab from another repo) must not drive glab on a
@@ -81,9 +81,16 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
81
81
  // contaminate host-agnostic reads.
82
82
  const root = vcsCwd(cwd);
83
83
  const determinateRoot = cwd !== undefined || process.env.WORKFLOW_WORKSPACE_ROOT !== undefined;
84
- const provider = String(
85
- wsVcs.provider ?? (determinateRoot ? remoteProvider(root) : null) ?? cfg.provider ?? "gitlab",
86
- ).toLowerCase();
84
+ // No silent default: a provider resolves from the workspace, the origin
85
+ // remote, or the global config otherwise it is null, explicitly. Branch
86
+ // policy never needed a provider (preset defaults apply); credential and
87
+ // PR flows fail closed on null instead of assuming a host.
88
+ const rawProvider =
89
+ wsVcs.provider ?? (determinateRoot ? remoteProvider(root) : null) ?? cfg.provider ?? null;
90
+ const provider =
91
+ rawProvider === null || rawProvider === undefined
92
+ ? null
93
+ : String(rawProvider).toLowerCase() || null;
87
94
  // CA-05: workspace/global explicit target wins; when unset the resolved
88
95
  // branch-policy preset supplies the default (gitflow->develop,
89
96
  // github-flow->main, trunk-based->master, custom->develop). Shared by load
@@ -140,9 +147,26 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
140
147
  if (cfgStatus === "missing") {
141
148
  return { ok: false, error: `vcs.json is missing: ${cfgPath}`, configPath: cfgPath };
142
149
  }
150
+ // Credentials cannot resolve without a provider: fail closed instead of
151
+ // assuming a host. Branch policy (resolve mode above) never needed one.
152
+ if (provider === null) {
153
+ return {
154
+ ok: false,
155
+ error: `no vcs provider configured (set vcs.provider in ${cfgPath}, match a workspace entry, or run inside a checkout with a recognized origin remote)`,
156
+ configPath: cfgPath,
157
+ };
158
+ }
143
159
 
144
160
  const prov = (cfg[provider] ?? {}) as Record<string, any>;
145
- const tokenFile = String(prov.tokenFile ?? path.join(configDir(), `${provider}.token`));
161
+ // Workspace-tight tokens: an explicit workspace vcs.tokenFile wins (per-area
162
+ // accounts without collisions), else the global provider tokenFile, else the
163
+ // default <provider>.token path. Same order as the per-workspace
164
+ // commitPolicy/branchPolicy overrides.
165
+ const wsTokenFile =
166
+ typeof wsVcs.tokenFile === "string" && wsVcs.tokenFile.trim() !== "" ? wsVcs.tokenFile : null;
167
+ const tokenFile = String(
168
+ wsTokenFile ?? prov.tokenFile ?? path.join(configDir(), `${provider}.token`),
169
+ );
146
170
  const tokenPath = path.resolve(tokenFile);
147
171
  let tokenOk = false;
148
172
  if (fs.existsSync(tokenPath)) {
@@ -281,24 +305,31 @@ export function vcsTokenCreateUrls(): Record<string, any> {
281
305
  : ["repo"];
282
306
  const githubClassicUrl = `https://github.com/settings/tokens/new?${new URLSearchParams({ description: name, scopes: classicScopes.join(",") })}`;
283
307
 
284
- const provider = String(cfg.provider ?? "gitlab").toLowerCase();
308
+ const rawProvider = cfg.provider;
309
+ // Display-only URL helper: both providers' URLs are always returned, but
310
+ // nothing is preselected without an explicit provider — no silent default.
311
+ const provider =
312
+ typeof rawProvider === "string" && rawProvider.trim() ? rawProvider.toLowerCase() : null;
285
313
  const active =
286
- {
287
- gitlab: {
288
- tokenFile: gitlab.tokenFile ?? path.join(configDir(), "gitlab.token"),
289
- createUrl: gitlabUrl,
290
- scopes: gitlabScopes,
291
- name,
292
- },
293
- github: {
294
- tokenFile:
295
- (cfg.github as Record<string, any>)?.tokenFile ?? path.join(configDir(), "github.token"),
296
- createUrl: githubFineUrl,
297
- createUrlClassic: githubClassicUrl,
298
- permissions: githubPerms,
299
- name,
300
- },
301
- }[provider] ?? {};
314
+ provider === null
315
+ ? undefined
316
+ : ({
317
+ gitlab: {
318
+ tokenFile: gitlab.tokenFile ?? path.join(configDir(), "gitlab.token"),
319
+ createUrl: gitlabUrl,
320
+ scopes: gitlabScopes,
321
+ name,
322
+ },
323
+ github: {
324
+ tokenFile:
325
+ (cfg.github as Record<string, any>)?.tokenFile ??
326
+ path.join(configDir(), "github.token"),
327
+ createUrl: githubFineUrl,
328
+ createUrlClassic: githubClassicUrl,
329
+ permissions: githubPerms,
330
+ name,
331
+ },
332
+ }[provider] ?? {});
302
333
 
303
334
  return {
304
335
  tokenName: name,
@@ -0,0 +1,166 @@
1
+ import {
2
+ canonicalJson,
3
+ failure,
4
+ success,
5
+ type Caller,
6
+ type Id,
7
+ type Owner,
8
+ type Provenance,
9
+ type Ref,
10
+ type Result,
11
+ type Revision,
12
+ type TaskRecord,
13
+ type Worker,
14
+ type WorkspaceRecord,
15
+ } from "./task-contract";
16
+ import { TaskStore } from "./task-store";
17
+
18
+ export type WorkerState = Worker["state"];
19
+ export type HostSession = Extract<Ref, { kind: "host" }>;
20
+
21
+ /** The adapter-owned caller identity used by all writer checks. */
22
+ export type CallerContext = Caller & {
23
+ workerId?: Id | null;
24
+ session?: HostSession;
25
+ };
26
+
27
+ export type NativeWorkerObservation = {
28
+ taskId: Id;
29
+ workerId: Id;
30
+ expectedRevision: Revision;
31
+ expectedWorkspaceRevision: Revision;
32
+ state: WorkerState;
33
+ session: HostSession | null;
34
+ observation: unknown;
35
+ };
36
+
37
+ export type NativeWorkerVerification = {
38
+ observation: unknown;
39
+ expected: Omit<NativeWorkerObservation, "observation"> & {
40
+ workspaceId: Id;
41
+ };
42
+ caller: Caller;
43
+ };
44
+
45
+ /**
46
+ * Host attestation for a worker that has no session yet: `prepare` claims the launch
47
+ * slot, `not_started` claims that no child was ever created for that same reservation.
48
+ */
49
+ export type WorkerDispatchStage = "prepare" | "not_started";
50
+
51
+ export type NativeWorkerDispatchVerification = {
52
+ observation: unknown;
53
+ expected: {
54
+ taskId: Id;
55
+ workspaceId: Id;
56
+ workerId: Id;
57
+ expectedRevision: Revision;
58
+ expectedWorkspaceRevision: Revision;
59
+ stage: WorkerDispatchStage;
60
+ };
61
+ caller: Caller;
62
+ };
63
+
64
+ export type NativeWorkerVerifier = {
65
+ verifyWorker: (input: NativeWorkerVerification) => Result<Provenance>;
66
+ /** Hosts that can observe their own launch surface also attest dispatch stages. */
67
+ verifyDispatch?: (input: NativeWorkerDispatchVerification) => Result<Provenance>;
68
+ };
69
+
70
+ declare const workerDispatch: unique symbol;
71
+ /** Opaque in-process launch reservation. Adapters hold it; it is never serialized. */
72
+ export type WorkerDispatch = { readonly [workerDispatch]: true };
73
+
74
+ export type WorkerDispatchRequest = {
75
+ taskId: Id;
76
+ workerId: Id;
77
+ expectedRevision: Revision;
78
+ expectedWorkspaceRevision: Revision;
79
+ observation: unknown;
80
+ };
81
+
82
+ export type WorkerDispatchCommit = WorkerDispatchRequest & {
83
+ dispatch: WorkerDispatch;
84
+ outcome: "started" | "not_started";
85
+ session: HostSession | null;
86
+ };
87
+
88
+ const same = (left: unknown, right: unknown): boolean => {
89
+ try {
90
+ return canonicalJson(left) === canonicalJson(right);
91
+ } catch {
92
+ return false;
93
+ }
94
+ };
95
+
96
+ const callerSession = (caller: CallerContext): HostSession =>
97
+ caller.session ?? { kind: "host", host: caller.host, handle: caller.actor };
98
+
99
+ const validPath = (value: unknown): value is string =>
100
+ typeof value === "string" &&
101
+ value.length > 0 &&
102
+ ![...value].some((char) => {
103
+ const code = char.charCodeAt(0);
104
+ return code < 32 || code === 127;
105
+ }) &&
106
+ !value.split(/[\\/]/).includes("..");
107
+
108
+ const callerValue = (value: CallerContext): Caller => ({ host: value.host, actor: value.actor });
109
+ const workerIdOf = (value: CallerContext): Id | null => value.workerId ?? null;
110
+
111
+ export type ProductWriteInput = {
112
+ task: TaskRecord;
113
+ workspace: WorkspaceRecord;
114
+ caller: CallerContext;
115
+ paths: string[];
116
+ store?: TaskStore;
117
+ };
118
+
119
+ /** Check the authoritative workspace owner and the caller's role. File paths
120
+ * are host-policy territory: only malformed paths are rejected here, never
121
+ * out-of-scope ones. */
122
+ export function assertProductWriteAllowed(input: ProductWriteInput): Result<Owner> {
123
+ if (!Array.isArray(input.paths) || input.paths.some((path) => !validPath(path)))
124
+ return failure("invalid_input", "invalid product write path");
125
+ if (!input.store) return failure("permission_denied", "write authorization requires core state");
126
+ const task = input.store.readTask(input.task.id);
127
+ if (!task.ok) return task as Result<never>;
128
+ const workspace = input.store.readWorkspace();
129
+ if (!workspace.ok) return workspace as Result<never>;
130
+ if (!workspace.data) return failure("not_found", "workspace not found");
131
+ const workspaceRecord = workspace.data;
132
+ if (task.data.status !== "active")
133
+ return failure("invalid_transition", "paused or closed tasks cannot own product writes");
134
+ if (workspaceRecord.id !== task.data.workspaceId || workspaceRecord.root.length === 0)
135
+ return failure("recovery_required", "task and workspace bindings are invalid");
136
+ const writer = workspaceRecord.writer;
137
+ if (!writer) return failure("permission_denied", "checkout has no writer owner");
138
+ if (writer.state === "uncertain")
139
+ return failure("recovery_required", "checkout writer requires recovery", {
140
+ owner: writer.owner,
141
+ });
142
+ const caller = callerValue(input.caller);
143
+ const workerId = workerIdOf(input.caller);
144
+ const owner = writer.owner;
145
+ if (
146
+ owner.taskId !== task.data.id ||
147
+ owner.workerId !== workerId ||
148
+ owner.session.host !== caller.host ||
149
+ owner.session.handle !== caller.actor
150
+ )
151
+ return failure("writer_conflict", "checkout is owned by another validated actor", { owner });
152
+ const worker =
153
+ workerId === null ? null : task.data.workers.find((entry) => entry.id === workerId);
154
+ if (workerId !== null && (!worker || worker.data.assignment.role !== "implementer"))
155
+ return failure("permission_denied", "only implementers can own product writes");
156
+ if (
157
+ workerId !== null &&
158
+ (!worker?.data.session ||
159
+ worker.data.state === "cancelling" ||
160
+ worker.data.state === "unknown" ||
161
+ worker.data.state === "stopped" ||
162
+ !same(worker.data.session, callerSession(input.caller)))
163
+ )
164
+ return failure("recovery_required", "worker session is not an active writer");
165
+ return success(null, workspaceRecord.revision, owner);
166
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { configDir, isConfigObject, type BranchPreset } from "./config";
4
+ import type { CommitFlavorPreset } from "./commit-flavors";
4
5
 
5
6
  export type VcsProvider = "gitlab" | "github";
6
7
 
@@ -18,10 +19,11 @@ export type WorkspaceBranchPolicy = {
18
19
  export type WorkspaceConfig = {
19
20
  name: string;
20
21
  glob: string;
21
- vcs?: { provider: VcsProvider; defaultTargetBranch?: string };
22
+ vcs?: { provider: VcsProvider; defaultTargetBranch?: string; tokenFile?: string };
22
23
  youtrack?: { baseUrl?: string; link_issues?: boolean };
23
24
  issues?: { provider?: "github"; link_on_pr?: boolean };
24
25
  branchPolicy?: WorkspaceBranchPolicy;
26
+ commitPolicy?: { preset: CommitFlavorPreset; pattern?: string };
25
27
  };
26
28
 
27
29
  export const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
@@ -93,6 +93,7 @@ type PostInput = {
93
93
  issueId: string;
94
94
  markdown: string;
95
95
  minutes?: number;
96
+ dateMs?: number;
96
97
  workspace_root?: string;
97
98
  };
98
99
 
@@ -147,6 +148,7 @@ export async function postUpdate(
147
148
  const time = await operations.logTime({
148
149
  issueId: input.issueId,
149
150
  minutes: input.minutes,
151
+ ...(input.dateMs === undefined ? {} : { dateMs: input.dateMs }),
150
152
  text: "workit update",
151
153
  workspace_root: input.workspace_root,
152
154
  });