@brainervirus/workit-core 2.8.0 → 3.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.
package/README.md CHANGED
@@ -30,7 +30,8 @@ Adapters map host-native surfaces to `src/core/`; they never re-implement core l
30
30
  ## Exports
31
31
 
32
32
  - Main entry: `./src/core.ts`.
33
- - Subpath exports: `./src/*.ts` (e.g. `@brainervirus/workit-core/src/core/doctor`), plus `./package.json`.
33
+ - Subpath exports: `./src/*.ts` (e.g. `@brainervirus/workit-core/src/core/task-store`), plus `./package.json`.
34
+ - Setup, doctor, upgrade, uninstall and host-install code lives in `@brainervirus/workit-cli` (`src/admin/`), not here, so host hooks and plugins never bundle it.
34
35
 
35
36
  ## Package scripts
36
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "2.8.0",
3
+ "version": "3.0.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -56,13 +56,7 @@
56
56
  "yoga-layout",
57
57
  "zod"
58
58
  ],
59
- "workit-opencode": [
60
- "@openclaw/fs-safe",
61
- "@opencode-ai/plugin",
62
- "@opencode-ai/plugin/zod",
63
- "@opencode/plugin",
64
- "zod"
65
- ],
59
+ "workit-opencode": ["@openclaw/fs-safe", "@opencode/plugin", "zod"],
66
60
  "workit-cursor": [
67
61
  "@modelcontextprotocol/sdk",
68
62
  "@openclaw/fs-safe",
@@ -19,10 +19,9 @@ fi
19
19
  mkdir -p "$PLUGIN_DIR"
20
20
  rsync -a --delete "$SOURCE/packages/workit-codex/" "$PLUGIN_DIR/"
21
21
 
22
- if ! bun "$ROOT/packages/workit-core/scripts/doctor-check.ts" cli; then
22
+ if ! bun "$ROOT/packages/workit-cli/scripts/doctor-check.ts" cli; then
23
23
  echo "FATAL: post-install doctor found problems after Codex plugin sync" >&2
24
24
  exit 1
25
25
  fi
26
26
 
27
27
  echo "Codex plugin installed at $PLUGIN_DIR"
28
- echo "Complete an explicit v1 cutover before treating this as a generation switch."
@@ -50,7 +50,7 @@ fi
50
50
  # The check reads only workit-owned files, so a healthy install is byte-untouched;
51
51
  # a registry-unreachable comparison reports registry_unreachable, never stale
52
52
  # (CA-04) — no false stale_install and no install failure.
53
- if bun "$ROOT/packages/workit-core/scripts/doctor-check.ts" cursor --stale; then
53
+ if bun "$ROOT/packages/workit-cli/scripts/doctor-check.ts" cursor --stale; then
54
54
  :
55
55
  elif [ $? -eq 2 ]; then
56
56
  echo "workit: stale Cursor plugin install detected — self-healing (refresh + canonical re-registration)" >&2
@@ -70,7 +70,7 @@ if [ -d "$SKILLS_DIR" ]; then
70
70
  fi
71
71
 
72
72
  PLUGIN_DIR="$HOME/.cursor/plugins/local/workit"
73
- REGISTRATION_TS="$ROOT/packages/workit-core/src/core/registration.ts"
73
+ REGISTRATION_TS="$ROOT/packages/workit-cli/src/admin/registration.ts"
74
74
  CURSOR_SETTINGS="$HOME/.cursor/settings.json" CURSOR_MCP="$HOME/.cursor/mcp.json" PLUGIN_DIR="$PLUGIN_DIR" REGISTRATION_TS="$REGISTRATION_TS" LOCAL_DIST="$LOCAL_DIST" bun -e '
75
75
  import fs from "node:fs";
76
76
  import path from "node:path";
@@ -120,7 +120,7 @@ if (localDist) {
120
120
  '
121
121
 
122
122
  # DG-09: verify the just-written Cursor registration with the shared offline doctor.
123
- if ! bun "$ROOT/packages/workit-core/scripts/doctor-check.ts" cursor; then
123
+ if ! bun "$ROOT/packages/workit-cli/scripts/doctor-check.ts" cursor; then
124
124
  echo "FATAL: post-install doctor found an unhealthy Cursor registration" >&2
125
125
  exit 1
126
126
  fi
@@ -140,6 +140,5 @@ if [ -d "$LEGACY_PLUGIN_DIR" ] && [ "$LEGACY_PLUGIN_DIR" != "$PLUGIN_DIR" ]; the
140
140
  fi
141
141
 
142
142
  echo "Cursor plugin installed + auto-sync enabled (sessionStart)."
143
- echo "Generation switches require an explicit cutover preview/apply — install alone is not a cutover."
144
143
  echo "Share: $SHARE"
145
144
  ls "$HOME/.cursor/plugins/local/workit/skills" | grep '^wk-' || true
@@ -38,7 +38,7 @@ mkdir -p "$(dirname "$CONFIG")"
38
38
 
39
39
  # Merge/dedupe registrations via the shared core helper (RR-06): one dev pin,
40
40
  # no legacy/current duplicates, unrelated user settings preserved.
41
- CONFIG_PATH="$CONFIG" PIN_PATH="$PIN" REGISTRATION_TS="$ROOT/packages/workit-core/src/core/registration.ts" bun -e '
41
+ CONFIG_PATH="$CONFIG" PIN_PATH="$PIN" REGISTRATION_TS="$ROOT/packages/workit-cli/src/admin/registration.ts" bun -e '
42
42
  import fs from "node:fs";
43
43
  const { mergeOpenCodeConfig } = await import(process.env.REGISTRATION_TS!);
44
44
  const path = process.env.CONFIG_PATH!;
@@ -71,10 +71,9 @@ if [ ! -s "$PLUGIN_ENTRY" ]; then
71
71
  fi
72
72
 
73
73
  # DG-09: verify the just-written registration with the shared offline doctor.
74
- if ! bun "$ROOT/packages/workit-core/scripts/doctor-check.ts" opencode; then
74
+ if ! bun "$ROOT/packages/workit-cli/scripts/doctor-check.ts" opencode; then
75
75
  echo "FATAL: post-install doctor found an unhealthy OpenCode registration" >&2
76
76
  exit 1
77
77
  fi
78
78
 
79
79
  echo "OpenCode install done. Fully quit all opencode processes, then restart."
80
- echo "Generation switches require an explicit cutover preview/apply — install alone is not a cutover."
@@ -19,8 +19,8 @@ fi
19
19
  # pi 0.85.1 loads extensions from the settings `packages` list, not from
20
20
  # ~/.pi/config.json below. Register the absolute package dir there (honoring
21
21
  # PI_CODING_AGENT_DIR) so `pi list` picks it up; the config.json write stays
22
- # because uninstall/cutover/detection still key pi state off it — migrating
23
- # those three off config.json is a separate contract change.
22
+ # because uninstall and detection still key pi state off it — migrating
23
+ # those two off config.json is a separate contract change.
24
24
  AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
25
25
  SETTINGS="$AGENT_DIR/settings.json"
26
26
  mkdir -p "$AGENT_DIR"
@@ -53,4 +53,3 @@ console.log("Registered Pi extension:", process.env.EXT);
53
53
  '
54
54
 
55
55
  echo "Pi package registered in $SETTINGS (and legacy $PI_CONFIG)"
56
- echo "Complete an explicit v1 cutover before treating this as a generation switch."
@@ -153,19 +153,6 @@ fi
153
153
  # Remove broken TLA live-loader if present (OpenCode ignored it; /wk-* vanished)
154
154
  rm -f "${OPENCODE_PLUGINS}/workflow-toolkit.ts"
155
155
 
156
- # Ensure OpenCode has plugin peer dep
157
- PKG="${HOME}/.config/opencode/package.json"
158
- if [ -f "$PKG" ]; then
159
- PKG_PATH="$PKG" "$BUN_BIN" -e '
160
- import fs from "node:fs";
161
- const path = process.env.PKG_PATH!;
162
- const data = JSON.parse(fs.readFileSync(path, "utf8"));
163
- data.dependencies = data.dependencies ?? {};
164
- data.dependencies["@opencode-ai/plugin"] = data.dependencies["@opencode-ai/plugin"] ?? "1.18.30";
165
- fs.writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
166
- ' || true
167
- fi
168
-
169
156
  # Drop bun package cache so old github/file installs cannot shadow file:// plugin.ts
170
157
  rm -rf "${HOME}/.cache/opencode/packages/workflow-toolkit-opencode@"* 2>/dev/null || true
171
158
 
package/src/core/init.ts CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { configDir, isConfigObject } from "./config";
5
5
  import { writeFileExclusive } from "./safe-write";
6
- import { applyWorkspaceBranchPolicy } from "./setup";
6
+ import { applyWorkspaceBranchPolicy } from "./workspace-branch-policy";
7
7
  import { youTrackTokenCreateUrl } from "./youtrack";
8
8
 
9
9
  const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
@@ -7,10 +7,10 @@
7
7
  export const SUPPORT_MATRIX = {
8
8
  bun: "1.4.1",
9
9
  node: { minimum: "24", current: "24.20.0" },
10
- // The object-form dual entry (server() + setup()) only loads from
11
- // 1.18.29+; the supported floor is 1.18.30 so no host can pass doctor while
12
- // being unable to load the entry shape.
13
- opencode: { minimum: "1.18.30", current: "1.18.34" },
10
+ // OpenCode V2 plugin API only: the entry exports `setup()` (Workit 3.0
11
+ // retired the V1 `server()` adapter), so 1.x hosts cannot load it. 2.0.18 is
12
+ // the `@opencode/plugin` contract the adapter is built and tested against.
13
+ opencode: { minimum: "2.0.18", current: "2.0.18" },
14
14
  // Codex is a qualification host, not a runtime dependency: the CLI version
15
15
  // below is the one live qualification evidence covers. The doctor warns when
16
16
  // an installed CLI drifts ahead so a fresh install never silently outruns
@@ -0,0 +1,123 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { isDeepStrictEqual } from "node:util";
4
+ import { detectBranchPolicy } from "./branch-policy";
5
+ import { configDir } from "./config";
6
+ import {
7
+ readWorkspacesResult,
8
+ resolveWorkspaceFrom,
9
+ validateWorkspaceGlob,
10
+ validateWorkspacesDocument,
11
+ workspacesRevision,
12
+ type WorkspaceConfig,
13
+ } from "./workspaces";
14
+
15
+ const readFileSafe = (p: string): string | null => {
16
+ try {
17
+ return readFileSync(p, "utf8");
18
+ } catch {
19
+ return null;
20
+ }
21
+ };
22
+
23
+ // The one proposal→write path for a workspace branch policy, shared by the
24
+ // OpenCode `workit_init_apply` branch_policy action (init.ts) and the CLI
25
+ // wizard, so both produce byte-identical workspaces.json writes.
26
+ export function applyWorkspaceBranchPolicy(opts: {
27
+ workspace_root: string;
28
+ env?: NodeJS.ProcessEnv;
29
+ }): Record<string, any> {
30
+ const { workspace_root, env = process.env } = opts;
31
+ const dir = path.join(env.WORKFLOW_TOOLKIT_CONFIG ?? configDir());
32
+ const {
33
+ status,
34
+ path: wsPath,
35
+ entries,
36
+ document,
37
+ revision,
38
+ error: workspacesError,
39
+ } = readWorkspacesResult(dir);
40
+ if (status === "malformed" || status === "invalid")
41
+ return { ok: false, error: workspacesError ?? `invalid workspaces.json: ${wsPath}` };
42
+ const detection = detectBranchPolicy(workspace_root);
43
+ const name = String(env.WORKFLOW_BP_NAME ?? path.basename(workspace_root));
44
+ const integration = (env.WORKFLOW_BP_INTEGRATION ?? detection.integration) as "pr" | "merge";
45
+ const policy = {
46
+ preset: detection.preset,
47
+ developBranch: env.WORKFLOW_BP_DEVELOP ?? detection.developBranch ?? undefined,
48
+ prefixes: detection.prefixes,
49
+ allowed: detection.allowed,
50
+ protected: detection.protected,
51
+ integration,
52
+ };
53
+ const glob = `${workspace_root.replace(/[\\/]+$/, "")}/**`;
54
+ if (!validateWorkspaceGlob(glob).ok)
55
+ return { ok: false, error: `invalid workspace glob: ${glob}` };
56
+ let existing: WorkspaceConfig | null;
57
+ try {
58
+ existing = resolveWorkspaceFrom(workspace_root, dir);
59
+ } catch (error) {
60
+ const selectedName = env.WORKFLOW_BP_NAME?.trim();
61
+ if (!selectedName)
62
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
63
+ try {
64
+ existing = resolveWorkspaceFrom(workspace_root, dir, selectedName);
65
+ } catch (selectionError) {
66
+ return {
67
+ ok: false,
68
+ error: selectionError instanceof Error ? selectionError.message : String(selectionError),
69
+ };
70
+ }
71
+ }
72
+ if (existing?.branchPolicy && isDeepStrictEqual(existing.branchPolicy, policy)) {
73
+ return {
74
+ ok: true,
75
+ status: "already-configured",
76
+ workspace: existing,
77
+ policy,
78
+ config_path: wsPath,
79
+ };
80
+ }
81
+ const next = existing
82
+ ? entries.map((w) => (w.name === existing?.name ? { ...w, branchPolicy: policy } : w))
83
+ : [...entries, { name, glob, branchPolicy: policy }];
84
+ if (!existing && entries.some((entry) => entry.name === name))
85
+ return { ok: false, error: `workspace name ${JSON.stringify(name)} is already configured` };
86
+ const proposed = validateWorkspacesDocument({ ...document, workspaces: next }, wsPath);
87
+ if (proposed.status !== "valid") return { ok: false, error: proposed.error };
88
+ if (workspacesRevision(readFileSafe(wsPath)) !== revision)
89
+ return {
90
+ ok: false,
91
+ error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
92
+ };
93
+ mkdirSync(path.dirname(wsPath), { recursive: true });
94
+ const tmp = `${wsPath}.${process.pid}.tmp`;
95
+ try {
96
+ writeFileSync(tmp, JSON.stringify({ ...document, workspaces: next }, null, 2) + "\n", "utf8");
97
+ if (workspacesRevision(readFileSafe(wsPath)) !== revision) {
98
+ rmSync(tmp, { force: true });
99
+ return {
100
+ ok: false,
101
+ error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
102
+ };
103
+ }
104
+ renameSync(tmp, wsPath);
105
+ } catch (error) {
106
+ try {
107
+ rmSync(tmp, { force: true });
108
+ } catch {}
109
+ return {
110
+ ok: false,
111
+ error: `failed to write ${wsPath}: ${error instanceof Error ? error.message : String(error)}`,
112
+ };
113
+ }
114
+ return {
115
+ ok: true,
116
+ status: existing ? "updated" : "configured",
117
+ workspace: existing
118
+ ? { ...existing, branchPolicy: policy }
119
+ : { name, glob, branchPolicy: policy },
120
+ policy,
121
+ config_path: wsPath,
122
+ };
123
+ }
@@ -241,6 +241,11 @@ export type MergeInput = {
241
241
  method: MergeMethod;
242
242
  deleteBranch: boolean;
243
243
  actor: LedgerActor;
244
+ /**
245
+ * Stack landing (S12): refuse unless the PR still targets this base and
246
+ * comes from this branch, re-checked right before the merge call.
247
+ */
248
+ expect?: { base: string; branch: string };
244
249
  };
245
250
 
246
251
  export type MergeOutcome = {
@@ -266,7 +271,8 @@ export type MergeRefusal = {
266
271
  | "head_mismatch"
267
272
  | "already_merged"
268
273
  | "closed"
269
- | "protected_branch";
274
+ | "protected_branch"
275
+ | "base_mismatch";
270
276
  next?: string;
271
277
  blockers?: string[];
272
278
  verdict?: Pick<VerdictCheck, "head" | "accepted" | "authors">;
@@ -340,6 +346,12 @@ export async function mergePullRequest(
340
346
  { reason: "not_ready", next: doc.next, blockers: doc.blockers },
341
347
  );
342
348
 
349
+ if (input.expect && (doc.base !== input.expect.base || doc.head.branch !== input.expect.branch))
350
+ return refuse(
351
+ `base_mismatch: ${label} is ${doc.head.branch} -> ${doc.base}, expected ${input.expect.branch} -> ${input.expect.base}`,
352
+ "workit stack sync # retarget it, then land again",
353
+ { reason: "base_mismatch" },
354
+ );
343
355
  const branch = doc.head.branch;
344
356
  const head = doc.head.sha;
345
357
  // --delete-branch never deletes a protected branch, the base, or the
@@ -4,8 +4,8 @@ import type { HostDescriptor } from "../descriptor";
4
4
  export const OPENCODE_DESCRIPTOR: HostDescriptor = {
5
5
  host: "opencode",
6
6
  label: "OpenCode",
7
- verifiedAgainst: "@opencode-ai/plugin 1.18.30",
8
- docs: ["https://opencode.ai/docs/plugins/"],
7
+ verifiedAgainst: "@opencode/plugin 2.0.18",
8
+ docs: ["https://opencode.ai/v2/docs/build/plugins/"],
9
9
  transport: "in-process-plugin",
10
10
  events: {
11
11
  // No separate start event: context is injected on every agent-loop call.
package/src/ledger.ts CHANGED
@@ -847,6 +847,47 @@ function effectiveOf(rows: readonly ReadRow[], authors: Set<string>): ReadRow |
847
847
  return effective;
848
848
  }
849
849
 
850
+ /**
851
+ * Stacked branches (S12): a verdict keyed against the trunk cannot carry
852
+ * through a restack after its parent was squash-merged, because the
853
+ * trunk-relative diff loses the parent's change. `workit stack sync` observes
854
+ * each restack itself and records `stack.restacked` with `patchEqual` when
855
+ * the change relative to the parent is identical (same patch-id AND same
856
+ * exact diff hash, D18). A verdict carries when such observed rows link its
857
+ * head to the current head.
858
+ */
859
+ function restackCarries(
860
+ rows: readonly ReadRow[],
861
+ branch: string,
862
+ from: string | null,
863
+ to: string | null,
864
+ ): boolean {
865
+ if (!from || !to) return false;
866
+ const edges = new Map<string, string[]>();
867
+ for (const row of rows)
868
+ if (
869
+ row.type === "stack.restacked" &&
870
+ row.observer === "workit_cli" &&
871
+ row.branch === branch &&
872
+ row.patchEqual === true &&
873
+ typeof row.fromHead === "string" &&
874
+ row.head
875
+ )
876
+ edges.set(row.fromHead, [...(edges.get(row.fromHead) ?? []), row.head]);
877
+ const seen = new Set<string>([from]);
878
+ const queue = [from];
879
+ while (queue.length) {
880
+ const next = queue.shift() as string;
881
+ if (next === to) return true;
882
+ for (const target of edges.get(next) ?? [])
883
+ if (!seen.has(target)) {
884
+ seen.add(target);
885
+ queue.push(target);
886
+ }
887
+ }
888
+ return false;
889
+ }
890
+
850
891
  /** Current and accepted verdicts for `branch`, after carry-over (D18). */
851
892
  export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRow[]): VerdictCheck {
852
893
  const head = branchHead(cwd, branch);
@@ -872,7 +913,8 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
872
913
  for (const [kind, list] of byKind) {
873
914
  const row = effectiveOf(list, authors);
874
915
  if (!row) continue;
875
- const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
916
+ let basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
917
+ if (basis === "stale" && restackCarries(rows, branch, row.head, head)) basis = "carried";
876
918
  const current = basis !== "stale";
877
919
  const independence = independenceReasons(row, authors);
878
920
  const reasons: RejectReason[] = [