@brainervirus/workit-core 2.8.0 → 2.9.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": "2.8.0",
3
+ "version": "2.9.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": [
@@ -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
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[] = [