@brainervirus/workit-core 8.30.0 → 8.31.1

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": "8.30.0",
3
+ "version": "8.31.1",
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": [
@@ -17,7 +17,8 @@
17
17
  import { mergeBase, fileAt } from "../../git/rev";
18
18
  import { computeLane, driftFiles, kindOf, parseRiskPaths, RISK_PATHS_FILE } from "./lane";
19
19
 
20
- export type CarryRule = "lane-none" | "tests-only";
20
+ /** `rebase`: the same patch on a moved base (ledger.ts exact carry), never a drift rule. */
21
+ export type CarryRule = "lane-none" | "tests-only" | "rebase";
21
22
 
22
23
  export type Drift =
23
24
  | { carries: true; rule: CarryRule; since: string; files: string[] }
@@ -0,0 +1,35 @@
1
+ // A mid-session model switch (#435): the new model inherits a long context
2
+ // whose Workit contract sits far up, so the host re-sends the compact contract
3
+ // and the current turn context once per change, never on every turn.
4
+
5
+ /** The last model seen per session, in the host plugin's own state. */
6
+ export type SessionModels = Map<string, string>;
7
+
8
+ const MAX_SESSIONS = 256;
9
+
10
+ /**
11
+ * Record `model` for the session and say whether it differs from the last one
12
+ * seen. The first model a session reports is a start, not a switch; an unknown
13
+ * model (null) changes nothing.
14
+ */
15
+ export const modelSwitched = (
16
+ models: SessionModels,
17
+ sessionId: string,
18
+ model: string | null,
19
+ ): boolean => {
20
+ if (!model) return false;
21
+ const previous = models.get(sessionId);
22
+ models.delete(sessionId);
23
+ models.set(sessionId, model);
24
+ if (models.size > MAX_SESSIONS) models.delete(models.keys().next().value!);
25
+ return previous !== undefined && previous !== model;
26
+ };
27
+
28
+ /** `provider/id` from a host's model reference, or null when it names none. */
29
+ export const modelKey = (model: unknown): string | null => {
30
+ if (model === null || typeof model !== "object") return null;
31
+ const { id, providerID, provider } = model as Record<string, unknown>;
32
+ if (typeof id !== "string" || !id) return null;
33
+ const source = typeof providerID === "string" ? providerID : provider;
34
+ return typeof source === "string" && source ? `${source}/${id}` : id;
35
+ };
@@ -0,0 +1,115 @@
1
+ // A repeated identical failing Workit call (#435): the second failure in a row
2
+ // of the same tool and args (or the same `workit …` command) in a session ends
3
+ // with one line naming the exact corrected next call, instead of the same error.
4
+ import { createHash } from "node:crypto";
5
+
6
+ export const SAME_CALL_FAILED = "Same call failed twice:";
7
+
8
+ /** The last failing call's hash per session, in the host plugin's own state. */
9
+ export type FailureMemo = Map<string, string>;
10
+
11
+ const MAX_SESSIONS = 256;
12
+
13
+ const stable = (value: unknown): string =>
14
+ JSON.stringify(value, (_key, entry: unknown) =>
15
+ entry && typeof entry === "object" && !Array.isArray(entry)
16
+ ? Object.fromEntries(Object.entries(entry).toSorted(([a], [b]) => a.localeCompare(b)))
17
+ : entry,
18
+ ) ?? "null";
19
+
20
+ const hashOf = (call: string): string =>
21
+ createHash("sha256").update(call).digest("hex").slice(0, 32);
22
+
23
+ /**
24
+ * Record this call's outcome for the session. A success clears the memo; a
25
+ * failure returns the escalation line when the previous call failed the same
26
+ * way and a corrected call is known.
27
+ */
28
+ const record = (
29
+ memo: FailureMemo,
30
+ sessionId: string,
31
+ call: string,
32
+ failed: boolean,
33
+ next: string | null,
34
+ ): string | null => {
35
+ const previous = memo.get(sessionId);
36
+ memo.delete(sessionId);
37
+ if (!failed) return null;
38
+ const hash = hashOf(call);
39
+ memo.set(sessionId, hash);
40
+ if (memo.size > MAX_SESSIONS) memo.delete(memo.keys().next().value!);
41
+ return previous === hash && next ? `${SAME_CALL_FAILED} ${next}` : null;
42
+ };
43
+
44
+ type Failed = { ok: false; error: string; details?: { guidance?: unknown } };
45
+
46
+ const isFailure = (result: unknown): result is Failed =>
47
+ result !== null &&
48
+ typeof result === "object" &&
49
+ (result as { ok?: unknown }).ok === false &&
50
+ typeof (result as { error?: unknown }).error === "string";
51
+
52
+ /** A failed tool result's corrected payload: its `Example:` or, failing that, its guidance. */
53
+ const correctedPayload = (tool: string, result: Failed): string | null => {
54
+ const example = /Example: (\{.*\})\s*$/.exec(result.error)?.[1];
55
+ if (example) return `${tool} ${example}`;
56
+ const guidance = result.details?.guidance;
57
+ return typeof guidance === "string" && guidance ? guidance : null;
58
+ };
59
+
60
+ /**
61
+ * A Workit tool result, with the escalation line appended to its error when
62
+ * the same tool and args failed on the session's previous call too.
63
+ */
64
+ export const escalateToolFailure = <T>(
65
+ memo: FailureMemo,
66
+ sessionId: string,
67
+ tool: string,
68
+ input: unknown,
69
+ result: T,
70
+ ): T => {
71
+ const failed = isFailure(result);
72
+ const line = record(
73
+ memo,
74
+ sessionId,
75
+ `tool\0${tool}\0${stable(input)}`,
76
+ failed,
77
+ failed ? correctedPayload(tool, result) : null,
78
+ );
79
+ return line && failed ? { ...result, error: `${result.error}\n${line}` } : result;
80
+ };
81
+
82
+ const WORKIT_COMMAND = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*workit\s/;
83
+
84
+ const unblockOf = (output: string): string | null => {
85
+ const human = /^\s*unblock: (.+)$/m.exec(output)?.[1];
86
+ if (human) return human.trim();
87
+ const json = /"unblock":("(?:[^"\\]|\\.)*")/.exec(output)?.[1];
88
+ if (!json) return null;
89
+ try {
90
+ return JSON.parse(json) as string;
91
+ } catch {
92
+ return null;
93
+ }
94
+ };
95
+
96
+ /**
97
+ * The escalation line for a finished `workit …` shell command, or null: a
98
+ * failed run is one whose output carries the CLI's error envelope.
99
+ */
100
+ export const repeatedCliFailure = (
101
+ memo: FailureMemo,
102
+ sessionId: string,
103
+ command: string,
104
+ output: string,
105
+ ): string | null => {
106
+ if (!WORKIT_COMMAND.test(command)) return null;
107
+ const failed = /^workit: /m.test(output) || /"ok":\s*false/.test(output);
108
+ return record(
109
+ memo,
110
+ sessionId,
111
+ `cli\0${command.trim()}`,
112
+ failed,
113
+ failed ? unblockOf(output) : null,
114
+ );
115
+ };
package/src/ledger.ts CHANGED
@@ -33,7 +33,8 @@
33
33
  // `base`, the stable patch-id of `base...head` and an exact diff hash. It is
34
34
  // `fresh` (same head; verdicts on a dirty worktree are refused, so the head
35
35
  // is the judged tree), `carried` (new head, same patch-id AND same exact
36
- // diff AND, for a pass, byte-identical own files the base left alone: a
36
+ // diff AND, for a pass, byte-identical own files the base left alone, or
37
+ // a head that is exactly the judged head cleanly rebased (cleanRebase): a
37
38
  // rebase that only moved the base; or a trusted verdict whose
38
39
  // follow-up change is lane none, or test-only for a unit verdict, see
39
40
  // core/policy/carry.ts) or `stale`.
@@ -338,6 +339,32 @@ export function diffHash(cwd: string, base: string, head: string): string | null
338
339
  return `sha256:${hash.digest("hex")}`;
339
340
  }
340
341
 
342
+ /**
343
+ * Whether `to` (on `toBase`) is exactly the judged `from` (on `fromBase`)
344
+ * rebased cleanly onto the moved base: a 3-way merge of `from` onto
345
+ * `merge-base(toBase, to)` with `merge-base(fromBase, from)` as the ancestor
346
+ * has no conflict and yields `to`'s tree. Hunks are anchored to the
347
+ * ancestor, so an edit moved elsewhere in a file, a conflict resolved by
348
+ * hand, or any other change in the branch's files makes the trees differ.
349
+ * False when git cannot tell (no `merge-tree --merge-base`, before 2.40).
350
+ */
351
+ function cleanRebase(
352
+ cwd: string,
353
+ range: { from: string; to: string; fromBase: string; toBase: string },
354
+ ): boolean {
355
+ if (!Object.values(range).every(safeRef)) return false;
356
+ const fromFork = git(cwd, ["merge-base", range.fromBase, range.from]);
357
+ const toFork = git(cwd, ["merge-base", range.toBase, range.to]);
358
+ if (!fromFork || !toFork || fromFork === toFork) return false;
359
+ const merged = gitRun(
360
+ cwd,
361
+ ["merge-tree", "--write-tree", "--no-messages", `--merge-base=${fromFork}`, toFork, range.from],
362
+ GIT_TIMEOUTS.worktree,
363
+ );
364
+ const tree = merged.ok ? merged.stdout.split("\n")[0]?.trim() : null;
365
+ return Boolean(tree) && tree === git(cwd, ["rev-parse", "--verify", "-q", `${range.to}^{tree}`]);
366
+ }
367
+
341
368
  // ---------------------------------------------------------------------------
342
369
  // store root (D13)
343
370
 
@@ -1124,7 +1151,9 @@ export type Reverify = { scope: "focused"; since: string; files: string[] };
1124
1151
 
1125
1152
  /** `carried (lane none since <head>)`: how a carried verdict reads in `ledger check` and `pr status`. */
1126
1153
  export const carryLabel = (carry: CarryNote): string =>
1127
- `carried (${carry.rule === "lane-none" ? "lane none" : "tests only"} since ${carry.since.slice(0, 12)})`;
1154
+ carry.rule === "rebase"
1155
+ ? "carried (rebase: patch unchanged)"
1156
+ : `carried (${carry.rule === "lane-none" ? "lane none" : "tests only"} since ${carry.since.slice(0, 12)})`;
1128
1157
 
1129
1158
  export type VerdictCheck = {
1130
1159
  branch: string;
@@ -1516,31 +1545,49 @@ export function checkVerdicts(
1516
1545
  };
1517
1546
  // The same patch-id and diff hash are not enough to carry a pass (#334):
1518
1547
  // both drop hunk line numbers, so an edit moved within a file keeps them.
1519
- // The branch's own files must be identical blobs too (sameOwnChange). A
1520
- // failure keeps the looser carry: it fails closed by applying more.
1521
- const exactCache = new Map<ReadRow, boolean>();
1522
- const exactCarry = (row: ReadRow): boolean => {
1523
- if (FAILING.has(String(row.result))) return true;
1548
+ // The branch's own files must be identical blobs too (sameOwnChange), or
1549
+ // the head must be exactly the judged head cleanly rebased onto the moved
1550
+ // base (cleanRebase): a base edit elsewhere in a file the branch touches.
1551
+ // A failure keeps the looser carry: it fails closed by applying more.
1552
+ // How a pass's own files match: identical blobs, a clean rebase, or neither.
1553
+ const exactCache = new Map<ReadRow, "same" | "rebase" | null>();
1554
+ const exactMatch = (row: ReadRow): "same" | "rebase" | null => {
1524
1555
  let exact = exactCache.get(row);
1525
1556
  if (exact === undefined) {
1526
1557
  const toBase = row.base ?? fallbackBase;
1527
1558
  const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
1528
1559
  const fromBase = row.baseSha ?? toBaseSha;
1529
- exact = Boolean(
1530
- row.head &&
1531
- head &&
1532
- toBaseSha &&
1533
- fromBase &&
1534
- sameOwnChange(cwd, { from: row.head, to: head, fromBase, toBase: toBaseSha }),
1535
- );
1560
+ const range =
1561
+ row.head && head && toBaseSha && fromBase
1562
+ ? { from: row.head, to: head, fromBase, toBase: toBaseSha }
1563
+ : null;
1564
+ exact = !range
1565
+ ? null
1566
+ : sameOwnChange(cwd, range)
1567
+ ? "same"
1568
+ : cleanRebase(cwd, range)
1569
+ ? "rebase"
1570
+ : null;
1536
1571
  exactCache.set(row, exact);
1537
1572
  }
1538
1573
  return exact;
1539
1574
  };
1575
+ const exactCarry = (row: ReadRow): boolean =>
1576
+ FAILING.has(String(row.result)) || exactMatch(row) !== null;
1540
1577
  const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
1541
1578
  const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
1542
1579
  return basis === "carried" && !exactCarry(row) ? "stale" : basis;
1543
1580
  };
1581
+ /** A pass the exact keys carry only through a clean rebase (the base also edited a branch file). */
1582
+ const rebaseNote = (row: ReadRow): CarryNote | null =>
1583
+ row.head &&
1584
+ head &&
1585
+ row.head !== head &&
1586
+ !FAILING.has(String(row.result)) &&
1587
+ keyBasis(row) === "carried" &&
1588
+ exactMatch(row) === "rebase"
1589
+ ? { rule: "rebase", since: row.head, files: [] }
1590
+ : null;
1544
1591
  const restacked = (row: ReadRow): boolean =>
1545
1592
  restackCarries(FAILING.has(String(row.result)) ? null : cwd, rows, branch, row.head, head);
1546
1593
  /** The follow-up carry of a verdict the exact keys and restacks do not carry. */
@@ -1646,7 +1693,7 @@ export function checkVerdicts(
1646
1693
  independent: independence.length === 0,
1647
1694
  accepted: reasons.length === 0,
1648
1695
  reasons,
1649
- carry: basis === "carried" ? carryOf(row) : null,
1696
+ carry: basis === "carried" ? (rebaseNote(row) ?? carryOf(row)) : null,
1650
1697
  verdict: row,
1651
1698
  });
1652
1699
  // The author's own failure never displaces an independent pass, but it