@brainervirus/workit-core 1.0.10 → 1.0.12

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.10",
3
+ "version": "1.0.12",
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": [
@@ -181,12 +181,57 @@ export type ReconcileActionInput = {
181
181
 
182
182
  type Workflow = { steps: string[]; completed: string[] };
183
183
 
184
+ /** One typed step of a chain reservation. Plain strings are commit
185
+ * messages (the historical plan_steps shape); branch and pr steps carry
186
+ * their match key. Keys are the canonical consumption alphabet. */
187
+ export type ChainStep =
188
+ | { kind: "commit"; message: string }
189
+ | { kind: "branch"; target: string }
190
+ | { kind: "pr" };
191
+
192
+ export const chainStepKey = (step: ChainStep): string =>
193
+ step.kind === "commit" ? step.message : step.kind === "branch" ? `branch:${step.target}` : "pr";
194
+
195
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
196
+ typeof value === "object" && value !== null && !Array.isArray(value);
197
+
198
+ /** Normalize mixed plan steps to typed entries. Returns null for empty,
199
+ * over-long, malformed, or key-colliding lists (a commit message that
200
+ * spells exactly like a typed key is rejected rather than misrouted). */
201
+ export const normalizeChainSteps = (steps: unknown): ChainStep[] | null => {
202
+ if (!Array.isArray(steps) || steps.length === 0 || steps.length > 32) return null;
203
+ const normalized: ChainStep[] = [];
204
+ for (const step of steps) {
205
+ if (typeof step === "string") {
206
+ if (!step.trim()) return null;
207
+ normalized.push({ kind: "commit", message: step });
208
+ continue;
209
+ }
210
+ if (!isRecord(step)) return null;
211
+ if (typeof step.branch === "string" && step.branch.trim() && Object.keys(step).length === 1) {
212
+ normalized.push({ kind: "branch", target: step.branch });
213
+ continue;
214
+ }
215
+ if (step.pr === true && Object.keys(step).length === 1) {
216
+ normalized.push({ kind: "pr" });
217
+ continue;
218
+ }
219
+ return null;
220
+ }
221
+ const keys = normalized.map(chainStepKey);
222
+ if (new Set(keys).size !== keys.length) return null;
223
+ return normalized;
224
+ };
225
+
184
226
  const scopeEqual = (
185
227
  left: Decision["binding"]["scope"],
186
228
  right: Decision["binding"]["scope"],
187
229
  ): boolean => canonicalJson(left) === canonicalJson(right);
188
230
 
189
- const parseSteps = (content: string): string[] | null => {
231
+ /** Parse the durable approved text into typed chain steps. Single commits
232
+ * and branch/PR operations carry no step list (empty, not an invalid
233
+ * chain); an invalid list is null and rejected by callers. */
234
+ const parseSteps = (content: string): ChainStep[] | null => {
190
235
  let value: unknown;
191
236
  try {
192
237
  value = JSON.parse(content) as unknown;
@@ -195,7 +240,9 @@ const parseSteps = (content: string): string[] | null => {
195
240
  }
196
241
  if (typeof value !== "object" || value === null || !("steps" in value)) return [];
197
242
  const steps = (value as { steps?: unknown }).steps;
198
- return Array.isArray(steps) && steps.every((step) => typeof step === "string") ? steps : null;
243
+ if (!Array.isArray(steps)) return null;
244
+ const normalized = normalizeChainSteps(steps);
245
+ return normalized;
199
246
  };
200
247
 
201
248
  const decisionMatches = (
@@ -543,22 +590,17 @@ const validateAction = (
543
590
  if (!content.ok) return content as Result<never>;
544
591
  const requestedSteps = parseSteps(decision.binding.approvedContent);
545
592
  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))
593
+ return failure("invalid_input", "bounded action steps must be a unique non-empty array");
594
+ if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps.map(chainStepKey)))
548
595
  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
596
  const stored = task.actionProgress?.find((item) => item.decisionId === input.decisionId);
555
597
  const workflow = stored
556
598
  ? { steps: stored.steps, completed: stored.completedSteps }
557
- : { steps: requestedSteps, completed: [] };
599
+ : { steps: requestedSteps.map(chainStepKey), completed: [] };
558
600
  if (
559
601
  workflow.steps.length &&
560
602
  requestedSteps.length &&
561
- canonicalJson(workflow.steps) !== canonicalJson(requestedSteps)
603
+ canonicalJson(workflow.steps) !== canonicalJson(requestedSteps.map(chainStepKey))
562
604
  )
563
605
  return failure("permission_denied", "bounded action scope changed");
564
606
  return success(null, null, { entry, workflow });
@@ -281,6 +281,43 @@ export type BranchSetupResult =
281
281
  | { error: string; phase: "preflight" | "post_mutation" };
282
282
 
283
283
  // Port of scripts/branch/setup-branch.sh
284
+ export type BranchDirt = "clean" | "carry" | "stash-required";
285
+
286
+ /**
287
+ * Classify working-tree dirt for branch setup. Untracked files ride along
288
+ * (`git switch -c` never touches them), and tracked modifications confined
289
+ * to `docs/` are branch-owned by construction in this workflow — both
290
+ * auto-carry with no stash question. Anything else keeps the stash-bound
291
+ * proposal, since the base-checkout sequence could disturb it. Any
292
+ * inspection failure reads as clean so a broken git surfaces at its own
293
+ * error instead of inventing a stash demand.
294
+ */
295
+ export const classifyBranchDirt = (workspaceRoot: string): BranchDirt => {
296
+ let raw: string;
297
+ try {
298
+ raw = execFileSync("git", ["status", "--porcelain=v1"], {
299
+ cwd: path.resolve(workspaceRoot),
300
+ encoding: "utf8",
301
+ stdio: ["pipe", "pipe", "pipe"],
302
+ });
303
+ } catch {
304
+ return "clean";
305
+ }
306
+ let dirty = false;
307
+ for (const line of raw.split("\n")) {
308
+ if (!line.trim()) continue;
309
+ dirty = true;
310
+ const code = line.slice(0, 2);
311
+ if (code === "??") continue;
312
+ let entry = line.slice(3);
313
+ const arrow = entry.indexOf(" -> ");
314
+ if (arrow >= 0) entry = entry.slice(arrow + 4);
315
+ const unquoted = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry;
316
+ if (unquoted !== "docs" && !unquoted.startsWith("docs/")) return "stash-required";
317
+ }
318
+ return dirty ? "carry" : "clean";
319
+ };
320
+
284
321
  export const branchSetup = ({
285
322
  action,
286
323
  sdd_dir,
@@ -398,7 +435,7 @@ export const branchSetup = ({
398
435
  };
399
436
  if (current !== target) {
400
437
  const dirty = Boolean(gitContext(cwd).status_short.trim());
401
- if (dirty && stash !== "yes") {
438
+ if (classifyBranchDirt(cwd) === "stash-required" && stash !== "yes") {
402
439
  return {
403
440
  error:
404
441
  "dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
@@ -416,7 +453,7 @@ export const branchSetup = ({
416
453
  return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
417
454
  validatedBase = base;
418
455
  }
419
- if (dirty) {
456
+ if (dirty && stash === "yes") {
420
457
  try {
421
458
  exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
422
459
  } 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) {
@@ -1,4 +1,9 @@
1
- import { branchSetup, resolveBranchPolicyFor, resolveCommitPolicyFor } from "./branch";
1
+ import {
2
+ branchSetup,
3
+ classifyBranchDirt,
4
+ resolveBranchPolicyFor,
5
+ resolveCommitPolicyFor,
6
+ } from "./branch";
2
7
  import { hostingCliAvailable, prCreate } from "./pr-create";
3
8
  import {
4
9
  changelogContext,
@@ -24,6 +29,7 @@ import { failure, success, sha256, type Caller, type Result, type Ref } from "./
24
29
  import { changelogApply, changelogApplyPreview } from "./changelog";
25
30
  import type { ExternalActionRequest } from "./external-action";
26
31
  import { externalActionDescriptor, externalActionRequest } from "./external-action";
32
+ import { normalizeChainSteps } from "./authority";
27
33
  import { resolveInside, run as coreRun } from "../core";
28
34
  import { vcsConfig } from "./vcs-config";
29
35
  import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
@@ -271,10 +277,33 @@ export const actionProposalQuestion = (
271
277
  const resolved = (payload.resolved ?? {}) as Record<string, unknown>;
272
278
  const payloadTarget = String(payload.target_branch ?? "");
273
279
  if (request.operation === "git.commit" && Array.isArray(payload.plan_steps)) {
274
- const steps = payload.plan_steps as string[];
280
+ const steps = payload.plan_steps as unknown[];
281
+ const chain = normalizeChainSteps(steps);
282
+ if (!chain)
283
+ return {
284
+ presented: `Workit decision: action — Approve committing the plan's listed tasks?`,
285
+ approvedText: `Approve the listed plan commits.`,
286
+ };
287
+ const kinds = chain.map((step) => step.kind);
288
+ if (kinds.every((kind) => kind === "commit"))
289
+ return {
290
+ presented: `Workit decision: action — Approve committing the plan's ${steps.length} listed tasks on \`${String(payload.plan_branch ?? "")}\`?`,
291
+ approvedText: `Approve the ${steps.length} listed plan commits.`,
292
+ };
293
+ const summary = [
294
+ kinds.filter((kind) => kind === "branch").length
295
+ ? `${kinds.filter((kind) => kind === "branch").length} branch`
296
+ : null,
297
+ kinds.filter((kind) => kind === "commit").length
298
+ ? `${kinds.filter((kind) => kind === "commit").length} commits`
299
+ : null,
300
+ kinds.includes("pr") ? "PR" : null,
301
+ ]
302
+ .filter(Boolean)
303
+ .join(", ");
275
304
  return {
276
- presented: `Workit decision: action — Approve committing the plan's ${steps.length} listed tasks on \`${String(payload.plan_branch ?? "")}\`?`,
277
- approvedText: `Approve the ${steps.length} listed plan commits.`,
305
+ presented: `Workit decision: action — Approve the ${steps.length}-step chain on \`${String(payload.plan_branch ?? "")}\` (${summary})?`,
306
+ approvedText: `Approve the ${steps.length}-step chain (${summary}).`,
278
307
  };
279
308
  }
280
309
  switch (request.operation) {
@@ -286,16 +315,29 @@ export const actionProposalQuestion = (
286
315
  approvedText: "Reapply the recorded stash.",
287
316
  };
288
317
  const target = String(payload.target_branch ?? "");
318
+ // A dirty tree binds stash: the approval carries the stash through,
319
+ // so the question names it instead of failing later at preflight.
320
+ const stashes = payload.stash === "yes" && resolved.dirty === true;
289
321
  if (resolved.target_exists === true)
290
- return {
291
- presented: `Workit decision: action — Switch to branch \`${target}\`?`,
292
- approvedText: `Switch to \`${target}\`.`,
293
- };
322
+ return stashes
323
+ ? {
324
+ presented: `Workit decision: action — Stash the dirty working tree and switch to branch \`${target}\`?`,
325
+ approvedText: `Stash and switch to \`${target}\`.`,
326
+ }
327
+ : {
328
+ presented: `Workit decision: action — Switch to branch \`${target}\`?`,
329
+ approvedText: `Switch to \`${target}\`.`,
330
+ };
294
331
  const base = String(resolved.base_branch ?? "the base branch");
295
- return {
296
- presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
297
- approvedText: `Create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
298
- };
332
+ return stashes
333
+ ? {
334
+ presented: `Workit decision: action — Stash the dirty working tree and create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
335
+ approvedText: `Stash and create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
336
+ }
337
+ : {
338
+ presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
339
+ approvedText: `Create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
340
+ };
299
341
  }
300
342
  case "git.commit": {
301
343
  const paths = Array.isArray(resolved.paths) ? (resolved.paths as string[]) : [];
@@ -793,6 +835,32 @@ const youTrackWritePreflight = (): Result<null> => {
793
835
  return success(null, null, null);
794
836
  };
795
837
 
838
+ /**
839
+ * A dirty tree binds stash up front so the proposal names it and one
840
+ * approval carries the whole effect instead of failing at preflight.
841
+ * Carry-class dirt (untracked or docs-confined) rides along silently.
842
+ * Returns the original resolution untouched unless an upgrade applies.
843
+ */
844
+ export const upgradeBranchSetupForStash = (
845
+ root: string,
846
+ parsed: ExternalActionRequest,
847
+ resolved: Result<ResolvedExternalAction>,
848
+ ): Result<ResolvedExternalAction> => {
849
+ if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
850
+ if (
851
+ (resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
852
+ true ||
853
+ (resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
854
+ classifyBranchDirt(root) !== "stash-required"
855
+ )
856
+ return resolved;
857
+ const upgraded = resolveExternalActionRequest(root, {
858
+ ...parsed,
859
+ payload: { ...(parsed.payload as Record<string, unknown>), stash: "yes" },
860
+ } as typeof parsed);
861
+ return upgraded.ok ? upgraded : resolved;
862
+ };
863
+
796
864
  export const resolveExternalActionRequest = (
797
865
  root: string,
798
866
  request: ExternalActionRequest,
@@ -859,26 +927,41 @@ export const resolveExternalActionRequest = (
859
927
  if (!head) return failure("storage_error", "Git state could not be resolved");
860
928
  const branch = gitValue(root, ["branch", "--show-current"]);
861
929
  if (planSteps !== undefined || planBranch !== undefined) {
862
- if (
863
- !Array.isArray(planSteps) ||
864
- planSteps.some((step) => typeof step !== "string" || !step.trim()) ||
865
- new Set(planSteps).size !== planSteps.length ||
866
- planSteps.length === 0 ||
867
- planSteps.length > 32
868
- )
930
+ const chain = Array.isArray(planSteps) ? normalizeChainSteps(planSteps) : null;
931
+ if (!chain)
869
932
  return failure(
870
933
  "invalid_input",
871
- "plan_steps must be 1-32 unique non-empty commit messages",
934
+ "plan_steps must be 1-32 unique non-empty commit messages, branch targets, or pull_request markers",
872
935
  {
873
936
  outcome: "not_started",
874
- fields: [{ path: "plan_steps", reason: "unique strings" }],
937
+ fields: [{ path: "plan_steps", reason: "unique steps" }],
875
938
  },
876
939
  );
877
- if (typeof planBranch !== "string" || !planBranch.trim() || planBranch !== branch)
878
- return failure("invalid_input", "plan_branch must name the current working branch", {
940
+ if (typeof planBranch !== "string" || !planBranch.trim())
941
+ return failure("invalid_input", "plan_branch must name the working branch", {
879
942
  outcome: "not_started",
880
- fields: [{ path: "plan_branch", reason: "current branch" }],
943
+ fields: [{ path: "plan_branch", reason: "required" }],
881
944
  });
945
+ // A chain that creates its own branch names that branch; otherwise
946
+ // the plan runs on the branch already checked out.
947
+ const first = chain[0];
948
+ const createsBranch = first?.kind === "branch";
949
+ if (createsBranch ? planBranch !== first.target : planBranch !== branch)
950
+ return failure(
951
+ "invalid_input",
952
+ createsBranch
953
+ ? "plan_branch must name the branch the chain creates"
954
+ : "plan_branch must name the current working branch",
955
+ {
956
+ outcome: "not_started",
957
+ fields: [
958
+ {
959
+ path: "plan_branch",
960
+ reason: createsBranch ? "chain branch" : "current branch",
961
+ },
962
+ ],
963
+ },
964
+ );
882
965
  if (isProtectedBranch(root, planBranch))
883
966
  return failure("invalid_input", "plan_branch must not be a protected branch", {
884
967
  outcome: "not_started",