@brainervirus/workit-core 1.0.11 → 1.1.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": "1.0.11",
3
+ "version": "1.1.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": [
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { operationAutoClass, standingApprovalLive } from "./auto-approval";
4
5
  import {
5
6
  canonicalJson,
6
7
  decisionDigest,
@@ -181,12 +182,57 @@ export type ReconcileActionInput = {
181
182
 
182
183
  type Workflow = { steps: string[]; completed: string[] };
183
184
 
185
+ /** One typed step of a chain reservation. Plain strings are commit
186
+ * messages (the historical plan_steps shape); branch and pr steps carry
187
+ * their match key. Keys are the canonical consumption alphabet. */
188
+ export type ChainStep =
189
+ | { kind: "commit"; message: string }
190
+ | { kind: "branch"; target: string }
191
+ | { kind: "pr" };
192
+
193
+ export const chainStepKey = (step: ChainStep): string =>
194
+ step.kind === "commit" ? step.message : step.kind === "branch" ? `branch:${step.target}` : "pr";
195
+
196
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
197
+ typeof value === "object" && value !== null && !Array.isArray(value);
198
+
199
+ /** Normalize mixed plan steps to typed entries. Returns null for empty,
200
+ * over-long, malformed, or key-colliding lists (a commit message that
201
+ * spells exactly like a typed key is rejected rather than misrouted). */
202
+ export const normalizeChainSteps = (steps: unknown): ChainStep[] | null => {
203
+ if (!Array.isArray(steps) || steps.length === 0 || steps.length > 32) return null;
204
+ const normalized: ChainStep[] = [];
205
+ for (const step of steps) {
206
+ if (typeof step === "string") {
207
+ if (!step.trim()) return null;
208
+ normalized.push({ kind: "commit", message: step });
209
+ continue;
210
+ }
211
+ if (!isRecord(step)) return null;
212
+ if (typeof step.branch === "string" && step.branch.trim() && Object.keys(step).length === 1) {
213
+ normalized.push({ kind: "branch", target: step.branch });
214
+ continue;
215
+ }
216
+ if (step.pr === true && Object.keys(step).length === 1) {
217
+ normalized.push({ kind: "pr" });
218
+ continue;
219
+ }
220
+ return null;
221
+ }
222
+ const keys = normalized.map(chainStepKey);
223
+ if (new Set(keys).size !== keys.length) return null;
224
+ return normalized;
225
+ };
226
+
184
227
  const scopeEqual = (
185
228
  left: Decision["binding"]["scope"],
186
229
  right: Decision["binding"]["scope"],
187
230
  ): boolean => canonicalJson(left) === canonicalJson(right);
188
231
 
189
- const parseSteps = (content: string): string[] | null => {
232
+ /** Parse the durable approved text into typed chain steps. Single commits
233
+ * and branch/PR operations carry no step list (empty, not an invalid
234
+ * chain); an invalid list is null and rejected by callers. */
235
+ const parseSteps = (content: string): ChainStep[] | null => {
190
236
  let value: unknown;
191
237
  try {
192
238
  value = JSON.parse(content) as unknown;
@@ -195,7 +241,9 @@ const parseSteps = (content: string): string[] | null => {
195
241
  }
196
242
  if (typeof value !== "object" || value === null || !("steps" in value)) return [];
197
243
  const steps = (value as { steps?: unknown }).steps;
198
- return Array.isArray(steps) && steps.every((step) => typeof step === "string") ? steps : null;
244
+ if (!Array.isArray(steps)) return null;
245
+ const normalized = normalizeChainSteps(steps);
246
+ return normalized;
199
247
  };
200
248
 
201
249
  const decisionMatches = (
@@ -532,6 +580,36 @@ const validateAction = (
532
580
  !provenanceMatches(entry.provenance, authority.provenance)
533
581
  )
534
582
  return failure("permission_denied", "action approval lacks native receipt assurance");
583
+ // Standing auto-approval receipts carry no live question. They authorize
584
+ // only while the referenced workspace rule still covers the operation —
585
+ // verified here, at reserve time, against current config. Question-backed
586
+ // approvals (host-kind receipts) skip this; anything else was already
587
+ // denied above.
588
+ const receipts = entry.provenance.receipts as unknown[];
589
+ const standing = receipts.filter(
590
+ (receipt) =>
591
+ typeof receipt === "object" &&
592
+ receipt !== null &&
593
+ (receipt as { kind?: unknown }).kind === "standing",
594
+ );
595
+ const questionBacked = receipts.some(
596
+ (receipt) =>
597
+ typeof receipt === "object" &&
598
+ receipt !== null &&
599
+ (receipt as { kind?: unknown }).kind === "host",
600
+ );
601
+ if (!questionBacked && standing.length > 0) {
602
+ let operation: unknown;
603
+ try {
604
+ operation = (JSON.parse(decision.binding.approvedContent) as { operation?: unknown })
605
+ .operation;
606
+ } catch {
607
+ operation = null;
608
+ }
609
+ const cls = operationAutoClass(operation);
610
+ if (!cls || !standing.some((receipt) => standingApprovalLive(store.root, receipt, cls)))
611
+ return failure("permission_denied", "standing auto-approval is not live for this operation");
612
+ }
535
613
  if (decision.revoked) return failure("permission_denied", "decision is revoked");
536
614
  if (decision.digest !== decisionDigest(decision))
537
615
  return failure("permission_denied", "decision binding is invalid");
@@ -543,22 +621,17 @@ const validateAction = (
543
621
  if (!content.ok) return content as Result<never>;
544
622
  const requestedSteps = parseSteps(decision.binding.approvedContent);
545
623
  if (requestedSteps === null)
546
- return failure("invalid_input", "bounded action steps must be a string array");
547
- if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps))
624
+ return failure("invalid_input", "bounded action steps must be a unique non-empty array");
625
+ if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps.map(chainStepKey)))
548
626
  return failure("permission_denied", "bounded action steps do not match approved content");
549
- if (
550
- requestedSteps.some((step) => !step || typeof step !== "string") ||
551
- new Set(requestedSteps).size !== requestedSteps.length
552
- )
553
- return failure("invalid_input", "bounded action steps must be unique and non-empty");
554
627
  const stored = task.actionProgress?.find((item) => item.decisionId === input.decisionId);
555
628
  const workflow = stored
556
629
  ? { steps: stored.steps, completed: stored.completedSteps }
557
- : { steps: requestedSteps, completed: [] };
630
+ : { steps: requestedSteps.map(chainStepKey), completed: [] };
558
631
  if (
559
632
  workflow.steps.length &&
560
633
  requestedSteps.length &&
561
- canonicalJson(workflow.steps) !== canonicalJson(requestedSteps)
634
+ canonicalJson(workflow.steps) !== canonicalJson(requestedSteps.map(chainStepKey))
562
635
  )
563
636
  return failure("permission_denied", "bounded action scope changed");
564
637
  return success(null, null, { entry, workflow });
@@ -0,0 +1,326 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolveWorkspaceFrom, type WorkspaceConfig } from "./workspaces";
4
+ import { configDir } from "./config";
5
+ import { verifyPushIdentity } from "./branch";
6
+ import {
7
+ failure,
8
+ success,
9
+ type Caller,
10
+ type Id,
11
+ type Provenance,
12
+ type Result,
13
+ type Revision,
14
+ type TaskRecord,
15
+ } from "./task-contract";
16
+ import type { TaskStore } from "./task-store";
17
+ import type { WorkitCore } from "./task-engine";
18
+ import type { Decision } from "./task-contract";
19
+
20
+ /** Action classes auto-approval can cover. Unknown classes never match. */
21
+ export const AUTO_CLASSES = ["branch", "commit", "push", "pr", "merge"] as const;
22
+ export type AutoClass = (typeof AUTO_CLASSES)[number];
23
+
24
+ export type AutoApproval =
25
+ | { status: "off" }
26
+ | {
27
+ status: "on";
28
+ workspace: string;
29
+ classes: AutoClass[];
30
+ account: string | null;
31
+ configDigest: string | null;
32
+ };
33
+
34
+ export type StandingReceipt = {
35
+ kind: "standing";
36
+ workspace: string;
37
+ class: string;
38
+ configDigest: string | null;
39
+ };
40
+
41
+ const OPERATION_CLASS: Record<string, AutoClass> = {
42
+ "git.branch_setup": "branch",
43
+ "git.commit": "commit",
44
+ "git.push": "push",
45
+ "hosting.pull_request": "pr",
46
+ "hosting.merge": "merge",
47
+ };
48
+
49
+ /** Map an external-action operation to its auto-approval class, if any. */
50
+ export const operationAutoClass = (operation: unknown): AutoClass | null =>
51
+ typeof operation === "string" ? (OPERATION_CLASS[operation] ?? null) : null;
52
+
53
+ const digestOf = (configDirPath: string): string | null => {
54
+ try {
55
+ const raw = readFileSync(`${configDirPath}/workspaces.json`, "utf8");
56
+ return createHash("sha256").update(raw).digest("hex");
57
+ } catch {
58
+ return null;
59
+ }
60
+ };
61
+
62
+ const normalizeClasses = (value: unknown): AutoClass[] => {
63
+ if (value === true) return [...AUTO_CLASSES];
64
+ if (!Array.isArray(value)) return [];
65
+ const seen = new Set<AutoClass>();
66
+ for (const item of value) {
67
+ if (typeof item === "string" && (AUTO_CLASSES as readonly string[]).includes(item))
68
+ seen.add(item as AutoClass);
69
+ }
70
+ return [...seen];
71
+ };
72
+
73
+ /** Resolve the auto-approval rule for a checkout root (live read, never cached). */
74
+ export const resolveAutoApproval = (root: string, dir: string = configDir()): AutoApproval => {
75
+ let match: WorkspaceConfig | null;
76
+ try {
77
+ match = resolveWorkspaceFrom(root, dir);
78
+ } catch {
79
+ return { status: "off" };
80
+ }
81
+ if (!match) return { status: "off" };
82
+ const classes = normalizeClasses(match.autoApprove);
83
+ if (classes.length === 0) return { status: "off" };
84
+ return {
85
+ status: "on",
86
+ workspace: match.name,
87
+ classes,
88
+ account: typeof match.vcs?.account === "string" && match.vcs.account ? match.vcs.account : null,
89
+ configDigest: digestOf(dir),
90
+ };
91
+ };
92
+
93
+ /** True when the workspace rule covers the class right now. */
94
+ export const autoApproves = (root: string, cls: unknown, dir?: string): boolean => {
95
+ if (typeof cls !== "string" || !(AUTO_CLASSES as readonly string[]).includes(cls)) return false;
96
+ const resolved = resolveAutoApproval(root, dir);
97
+ return resolved.status === "on" && resolved.classes.includes(cls as AutoClass);
98
+ };
99
+
100
+ const isStandingReceipt = (value: unknown): value is StandingReceipt =>
101
+ typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === "standing";
102
+
103
+ /**
104
+ * A standing receipt authorizes only while the referenced workspace rule
105
+ * still covers the class. Removing the flag (or narrowing the list) fails
106
+ * closed on the very next reserve — no revocation step needed.
107
+ */
108
+ export const standingApprovalLive = (
109
+ root: string,
110
+ receipt: unknown,
111
+ cls: unknown,
112
+ dir?: string,
113
+ ): boolean => {
114
+ if (!isStandingReceipt(receipt)) return false;
115
+ if (typeof cls !== "string" || !(AUTO_CLASSES as readonly string[]).includes(cls)) return false;
116
+ if (receipt.class !== cls) return false;
117
+ const resolved = resolveAutoApproval(root, dir);
118
+ if (resolved.status !== "on") return false;
119
+ if (resolved.workspace !== receipt.workspace) return false;
120
+ return resolved.classes.includes(cls as AutoClass);
121
+ };
122
+
123
+ /** Build the receipt an auto-recorded decision carries (current digest). */
124
+ export const standingReceiptFor = (
125
+ root: string,
126
+ workspace: string,
127
+ cls: AutoClass,
128
+ dir?: string,
129
+ ): StandingReceipt => {
130
+ const resolved = resolveAutoApproval(root, dir);
131
+ return {
132
+ kind: "standing",
133
+ workspace,
134
+ class: cls,
135
+ configDigest:
136
+ resolved.status === "on" && resolved.workspace === workspace ? resolved.configDigest : null,
137
+ };
138
+ };
139
+
140
+ /**
141
+ * Verify a standing approval record request: active task, lead session
142
+ * match (helpers never auto-approve), operation class equals the claimed
143
+ * class, rule live right now, push identity enforced for push.
144
+ * Returns the host-observed provenance the entry carries on success.
145
+ */
146
+ export const verifyStandingApproval = (
147
+ root: string,
148
+ task: TaskRecord,
149
+ caller: Caller,
150
+ binding: {
151
+ standing?: { workspace?: unknown; class?: unknown };
152
+ approvedContent?: unknown;
153
+ },
154
+ ): Result<Provenance> => {
155
+ if (task.status !== "active")
156
+ return failure("invalid_transition", "only active tasks can authorize actions");
157
+ const session = task.intent?.provenance?.session;
158
+ if (
159
+ !session ||
160
+ session.kind !== "host" ||
161
+ session.host !== caller.host ||
162
+ session.handle !== caller.actor
163
+ )
164
+ return failure("permission_denied", "standing approval requires the lead session");
165
+ const standing = binding.standing;
166
+ if (!standing || typeof standing.workspace !== "string" || typeof standing.class !== "string")
167
+ return failure("invalid_input", "standing approval needs a workspace and class");
168
+ let operation: unknown;
169
+ try {
170
+ operation =
171
+ typeof binding.approvedContent === "string"
172
+ ? (JSON.parse(binding.approvedContent) as { operation?: unknown }).operation
173
+ : null;
174
+ } catch {
175
+ operation = null;
176
+ }
177
+ const cls = operationAutoClass(operation);
178
+ if (!cls || cls !== standing.class)
179
+ return failure("permission_denied", "standing approval does not cover this operation");
180
+ const resolved = resolveAutoApproval(root);
181
+ if (
182
+ resolved.status !== "on" ||
183
+ resolved.workspace !== standing.workspace ||
184
+ !resolved.classes.includes(cls)
185
+ )
186
+ return failure("permission_denied", "standing auto-approval is not live for this operation");
187
+ if (cls === "push") {
188
+ const workspace = resolveWorkspaceFrom(root, configDir());
189
+ const provider = workspace?.vcs?.provider;
190
+ const account = resolved.account;
191
+ if (!provider || !account)
192
+ return failure(
193
+ "permission_denied",
194
+ "auto-push requires a workspace provider and area account",
195
+ );
196
+ const identity = verifyPushIdentity(root, provider, account);
197
+ if (!identity.ok) return failure("permission_denied", identity.error);
198
+ }
199
+ return success(null, null, {
200
+ kind: "host_observed",
201
+ host: caller.host,
202
+ session: { kind: "host", host: caller.host, handle: caller.actor },
203
+ workerId: null,
204
+ receipts: [standingReceiptFor(root, standing.workspace, cls)],
205
+ } as Provenance);
206
+ };
207
+
208
+ export type StandingAutoBinding = {
209
+ taskId: Id;
210
+ decisionId: Id;
211
+ binding: Decision["binding"];
212
+ expectedRevision: Revision;
213
+ expectedWorkspaceRevision: Revision;
214
+ step?: string;
215
+ };
216
+
217
+ type StandingMatch = {
218
+ task: TaskRecord;
219
+ workspaceId: Id;
220
+ workspaceRevision: Revision;
221
+ cls: AutoClass;
222
+ workspaceName: string;
223
+ };
224
+
225
+ /**
226
+ * Dry-run half of standingAutoBinding: same matching, no recording. Tool
227
+ * gates use this to decide between executing and asking; the runner records.
228
+ */
229
+ export const standingAutoApplies = (
230
+ store: TaskStore,
231
+ host: string,
232
+ actor: string,
233
+ operation: string,
234
+ ): StandingMatch | null => {
235
+ let descriptor: { operation?: unknown };
236
+ try {
237
+ descriptor = JSON.parse(operation) as { operation?: unknown };
238
+ } catch {
239
+ return null;
240
+ }
241
+ const cls = operationAutoClass(descriptor.operation);
242
+ if (!cls || !autoApproves(store.root, cls)) return null;
243
+ const listed = store.listTasks();
244
+ const workspace = store.readWorkspace();
245
+ if (!listed.ok || !workspace.ok || !workspace.data) return null;
246
+ const matches = listed.data.filter(
247
+ (task) =>
248
+ task.status === "active" &&
249
+ task.workspaceId === workspace.data!.id &&
250
+ task.intent?.provenance?.session?.kind === "host" &&
251
+ (task.intent.provenance.session as { host?: unknown; handle?: unknown }).host === host &&
252
+ (task.intent.provenance.session as { host?: unknown; handle?: unknown }).handle === actor,
253
+ );
254
+ if (matches.length !== 1) return null;
255
+ const resolved = resolveAutoApproval(store.root);
256
+ if (resolved.status !== "on") return null;
257
+ return {
258
+ task: matches[0],
259
+ workspaceId: workspace.data.id,
260
+ workspaceRevision: workspace.data.revision,
261
+ cls,
262
+ workspaceName: resolved.workspace,
263
+ };
264
+ };
265
+
266
+ /**
267
+ * Shared adapter path: resolve one gated operation under the workspace
268
+ * standing rule without asking. Returns the exact binding the runner needs
269
+ * for reserve/settle, or null when anything is uncovered, ambiguous, or
270
+ * unresolvable — callers fall through to the question proposal.
271
+ *
272
+ * Single active task per session (ambiguity asks), plan-before-mutation
273
+ * (no task asks), guardrails enforced at record time, revocation instant.
274
+ */
275
+ export const standingAutoBinding = (
276
+ core: WorkitCore,
277
+ store: TaskStore,
278
+ host: string,
279
+ actor: string,
280
+ operation: string,
281
+ ): StandingAutoBinding | null => {
282
+ let descriptor: { operation?: unknown };
283
+ try {
284
+ descriptor = JSON.parse(operation) as { operation?: unknown };
285
+ } catch {
286
+ return null;
287
+ }
288
+ const cls = operationAutoClass(descriptor.operation);
289
+ if (!cls || !autoApproves(store.root, cls)) return null;
290
+ const applies = standingAutoApplies(store, host, actor, operation);
291
+ if (!applies) return null;
292
+ const { task } = applies;
293
+ const resolved = resolveAutoApproval(store.root);
294
+ if (resolved.status !== "on") return null;
295
+ const presented =
296
+ `Workit decision: action — auto-approved ${String(descriptor.operation)} ` +
297
+ `under workspace rule "${resolved.workspace}" (no question asked).`;
298
+ const recorded = core.observeStandingDecision({
299
+ schemaVersion: 1,
300
+ action: "record",
301
+ taskId: task.id,
302
+ expectedRevision: task.revision,
303
+ purpose: "action",
304
+ binding: {
305
+ taskId: task.id,
306
+ workspaceId: applies.workspaceId,
307
+ scope: task.intent.data.scope,
308
+ presented,
309
+ approvedContent: operation,
310
+ contentRefs: [],
311
+ standing: { workspace: resolved.workspace, class: cls },
312
+ },
313
+ response: "approved",
314
+ requirementIds: [],
315
+ });
316
+ if (!recorded.ok) return null;
317
+ const fresh = store.readTask(task.id);
318
+ if (!fresh.ok) return null;
319
+ return {
320
+ taskId: task.id,
321
+ decisionId: recorded.data.id,
322
+ binding: recorded.data.data.binding,
323
+ expectedRevision: fresh.data.revision,
324
+ expectedWorkspaceRevision: applies.workspaceRevision,
325
+ };
326
+ };
@@ -18,6 +18,50 @@ const policy = (root: string) => resolveBranchPolicyFor(root);
18
18
  const allowedBranch = (root: string, name: string) =>
19
19
  policy(root).allowed.some((r) => r.test(name));
20
20
  const isProtected = (root: string, name: string) => policy(root).protected.has(name.toLowerCase());
21
+
22
+ /** Public guardrail: protected refs fail closed anywhere, never ask. */
23
+ export const isProtectedTarget = (root: string, name: string): boolean => {
24
+ try {
25
+ return isProtected(root, name);
26
+ } catch {
27
+ return true;
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Push identity check as code: the credential actually used must belong to
33
+ * the workspace area account. Anything unresolvable fails closed — an
34
+ * unreachable host never reads as a match.
35
+ */
36
+ export const verifyPushIdentity = (
37
+ root: string,
38
+ provider: string,
39
+ account: string,
40
+ ): { ok: true } | { ok: false; error: string } => {
41
+ if (!account || typeof account !== "string")
42
+ return { ok: false, error: "push identity requires a configured area account" };
43
+ const probe =
44
+ provider === "gitlab"
45
+ ? (["api", "user", "--jq", ".username"] as const)
46
+ : (["api", "user", "--jq", ".login"] as const);
47
+ const bin = provider === "gitlab" ? "glab" : "gh";
48
+ let login: string;
49
+ try {
50
+ login = execFileSync(bin, [...probe], {
51
+ cwd: root,
52
+ encoding: "utf8",
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ }).trim();
55
+ } catch {
56
+ return { ok: false, error: `${bin} identity could not be resolved` };
57
+ }
58
+ if (!login || login.toLowerCase() !== account.toLowerCase())
59
+ return {
60
+ ok: false,
61
+ error: `push identity ${login || "(unknown)"} does not match area account ${account}`,
62
+ };
63
+ return { ok: true };
64
+ };
21
65
  // RL-01: malformed vcs.json blocks branch resolution with an exact-path error.
22
66
  const baseBranch = (cwd: string): { base: string } | { error: string } => {
23
67
  const resolved = vcsConfig("resolve", cwd);
@@ -281,6 +325,43 @@ export type BranchSetupResult =
281
325
  | { error: string; phase: "preflight" | "post_mutation" };
282
326
 
283
327
  // Port of scripts/branch/setup-branch.sh
328
+ export type BranchDirt = "clean" | "carry" | "stash-required";
329
+
330
+ /**
331
+ * Classify working-tree dirt for branch setup. Untracked files ride along
332
+ * (`git switch -c` never touches them), and tracked modifications confined
333
+ * to `docs/` are branch-owned by construction in this workflow — both
334
+ * auto-carry with no stash question. Anything else keeps the stash-bound
335
+ * proposal, since the base-checkout sequence could disturb it. Any
336
+ * inspection failure reads as clean so a broken git surfaces at its own
337
+ * error instead of inventing a stash demand.
338
+ */
339
+ export const classifyBranchDirt = (workspaceRoot: string): BranchDirt => {
340
+ let raw: string;
341
+ try {
342
+ raw = execFileSync("git", ["status", "--porcelain=v1"], {
343
+ cwd: path.resolve(workspaceRoot),
344
+ encoding: "utf8",
345
+ stdio: ["pipe", "pipe", "pipe"],
346
+ });
347
+ } catch {
348
+ return "clean";
349
+ }
350
+ let dirty = false;
351
+ for (const line of raw.split("\n")) {
352
+ if (!line.trim()) continue;
353
+ dirty = true;
354
+ const code = line.slice(0, 2);
355
+ if (code === "??") continue;
356
+ let entry = line.slice(3);
357
+ const arrow = entry.indexOf(" -> ");
358
+ if (arrow >= 0) entry = entry.slice(arrow + 4);
359
+ const unquoted = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry;
360
+ if (unquoted !== "docs" && !unquoted.startsWith("docs/")) return "stash-required";
361
+ }
362
+ return dirty ? "carry" : "clean";
363
+ };
364
+
284
365
  export const branchSetup = ({
285
366
  action,
286
367
  sdd_dir,
@@ -398,7 +479,7 @@ export const branchSetup = ({
398
479
  };
399
480
  if (current !== target) {
400
481
  const dirty = Boolean(gitContext(cwd).status_short.trim());
401
- if (dirty && stash !== "yes") {
482
+ if (classifyBranchDirt(cwd) === "stash-required" && stash !== "yes") {
402
483
  return {
403
484
  error:
404
485
  "dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
@@ -416,7 +497,7 @@ export const branchSetup = ({
416
497
  return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
417
498
  validatedBase = base;
418
499
  }
419
- if (dirty) {
500
+ if (dirty && stash === "yes") {
420
501
  try {
421
502
  exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
422
503
  } catch (error) {
@@ -22,6 +22,7 @@ import {
22
22
  import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { SUPPORT_MATRIX } from "./support-matrix";
25
+ import { bundleHashOfFile, isEphemeralCachePath } from "./runtime-identity";
25
26
  import { EVENT } from "./boundary";
26
27
  import { getDiagnosticLogger, isConfigObject } from "./config";
27
28
  import { packageRoot } from "./package-root";
@@ -682,12 +683,7 @@ const checkUtility = (res: Resolved): DoctorCheck => {
682
683
  const isFragileCachePin = (entry: string): boolean => {
683
684
  const n = entry.replaceAll("\\", "/").toLowerCase();
684
685
  if (!n.startsWith("file:")) return false;
685
- return (
686
- n.includes("/caches/pnpm/dlx/") ||
687
- n.includes("/_npx/") ||
688
- n.includes("/.pacquet/") ||
689
- n.includes("/library/caches/pnpm/")
690
- );
686
+ return isEphemeralCachePath(entry);
691
687
  };
692
688
 
693
689
  const staleEntry = (entry: string): "ok" | "stale" | "missing-file" | "fragile-cache" => {
@@ -936,12 +932,7 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
936
932
  } catch {
937
933
  target = "(unreadable)";
938
934
  }
939
- const n = target.replaceAll("\\", "/").toLowerCase();
940
- const fragile =
941
- n.includes("/caches/pnpm/dlx/") ||
942
- n.includes("/_npx/") ||
943
- n.includes("/.pacquet/") ||
944
- n.includes("/library/caches/pnpm/");
935
+ const fragile = isEphemeralCachePath(target);
945
936
  return {
946
937
  id: "stale_install",
947
938
  status: "fail",
@@ -1013,6 +1004,28 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
1013
1004
  fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory and rewrites the workit MCP/hook entries",
1014
1005
  };
1015
1006
  }
1007
+ // Same version, different bytes: the install was hand-edited or built from
1008
+ // a dirty tree. Version strings cannot see that; the bundle hash can. The
1009
+ // dev dist is byte-deterministic, so a mismatch heals with reinstall.
1010
+ if (localDist && installed !== null && source !== null && res.dev) {
1011
+ const entry = /^node\s+(.+)$/.exec((hookCmd ?? "").trim())?.[1]?.trim() ?? "";
1012
+ const sourceBundle =
1013
+ entry && path.isAbsolute(entry)
1014
+ ? path.join(res.dev, "packages", "workit-cursor", "dist", path.basename(entry))
1015
+ : "";
1016
+ if (entry && sourceBundle) {
1017
+ const installedHash = bundleHashOfFile(entry);
1018
+ const sourceHash = bundleHashOfFile(sourceBundle);
1019
+ if (installedHash !== null && sourceHash !== null && installedHash !== sourceHash) {
1020
+ return {
1021
+ id: "stale_install",
1022
+ status: "fail",
1023
+ detail: `stale_install: installed workit-cursor ${installed} runs different bytes than the current runtime build (${path.basename(entry)} hash mismatch)`,
1024
+ fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory with the current build",
1025
+ };
1026
+ }
1027
+ }
1028
+ }
1016
1029
  if (installed !== null && localDist) {
1017
1030
  const expected = registryLatestVersion(res);
1018
1031
  if (expected === null) {