@brainervirus/workit-core 0.9.1 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Workit — workflow rails for agentic coding: specs, plans, YouTrack, CI-gated commits (shared core)",
6
6
  "keywords": [
@@ -19,7 +19,7 @@ Never ask for or accept tokens in chat. For missing YouTrack configuration, prev
19
19
 
20
20
  When the user wants to personalize the toolkit (locale, timezone, branch policy), ask native questions one at a time:
21
21
 
22
- 1. **Locale** — present a combobox of `localeOptions` (en, es-CL, es-MX, es-AR, pt-BR) + custom answer; validate the answer against BCP-47 (`^[a-z]{2,3}(-[A-Z]{2})?$`) before passing it.
22
+ 1. **Locale** — present a combobox of `localeOptions` (en, es-CL, es-MX, es-AR, pt-BR) + custom answer; validate the answer against BCP-47 (`^[a-z]{2,3}(-(?:[A-Z]{2}|[0-9]{3}))?$`, so `es-419` is valid) before passing it.
23
23
  2. **Timezone** — e.g. `America/Santiago`, custom allowed.
24
24
  3. **Branch policy preset** — gitflow / github-flow / trunk-based / custom.
25
25
  4. **Custom branch lists** (only when preset = custom): allowed patterns (`feature/*`, `codex/*`, …) and protected names (`main`, …).
@@ -35,6 +35,10 @@ const baseBranch = (cwd: string): { base: string } | { error: string } => {
35
35
  };
36
36
  const DECLARE_RE = /^\s*\*+Branch:\*+\s*`?([^`\s|]+)`?\s*$/gim;
37
37
  const USE_CURRENT_RE = /^\s*\*+Branch:\*+\s*use-current\s*$/im;
38
+ // Windows portability: journal lines and stash-coverage sets carry
39
+ // repo-relative paths, which must match git's POSIX separator output even
40
+ // though path.join emits platform separators.
41
+ const toPosix = (p: string) => p.split(path.sep).join("/");
38
42
  const readSafe = (p: string): string | null => {
39
43
  try {
40
44
  return readFileSync(p, "utf8");
@@ -274,14 +278,39 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
274
278
  return fastForwardBase(cwd, base);
275
279
  };
276
280
 
277
- // CA-05: flow-state snapshots live under the OS tempdir scoped by a hash of
278
- // the workspace path never inside the repository or docs/.
281
+ // CA-05: crash-orphaned guard roots are garbage-collected at snapshot time.
282
+ // Fresh roots (any live invocation) and non-workit entries are never touched.
283
+ export const purgeStaleFlowGuardRoots = (now: number): void => {
284
+ const cutoff = now - 24 * 3600_000;
285
+ let entries: string[];
286
+ try {
287
+ entries = readdirSync(tmpdir());
288
+ } catch {
289
+ return;
290
+ }
291
+ for (const entry of entries) {
292
+ if (!entry.startsWith("workit-flow-guard-")) continue;
293
+ try {
294
+ if (statSync(path.join(tmpdir(), entry)).mtimeMs < cutoff)
295
+ rmSync(path.join(tmpdir(), entry), { recursive: true, force: true });
296
+ } catch {
297
+ /* raced removal or unreadable entry: skip */
298
+ }
299
+ }
300
+ };
301
+
302
+ // CA-04: flow-state snapshots live under the OS tempdir scoped by a hash of
303
+ // the workspace path — never inside the repository or docs/. The pid+hrtime
304
+ // suffix makes every invocation unique, so two concurrent setups never share
305
+ // (and never clobber) a root; CA-05 GC reclaims anything a crash orphaned.
279
306
  export const snapshotFlowState = (cwd: string): string => {
307
+ purgeStaleFlowGuardRoots(Date.now());
280
308
  const root = path.join(
281
309
  tmpdir(),
282
- `workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}`,
310
+ `workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}.${
311
+ process.pid
312
+ }.${process.hrtime.bigint()}`,
283
313
  );
284
- rmSync(root, { recursive: true, force: true }); // drop a stale guard from a crashed run
285
314
  mkdirSync(root, { recursive: true });
286
315
  const docsDir = path.join(path.resolve(cwd), "docs");
287
316
  let slugs: string[] = [];
@@ -291,15 +320,21 @@ export const snapshotFlowState = (cwd: string): string => {
291
320
  return root; // no docs/ yet — zero-file snapshot
292
321
  }
293
322
  for (const slug of slugs) {
294
- const src = path.join(docsDir, slug, "sdd", "flow.json");
295
- try {
296
- if (!statSync(src).isFile()) continue;
297
- } catch {
298
- continue;
323
+ // The approval gate digests docs/<slug>/spec.md and docs/<slug>/plan.md,
324
+ // so those bytes must survive the stash window too: a stash push -u takes
325
+ // untracked spec/plan away and any concurrent effective flow read would
326
+ // classify document_missing drift and persist an approval-chain wipe.
327
+ for (const rel of [["sdd", "flow.json"], ["spec.md"], ["plan.md"]]) {
328
+ const src = path.join(docsDir, slug, ...rel);
329
+ try {
330
+ if (!statSync(src).isFile()) continue;
331
+ } catch {
332
+ continue;
333
+ }
334
+ const dest = path.join(root, "docs", slug, ...rel);
335
+ mkdirSync(path.dirname(dest), { recursive: true });
336
+ cpSync(src, dest);
299
337
  }
300
- const dest = path.join(root, "docs", slug, "sdd", "flow.json");
301
- mkdirSync(path.dirname(dest), { recursive: true });
302
- cpSync(src, dest);
303
338
  }
304
339
  return root;
305
340
  };
@@ -307,14 +342,20 @@ export const snapshotFlowState = (cwd: string): string => {
307
342
  // CA-04: restore-if-missing keeps the newest working-tree bytes; the snapshot
308
343
  // root is removed only after every file is handled and retained on failure.
309
344
  // A caught failure must not vanish: the message is returned so callers can
310
- // surface it to operators as a warning.
311
- export const restoreFlowSnapshot = (snapDir: string, cwd: string): string | undefined => {
345
+ // surface it to operators as a warning. `restored` lists ONLY the files this
346
+ // call actually wrote present-at-restore files are skipped and excluded,
347
+ // which is what makes the redundant-stash coverage check honest.
348
+ export const restoreFlowSnapshot = (
349
+ snapDir: string,
350
+ cwd: string,
351
+ ): { restored: string[]; warning?: string } => {
312
352
  const walk = (dir: string, rel: string): string[] =>
313
353
  readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
314
354
  entry.isDirectory()
315
355
  ? walk(path.join(dir, entry.name), path.join(rel, entry.name))
316
356
  : [path.join(rel, entry.name)],
317
357
  );
358
+ const restored: string[] = [];
318
359
  try {
319
360
  const workspace = path.resolve(cwd);
320
361
  for (const rel of walk(snapDir, "")) {
@@ -331,13 +372,17 @@ export const restoreFlowSnapshot = (snapDir: string, cwd: string): string | unde
331
372
  rmSync(tmpDest, { force: true });
332
373
  throw error;
333
374
  }
375
+ restored.push(toPosix(rel));
334
376
  }
335
377
  rmSync(snapDir, { recursive: true, force: true });
336
- return undefined;
378
+ return { restored };
337
379
  } catch (error) {
338
- return `flow state snapshot restore failed: ${
339
- error instanceof Error ? error.message : String(error)
340
- }`;
380
+ return {
381
+ restored,
382
+ warning: `flow state snapshot restore failed: ${
383
+ error instanceof Error ? error.message : String(error)
384
+ }`,
385
+ };
341
386
  }
342
387
  };
343
388
 
@@ -348,12 +393,14 @@ export const branchSetup = ({
348
393
  target_branch,
349
394
  stash,
350
395
  workspace_root,
396
+ log,
351
397
  }: {
352
398
  action?: string;
353
399
  sdd_dir?: string;
354
400
  target_branch?: string;
355
401
  stash?: string;
356
402
  workspace_root: string;
403
+ log?: (message: string) => void;
357
404
  }) => {
358
405
  const cwd = path.resolve(workspace_root);
359
406
  const exec = (args: string[]): string =>
@@ -376,9 +423,60 @@ export const branchSetup = ({
376
423
  writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
377
424
 
378
425
  let snapDir: string | null = null;
379
- const restoreWithWarning = (dir: string | null): string[] => {
380
- const warning = dir ? restoreFlowSnapshot(dir, cwd) : undefined;
381
- return warning ? [warning] : [];
426
+ // CA-01: flow-guard journal brackets the stash/checkout mutation window so a
427
+ // mid-window wipe is pinpointable between adjacent checkpoint lines. With no
428
+ // logger injected every call below is a no-op and nothing extra runs —
429
+ // behaviorally identical to the pre-journal code.
430
+ const journal = (message: string) => log?.(`flow-guard: ${message}`);
431
+ const snapshotRelPaths = (): string[] => {
432
+ const dir = snapDir;
433
+ // Not gated on log: the redundant-stash coverage check below needs this
434
+ // walk even without a logger (the walk is read-only over the tmpdir
435
+ // snapshot).
436
+ if (!dir) return [];
437
+ try {
438
+ const walk = (from: string, rel: string): string[] =>
439
+ readdirSync(from, { withFileTypes: true }).flatMap((entry) =>
440
+ entry.isDirectory()
441
+ ? walk(path.join(from, entry.name), path.join(rel, entry.name))
442
+ : [path.join(rel, entry.name)],
443
+ );
444
+ return walk(dir, "").map(toPosix);
445
+ } catch {
446
+ return [];
447
+ }
448
+ };
449
+ const journalSnapshot = () => {
450
+ const dir = snapDir;
451
+ if (!dir || !log) return;
452
+ const rels = snapshotRelPaths();
453
+ journal(`snapshot: ${rels.length} file(s)`);
454
+ for (const rel of rels) {
455
+ let shortHash = "";
456
+ try {
457
+ shortHash = createHash("sha256")
458
+ .update(readFileSync(path.join(dir, rel)))
459
+ .digest("hex")
460
+ .slice(0, 8);
461
+ } catch {}
462
+ journal(`snapshot: ${rel} sha=${shortHash}`);
463
+ }
464
+ };
465
+ const journalPresence = (phase: string) => {
466
+ for (const rel of snapshotRelPaths()) {
467
+ journal(`${phase}: ${rel} ${existsSync(path.join(cwd, rel)) ? "present" : "MISSING"}`);
468
+ }
469
+ };
470
+ const restoreWithWarning = (dir: string | null): { restored: string[]; warnings: string[] } => {
471
+ if (!dir) return { restored: [], warnings: [] };
472
+ const total = log ? snapshotRelPaths().length : 0;
473
+ const { restored, warning } = restoreFlowSnapshot(dir, cwd);
474
+ if (log)
475
+ journal(
476
+ `restore: restored=${restored.length} skipped=${total - restored.length}${warning ? ` warning: ${warning}` : ""}`,
477
+ );
478
+ else if (warning) journal(`restore warning: ${warning}`);
479
+ return { restored, warnings: warning ? [warning] : [] };
382
480
  };
383
481
 
384
482
  if (action === "reapply_stash") {
@@ -387,22 +485,28 @@ export const branchSetup = ({
387
485
  if (!ref) return { error: "no stash_ref in manifest" };
388
486
  // D-03: guard flow.json across the stash pop window.
389
487
  snapDir = snapshotFlowState(cwd);
488
+ journalSnapshot();
489
+ journal(`pre-pop: ${String(ref)}`);
390
490
  try {
391
491
  exec(["stash", "pop", String(ref)]);
392
492
  } catch (error) {
393
493
  // CA-03: the snapshot ran before the pop — a failing pop must still
394
494
  // restore a mid-window-wiped flow.json before returning.
395
- const [warning] = restoreWithWarning(snapDir);
495
+ journal("pop: failed");
496
+ const {
497
+ warnings: [warning],
498
+ } = restoreWithWarning(snapDir);
396
499
  return {
397
500
  error: `${error instanceof Error ? error.message : "stash pop failed"}${
398
501
  warning ? `; ${warning}` : ""
399
502
  }`,
400
503
  };
401
504
  }
505
+ journal("pop: ok");
402
506
  delete manifest.stash_ref;
403
507
  delete manifest.stash_created_at;
404
508
  writeManifest(manifest);
405
- const warnings = restoreWithWarning(snapDir);
509
+ const { warnings } = restoreWithWarning(snapDir);
406
510
  return { action: "reapply_stash", ok: true, ...(warnings.length > 0 ? { warnings } : {}) };
407
511
  }
408
512
 
@@ -427,6 +531,7 @@ export const branchSetup = ({
427
531
  if ("error" in baseResolved) return { error: baseResolved.error };
428
532
  base = baseResolved.base;
429
533
  }
534
+ journal(`entry: current=${current} target=${target} base=${base ?? "-"}`);
430
535
 
431
536
  let stash_ref: string | undefined;
432
537
  // Best-effort restore; if the pop itself fails, the caller's error gains a
@@ -434,18 +539,23 @@ export const branchSetup = ({
434
539
  const failAfterStash = (message: string): { error: string } => {
435
540
  let suffix = "";
436
541
  if (stash_ref) {
542
+ journal(`pre-pop: ${stash_ref}`);
437
543
  try {
438
544
  exec(["stash", "pop", stash_ref]);
439
545
  stash_ref = undefined;
546
+ journal("pop: ok");
440
547
  } catch {
548
+ journal("pop: failed");
441
549
  suffix = " (changes preserved in stash)";
442
550
  }
443
551
  }
444
552
  // CA-03: the snapshot ran before the stash push, so every error return
445
553
  // here must still restore a mid-window-wiped flow.json and drop the
446
- // guard root (a retained root is destroyed by the next run's
447
- // stale-root rmSync). Never masks the original error.
448
- const [warning] = restoreWithWarning(snapDir);
554
+ // guard root (a retained root is purged by the next run's 24h GC).
555
+ // Never masks the original error.
556
+ const {
557
+ warnings: [warning],
558
+ } = restoreWithWarning(snapDir);
449
559
  return { error: `${message}${suffix}${warning ? `; ${warning}` : ""}` };
450
560
  };
451
561
  if (current !== target) {
@@ -471,14 +581,17 @@ export const branchSetup = ({
471
581
  // CA-03: snapshot before the stash push so flow.json survives the
472
582
  // stash/checkout window even if the pathspec exclusion misses.
473
583
  snapDir = snapshotFlowState(cwd);
584
+ journalSnapshot();
474
585
  exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`, "--", ":!docs/*/sdd"]);
475
586
  } catch (error) {
476
587
  return { error: error instanceof Error ? error.message : "stash push failed" };
477
588
  }
478
589
  stash_ref = "stash@{0}";
590
+ journal(`stash push: ${stash_ref}`);
479
591
  }
480
592
  try {
481
593
  exec(["checkout", target]);
594
+ journalPresence("post-checkout");
482
595
  } catch (error) {
483
596
  const message = error instanceof Error ? error.message : "checkout failed";
484
597
  if (/worktree/i.test(message)) {
@@ -501,6 +614,7 @@ export const branchSetup = ({
501
614
  : ensureBaseBranch(cwd, effectiveBase);
502
615
  if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
503
616
  exec(["checkout", "-b", target]);
617
+ journalPresence("post-create");
504
618
  } catch (createError) {
505
619
  return failAfterStash(
506
620
  createError instanceof Error ? createError.message : "branch create failed",
@@ -534,7 +648,47 @@ export const branchSetup = ({
534
648
  }
535
649
  return result;
536
650
  }
537
- const warnings = restoreWithWarning(snapDir);
651
+ // Files the guard actually wrote back, straight from the restore itself.
652
+ const { restored: restoredRels, warnings } = restoreWithWarning(snapDir);
653
+ // Round-1 fallout: the guard restores untracked spec/plan right after
654
+ // checkout, but the stash pushed the same files — a later reapply_stash pop
655
+ // refuses ("untracked working tree files would be overwritten"), stranding
656
+ // a stash_ref on every successful setup. When every stashed path was just
657
+ // restored byte-identical from the pre-stash snapshot, the stash is
658
+ // redundant: drop it and clear the manifest ref. Coverage counts ONLY
659
+ // actually-restored paths: a tracked-and-modified file survives its stash
660
+ // (reverted to HEAD bytes, still present), so restore skips it — counting
661
+ // it as covered would drop the only copy of the user's edit. Any stashed
662
+ // path not restored keeps the ref so reapply_stash stays available.
663
+ // Best-effort: any failure keeps today's keep-the-ref behavior.
664
+ if (stash_ref && snapDir && warnings.length === 0) {
665
+ const covered = new Set(restoredRels.map(toPosix));
666
+ try {
667
+ const stashed = exec([
668
+ "stash",
669
+ "show",
670
+ "--include-untracked",
671
+ "--name-only",
672
+ String(stash_ref),
673
+ ])
674
+ .split("\n")
675
+ .map((line) => toPosix(line.trim()))
676
+ .filter(Boolean);
677
+ if (stashed.length > 0 && stashed.every((p) => covered.has(p))) {
678
+ exec(["stash", "drop", String(stash_ref)]);
679
+ stash_ref = undefined;
680
+ journal("dropped redundant stash (fully covered by snapshot restore)");
681
+ const manifest = readManifest();
682
+ delete manifest.stash_ref;
683
+ delete manifest.stash_created_at;
684
+ writeManifest(manifest);
685
+ } else {
686
+ journal(`kept stash ref ${stash_ref} (not fully covered by actual restores)`);
687
+ }
688
+ } catch {
689
+ /* keep the stash ref — reapply_stash stays available */
690
+ }
691
+ }
538
692
  return {
539
693
  action: "setup",
540
694
  ok: true,
@@ -122,7 +122,9 @@ export const ensureConfigDir = (dir: string = resolveConfigDir()): string => {
122
122
 
123
123
  export const configDir = (): string => ensureConfigDir();
124
124
 
125
- export const LOCALE_RE = /^[a-z]{2,3}(-[A-Z]{2})?$/;
125
+ // Region subtags: 2-letter ISO 3166 alpha-2 or 3-digit UN M.49 (es-419 =
126
+ // Latinoamérica), per BCP-47 well-formedness for the tags this toolkit stores.
127
+ export const LOCALE_RE = /^[a-z]{2,3}(-(?:[A-Z]{2}|[0-9]{3}))?$/;
126
128
 
127
129
  const DEFAULTS: ToolkitConfig = {
128
130
  locale: "en",
@@ -0,0 +1,377 @@
1
+ // Uninstall planning + apply (Task 8): the exact inverse of the setup/registration
2
+ // write set. planUninstall is a pure reader — it classifies the installed state
3
+ // and returns the actions apply WOULD perform; applyUninstall dispatches only
4
+ // reviewed plan actions, preserves unrelated user config byte-for-byte
5
+ // (write-only-if-changed), and never touches ~/.config/workit
6
+ // (CA-11, CA-12, CA-13, CA-14). Homes are injectable exactly like setup/doctor
7
+ // path options (D-07): tests pass explicit paths and no default ever resolves
8
+ // to a real user directory in tests.
9
+ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { isWorkitPlugin } from "./registration";
13
+
14
+ export type UninstallHost = "opencode" | "cursor";
15
+
16
+ export type UninstallAction =
17
+ | { kind: "edit-json-remove"; path: string; detail: string }
18
+ | { kind: "remove-dir"; path: string; detail: string };
19
+
20
+ export type UninstallHostPlan = {
21
+ host: UninstallHost;
22
+ installed: boolean;
23
+ actions: UninstallAction[];
24
+ };
25
+
26
+ export type UninstallPlan = {
27
+ hosts: UninstallHostPlan[];
28
+ };
29
+
30
+ export type UninstallResultStatus = "removed" | "skipped" | "failed";
31
+
32
+ export type UninstallResultEntry = {
33
+ host: UninstallHost;
34
+ path: string;
35
+ status: UninstallResultStatus;
36
+ detail?: string;
37
+ };
38
+
39
+ export type UninstallResult = {
40
+ ok: boolean;
41
+ entries: UninstallResultEntry[];
42
+ };
43
+
44
+ /** Injectable homes mirroring setup's ApplySetupOptions subset (D-07). */
45
+ export type UninstallPaths = {
46
+ home?: string;
47
+ env?: NodeJS.ProcessEnv;
48
+ opencodeConfig?: string;
49
+ cursorSettings?: string;
50
+ cursorMcp?: string;
51
+ cursorPluginDir?: string;
52
+ };
53
+
54
+ type ResolvedUninstall = {
55
+ opencodeConfig: string;
56
+ cursorSettings: string;
57
+ cursorMcp: string;
58
+ cursorPluginDir: string;
59
+ };
60
+
61
+ const resolveUninstallPaths = (options: UninstallPaths = {}): ResolvedUninstall => {
62
+ // Same chain as setup.ts (parity advisory): explicit option > env.HOME >
63
+ // homedir. No process.env tier — an ambient HOME must never leak past an
64
+ // explicitly empty injected env.
65
+ const home = options.home ?? options.env?.HOME ?? os.homedir();
66
+ return {
67
+ opencodeConfig:
68
+ options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
69
+ cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
70
+ cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
71
+ cursorPluginDir:
72
+ options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
73
+ };
74
+ };
75
+
76
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
77
+ v !== null && typeof v === "object" && !Array.isArray(v);
78
+
79
+ type Existing =
80
+ | { kind: "missing" }
81
+ | { kind: "malformed"; error: string }
82
+ | { kind: "record"; value: Record<string, unknown> };
83
+
84
+ const readJsonRecord = (file: string): Existing => {
85
+ let raw: string;
86
+ try {
87
+ raw = readFileSync(file, "utf8");
88
+ } catch {
89
+ // A read-permission error (EACCES) must not look like a missing file (same
90
+ // disambiguation as setup.ts readExisting): classify it malformed so plan
91
+ // keeps the host installed and apply reports Failed with the path untouched.
92
+ if (existsSync(file)) return { kind: "malformed", error: `${file} is not readable` };
93
+ return { kind: "missing" };
94
+ }
95
+ try {
96
+ const value = JSON.parse(raw);
97
+ return isRecord(value)
98
+ ? { kind: "record", value }
99
+ : { kind: "malformed", error: `${file} is not a JSON object` };
100
+ } catch {
101
+ return { kind: "malformed", error: `${file} is not valid JSON` };
102
+ }
103
+ };
104
+
105
+ // Mirror of mergeCursorEnabledPlugins' strip(): trailing-separator-insensitive
106
+ // comparison that guards the filesystem root.
107
+ const stripTrailingSep = (p: string): string => {
108
+ const j = path.join(p);
109
+ return path.dirname(j) === j ? j : j.replace(/[\\/]+$/, "");
110
+ };
111
+
112
+ const CURSOR_LEGACY_IDENTITIES = ["workflow-toolkit", "local/workflow-toolkit"];
113
+
114
+ // Inverse of mergeCursorSettings: drop workit identities from enabled_plugins,
115
+ // drop the canonical plugin dir entry from plugin_dirs. Returns the next record
116
+ // plus whether anything changed (plan/apply share this so outcomes match).
117
+ function cleanCursorSettings(
118
+ settings: Record<string, unknown>,
119
+ pluginDir: string,
120
+ ): { next: Record<string, unknown>; changed: boolean } {
121
+ const next = { ...settings };
122
+ let changed = false;
123
+ if (isRecord(next.enabled_plugins)) {
124
+ const enabled = { ...(next.enabled_plugins as Record<string, unknown>) };
125
+ for (const identity of ["workit", ...CURSOR_LEGACY_IDENTITIES]) {
126
+ if (identity in enabled) {
127
+ delete enabled[identity];
128
+ changed = true;
129
+ }
130
+ }
131
+ next.enabled_plugins = enabled;
132
+ }
133
+ if (Array.isArray(next.plugin_dirs)) {
134
+ const canonical = stripTrailingSep(pluginDir);
135
+ const kept = (next.plugin_dirs as unknown[])
136
+ .map(String)
137
+ .filter((d) => stripTrailingSep(d) !== canonical);
138
+ if (kept.length !== (next.plugin_dirs as unknown[]).length) {
139
+ next.plugin_dirs = kept;
140
+ changed = true;
141
+ }
142
+ }
143
+ return { next, changed };
144
+ }
145
+
146
+ // Inverse of mergeOpenCodeConfig's plugin registration: remove every workit
147
+ // identity from the plugin list.
148
+ function cleanOpenCodeConfig(config: Record<string, unknown>): {
149
+ next: Record<string, unknown>;
150
+ changed: boolean;
151
+ } {
152
+ const next = { ...config };
153
+ let changed = false;
154
+ if (Array.isArray(next.plugin)) {
155
+ const plugins = (next.plugin as unknown[]).map(String);
156
+ const kept = plugins.filter((p) => !isWorkitPlugin(p));
157
+ if (kept.length !== plugins.length) {
158
+ next.plugin = kept;
159
+ changed = true;
160
+ }
161
+ } else if (typeof next.plugin === "string" && isWorkitPlugin(next.plugin)) {
162
+ delete next.plugin;
163
+ changed = true;
164
+ }
165
+ return { next, changed };
166
+ }
167
+
168
+ // Inverse of mergeCursorMcp: drop the canonical server name and its legacy twin.
169
+ function cleanCursorMcp(mcp: Record<string, unknown>): {
170
+ next: Record<string, unknown>;
171
+ changed: boolean;
172
+ } {
173
+ const next = { ...mcp };
174
+ let changed = false;
175
+ if (isRecord(next.mcpServers)) {
176
+ const servers = { ...(next.mcpServers as Record<string, unknown>) };
177
+ for (const name of ["workit", "workflow-toolkit"]) {
178
+ if (name in servers) {
179
+ delete servers[name];
180
+ changed = true;
181
+ }
182
+ }
183
+ next.mcpServers = servers;
184
+ }
185
+ return { next, changed };
186
+ }
187
+
188
+ // Shared edit-json-remove executor for plan parity: parse → clean by target →
189
+ // report change without writing.
190
+ type JsonCleaner = (record: Record<string, unknown>) => {
191
+ next: Record<string, unknown>;
192
+ changed: boolean;
193
+ };
194
+
195
+ const jsonCleanerFor = (target: string, res: ResolvedUninstall): JsonCleaner | null => {
196
+ if (target === res.opencodeConfig) return cleanOpenCodeConfig;
197
+ if (target === res.cursorSettings) return (r) => cleanCursorSettings(r, res.cursorPluginDir);
198
+ if (target === res.cursorMcp) return cleanCursorMcp;
199
+ return null;
200
+ };
201
+
202
+ /** Pure planner: reports the uninstall actions Apply would perform. Reads host
203
+ * config files but never writes; ~/.config/workit is never an action target. */
204
+ export function planUninstall(paths: UninstallPaths = {}): UninstallPlan {
205
+ const res = resolveUninstallPaths(paths);
206
+
207
+ const ocExisting = readJsonRecord(res.opencodeConfig);
208
+ const ocDirty =
209
+ ocExisting.kind === "malformed" ||
210
+ (ocExisting.kind === "record" && cleanOpenCodeConfig(ocExisting.value).changed);
211
+ const opencode: UninstallHostPlan = {
212
+ host: "opencode",
213
+ installed: ocDirty,
214
+ actions: ocDirty
215
+ ? [
216
+ {
217
+ kind: "edit-json-remove",
218
+ path: res.opencodeConfig,
219
+ detail: "remove workit plugin entries from opencode.json",
220
+ },
221
+ ]
222
+ : [],
223
+ };
224
+
225
+ const settingsExisting = readJsonRecord(res.cursorSettings);
226
+ const mcpExisting = readJsonRecord(res.cursorMcp);
227
+ // A malformed host file is still planned: apply must surface the failure
228
+ // (file untouched) instead of silently pretending the host is clean.
229
+ const settingsDirty =
230
+ settingsExisting.kind === "malformed" ||
231
+ (settingsExisting.kind === "record" &&
232
+ cleanCursorSettings(settingsExisting.value, res.cursorPluginDir).changed);
233
+ const mcpDirty =
234
+ mcpExisting.kind === "malformed" ||
235
+ (mcpExisting.kind === "record" && cleanCursorMcp(mcpExisting.value).changed);
236
+ const dirExists = existsSync(res.cursorPluginDir);
237
+ const actions: UninstallAction[] = [];
238
+ if (settingsDirty) {
239
+ actions.push({
240
+ kind: "edit-json-remove",
241
+ path: res.cursorSettings,
242
+ detail: "remove workit enabled_plugins/plugin_dirs entries from settings.json",
243
+ });
244
+ }
245
+ if (mcpDirty) {
246
+ actions.push({
247
+ kind: "edit-json-remove",
248
+ path: res.cursorMcp,
249
+ detail: "remove the workit MCP server registration from mcp.json",
250
+ });
251
+ }
252
+ if (dirExists) {
253
+ actions.push({
254
+ kind: "remove-dir",
255
+ path: res.cursorPluginDir,
256
+ detail: "delete the local workit plugin directory",
257
+ });
258
+ }
259
+ const cursor: UninstallHostPlan = {
260
+ host: "cursor",
261
+ installed: settingsDirty || mcpDirty || dirExists,
262
+ actions,
263
+ };
264
+
265
+ return { hosts: [opencode, cursor] };
266
+ }
267
+
268
+ // CA-14 traversal guard: rm -rf is permitted ONLY on the exact resolved
269
+ // canonical <home>/.cursor/plugins/local/workit directory. Any other resolved
270
+ // path (symlinked alias, sibling, traversal) fails closed without touching disk.
271
+ // Belt-and-braces (Task 8 advisory): when both sides resolve, their real paths
272
+ // must agree too — an ancestor symlink swapped in between plan and apply cannot
273
+ // widen the rm target. An unresolvable path falls through to the lexical
274
+ // verdict (apply then reports skipped/failed downstream).
275
+ const canonicalRemoveDirAllowed = (actionPath: string, res: ResolvedUninstall): boolean => {
276
+ const expected = path.resolve(res.cursorPluginDir);
277
+ if (
278
+ !(
279
+ actionPath === expected &&
280
+ path.basename(expected) === "workit" &&
281
+ path.basename(path.dirname(expected)) === "local" &&
282
+ path.basename(path.dirname(path.dirname(expected))) === "plugins"
283
+ )
284
+ ) {
285
+ return false;
286
+ }
287
+ try {
288
+ return realpathSync(actionPath) === realpathSync(expected);
289
+ } catch {
290
+ return true;
291
+ }
292
+ };
293
+
294
+ const applyEditJsonRemove = (
295
+ target: string,
296
+ cleaner: JsonCleaner,
297
+ ): { status: UninstallResultStatus; detail?: string } => {
298
+ const existing = readJsonRecord(target);
299
+ if (existing.kind === "malformed") {
300
+ return { status: "failed", detail: `${existing.error} — file untouched` };
301
+ }
302
+ if (existing.kind === "missing") {
303
+ return { status: "skipped", detail: "file already absent" };
304
+ }
305
+ const { next, changed } = cleaner(existing.value);
306
+ // Only-if-changed: byte-preserving when there is nothing to remove.
307
+ if (!changed) return { status: "skipped", detail: "no workit entries present" };
308
+ const serialized = JSON.stringify(next, null, 2) + "\n";
309
+ // The byte-compare re-read races any external writer; a vanished/unreadable
310
+ // file between the two reads must fail THIS action, not abort the rest.
311
+ let current: string;
312
+ try {
313
+ current = readFileSync(target, "utf8");
314
+ } catch (error) {
315
+ return {
316
+ status: "failed",
317
+ detail: `read failed before write: ${error instanceof Error ? error.message : String(error)}`,
318
+ };
319
+ }
320
+ if (current === serialized) {
321
+ return { status: "skipped", detail: "already clean" };
322
+ }
323
+ try {
324
+ writeFileSync(target, serialized, "utf8");
325
+ } catch (error) {
326
+ return {
327
+ status: "failed",
328
+ detail: `write failed: ${error instanceof Error ? error.message : String(error)}`,
329
+ };
330
+ }
331
+ return { status: "removed" };
332
+ };
333
+
334
+ /** Applies ONLY the reviewed plan actions with the given path options. Each
335
+ * planned action yields exactly one result entry; malformed host JSON fails
336
+ * its own action untouched while the remaining actions proceed (CA-13). */
337
+ export function applyUninstall(plan: UninstallPlan, paths: UninstallPaths = {}): UninstallResult {
338
+ const res = resolveUninstallPaths(paths);
339
+ const entries: UninstallResultEntry[] = [];
340
+ for (const hostPlan of plan.hosts) {
341
+ for (const action of hostPlan.actions) {
342
+ let status: UninstallResultStatus;
343
+ let detail: string | undefined;
344
+ if (action.kind === "remove-dir") {
345
+ // Resolve before comparing so ".."/symlink tricks can never widen the rm.
346
+ const resolved = path.resolve(action.path);
347
+ if (!canonicalRemoveDirAllowed(resolved, res)) {
348
+ status = "failed";
349
+ detail = `refusing to remove non-canonical plugin directory: ${resolved}`;
350
+ } else if (!existsSync(resolved)) {
351
+ status = "skipped";
352
+ detail = "directory already absent";
353
+ } else {
354
+ try {
355
+ rmSync(resolved, { recursive: true, force: true });
356
+ status = "removed";
357
+ } catch (error) {
358
+ status = "failed";
359
+ detail = error instanceof Error ? error.message : String(error);
360
+ }
361
+ }
362
+ } else {
363
+ const cleaner = jsonCleanerFor(action.path, res);
364
+ if (cleaner === null) {
365
+ status = "failed";
366
+ detail = `${action.path} is not a recognized uninstall target for this host`;
367
+ } else {
368
+ const outcome = applyEditJsonRemove(action.path, cleaner);
369
+ status = outcome.status;
370
+ detail = outcome.detail;
371
+ }
372
+ }
373
+ entries.push({ host: hostPlan.host, path: action.path, status, detail });
374
+ }
375
+ }
376
+ return { ok: entries.every((e) => e.status !== "failed"), entries };
377
+ }