opencode-workflow-guard 1.15.0 → 1.15.2

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.
@@ -193,16 +193,114 @@ export function prBodyHasLiteralLineBreakEscapes(command: string): boolean {
193
193
  return false;
194
194
  }
195
195
 
196
+ /**
197
+ * Top-level package.json sections whose change requires a matching lockfile
198
+ * update: the dependency maps (including peerDependenciesMeta) plus every
199
+ * other section a supported package manager records in its lockfile
200
+ * (engines/packageManager, overrides/resolutions, the pnpm section with
201
+ * pnpm.overrides and pnpm.patchedDependencies, the workspace graph, and the
202
+ * project name/version stored in the lockfile root). A scripts-only or
203
+ * metadata-only manifest edit regenerates the lockfile byte-identical —
204
+ * demanding a lockfile change for it is unsatisfiable and must not block PR
205
+ * creation.
206
+ */
207
+ const LOCKFILE_RELEVANT_MANIFEST_KEYS = new Set([
208
+ "name",
209
+ "version",
210
+ "dependencies",
211
+ "devDependencies",
212
+ "optionalDependencies",
213
+ "peerDependencies",
214
+ "peerDependenciesMeta",
215
+ "engines",
216
+ "packageManager",
217
+ "overrides",
218
+ "resolutions",
219
+ "workspaces",
220
+ // pnpm-specific top-level section: pnpm.overrides and
221
+ // pnpm.patchedDependencies are recorded in pnpm-lock.yaml.
222
+ "pnpm",
223
+ ]);
224
+
225
+ /** Order-insensitive JSON comparison: dependency maps are semantically
226
+ * unordered, so reordering keys alone is not a lockfile-relevant change. */
227
+ function canonicalJsonValue(value: unknown): string {
228
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
229
+ if (Array.isArray(value)) return `[${value.map(canonicalJsonValue).join(",")}]`;
230
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
231
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJsonValue(item)}`).join(",")}}`;
232
+ }
233
+
234
+ function manifestTouchesLockfileRelevantKeys(oldSource: string | undefined, newSource: string | undefined): boolean {
235
+ try {
236
+ const parse = (source: string | undefined): Record<string, unknown> | undefined => {
237
+ if (source === undefined) return undefined;
238
+ const parsed = JSON.parse(source) as unknown;
239
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not a manifest object");
240
+ return parsed as Record<string, unknown>;
241
+ };
242
+ const oldManifest = parse(oldSource);
243
+ const newManifest = parse(newSource);
244
+ const keys = new Set([...Object.keys(oldManifest ?? {}), ...Object.keys(newManifest ?? {})]);
245
+ for (const key of keys) {
246
+ if (!LOCKFILE_RELEVANT_MANIFEST_KEYS.has(key)) continue;
247
+ if (canonicalJsonValue(oldManifest?.[key]) !== canonicalJsonValue(newManifest?.[key])) return true;
248
+ }
249
+ return false;
250
+ } catch {
251
+ return true; // unreadable manifest: fail closed and keep demanding the lockfile
252
+ }
253
+ }
254
+
255
+ function manifestSourceAt(root: string, revision: string, manifestPath: string): string | undefined {
256
+ const res = spawnSync("git", ["show", `${revision}:${manifestPath}`], { cwd: root, encoding: "utf8", timeout: 10_000 });
257
+ if (res.status !== 0) return undefined;
258
+ return res.stdout;
259
+ }
260
+
261
+ function workingTreeManifestSource(root: string, manifestPath: string): string | undefined {
262
+ try {
263
+ return readFileSync(resolve(root, manifestPath), "utf8");
264
+ } catch {
265
+ return undefined; // deleted from the working tree
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Whether a manifest change demands a lockfile update. package.json is
271
+ * compared section-by-section (scripts-only edits pass); Cargo.toml and
272
+ * go.mod have no dependency-section parser here, so any change to them
273
+ * conservatively demands the lockfile (unchanged behavior).
274
+ */
275
+ function manifestChangeRequiresLockfile(root: string, mergeBaseCommit: string | undefined, manifestPath: string): boolean {
276
+ if (!/(?:^|\/)package\.json$/.test(manifestPath)) return true;
277
+ let oldSource: string | undefined;
278
+ if (mergeBaseCommit) {
279
+ // Absent at the merge base means the manifest was introduced on this
280
+ // branch (or is untracked): the pre-branch state had no manifest, so
281
+ // every lockfile-relevant key it has counts as new.
282
+ oldSource = manifestSourceAt(root, mergeBaseCommit, manifestPath);
283
+ } else {
284
+ oldSource = manifestSourceAt(root, "HEAD", manifestPath);
285
+ }
286
+ const newSource = workingTreeManifestSource(root, manifestPath);
287
+ if (oldSource === undefined && newSource === undefined) return true;
288
+ return manifestTouchesLockfileRelevantKeys(oldSource, newSource);
289
+ }
290
+
196
291
  export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifest?: string; lockfile?: string; reason?: string } {
197
292
  try {
198
293
  let modifiedFiles: string[] = [];
294
+ let mergeBaseCommit: string | undefined;
199
295
  const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
200
296
  for (const base of baseCandidates) {
201
297
  const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], { cwd: root, encoding: "utf8", timeout: 10_000 });
202
298
  if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
203
- const diff = spawnSync("git", ["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
299
+ const baseCommit = mergeBase.stdout.trim();
300
+ const diff = spawnSync("git", ["diff", "--name-only", `${baseCommit}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
204
301
  if (diff.status === 0 && diff.stdout.trim()) {
205
302
  modifiedFiles = diff.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
303
+ mergeBaseCommit = baseCommit;
206
304
  break;
207
305
  }
208
306
  }
@@ -236,16 +334,19 @@ export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifes
236
334
  ];
237
335
 
238
336
  for (const item of manifestToLockfiles) {
239
- const hasManifest = modifiedFiles.some((f) => item.manifest.test(f));
240
- if (hasManifest) {
241
- const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
242
- if (!hasLockfile) {
243
- return {
244
- isOutOfSync: true,
245
- manifest: item.name,
246
- reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
247
- };
248
- }
337
+ const manifestPaths = modifiedFiles.filter((f) => item.manifest.test(f));
338
+ if (manifestPaths.length === 0) continue;
339
+ const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
340
+ if (hasLockfile) continue;
341
+ // Only a lockfile-relevant manifest change demands the lockfile: a
342
+ // scripts-only edit regenerates it byte-identical, so demanding one
343
+ // is unsatisfiable.
344
+ if (manifestPaths.some((manifestPath) => manifestChangeRequiresLockfile(root, mergeBaseCommit, manifestPath))) {
345
+ return {
346
+ isOutOfSync: true,
347
+ manifest: item.name,
348
+ reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
349
+ };
249
350
  }
250
351
  }
251
352
  } catch {}
@@ -335,29 +335,152 @@ export function checkMergeConflicts(root: string): {
335
335
  return { hasConflicts: false };
336
336
  }
337
337
 
338
- export function checkBranchBaseIsUpToDate(root: string): {
338
+ const BRANCH_CREATION_VALUELESS_FLAGS = new Set([
339
+ "-f",
340
+ "--force",
341
+ "--discard-changes",
342
+ "-m",
343
+ "--merge",
344
+ "-q",
345
+ "--quiet",
346
+ "--no-track",
347
+ ]);
348
+
349
+ // Classifies the optional start-point operand of an already-matched branch
350
+ // creation invocation (`git switch -c|--create` / `git checkout -b`). Returns
351
+ // the explicit start point when present, `{ start: undefined }` when the
352
+ // invocation creates a branch from the current HEAD, and `undefined` when the
353
+ // shape cannot be confidently classified — the caller must then fail closed to
354
+ // the HEAD-based staleness check. Indeterminate tokens (variables, quotes,
355
+ // unknown or value-taking flags) are never classified.
356
+ export function branchCreationStartPoint(rest: string): { start?: string } | undefined {
357
+ const tokens = rest.split(/\s+/).filter(Boolean);
358
+ let sawCreate = false;
359
+ let named = false;
360
+ for (const token of tokens) {
361
+ const indeterminate = /[$`"']/.test(token);
362
+ if (!sawCreate) {
363
+ if (token === "switch" || token === "checkout") continue;
364
+ if (token === "-c" || token === "--create" || token === "-b") {
365
+ sawCreate = true;
366
+ continue;
367
+ }
368
+ if (token.startsWith("--create=")) {
369
+ sawCreate = true;
370
+ named = true;
371
+ continue;
372
+ }
373
+ if (!token.startsWith("--") && token.length > 2 && (token.startsWith("-c") || token.startsWith("-b"))) {
374
+ sawCreate = true;
375
+ named = true;
376
+ continue;
377
+ }
378
+ if (token.startsWith("-")) {
379
+ if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
380
+ return undefined;
381
+ }
382
+ return undefined;
383
+ }
384
+ if (!named) {
385
+ if (token.startsWith("-")) {
386
+ if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
387
+ return undefined;
388
+ }
389
+ if (indeterminate) return undefined;
390
+ named = true;
391
+ continue;
392
+ }
393
+ if (token.startsWith("-")) {
394
+ if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
395
+ return undefined;
396
+ }
397
+ if (indeterminate) return undefined;
398
+ return { start: token };
399
+ }
400
+ return sawCreate ? { start: undefined } : undefined;
401
+ }
402
+
403
+ // A branch created from an explicit start point that already contains the
404
+ // remote default is fresh by construction, so the current branch's staleness
405
+ // is irrelevant for it; every provided start point must resolve and contain
406
+ // the base, and any missing start point keeps the HEAD-based check. Start
407
+ // points that cannot be resolved fail closed. Block reasons name what was
408
+ // actually checked and remedies that work under the guard.
409
+ function evaluateBranchCreationStarts(
410
+ root: string,
411
+ base: string,
412
+ startPoints: ReadonlyArray<string>,
413
+ ): { isBehind: true; baseRef: string; reason: string } | undefined {
414
+ for (const startPoint of startPoints) {
415
+ const resolved = spawnSync("git", ["rev-parse", "--verify", "--quiet", startPoint], {
416
+ cwd: root,
417
+ encoding: "utf8",
418
+ timeout: 5_000,
419
+ });
420
+ if (resolved.status !== 0 || !resolved.stdout.trim()) {
421
+ return {
422
+ isBehind: true,
423
+ baseRef: base,
424
+ reason: `the explicit start point '${startPoint}' could not be resolved as a commit, so freshness cannot be classified (fail closed). Use a literal branch name or ref as the start point.`,
425
+ };
426
+ }
427
+ const startSha = resolved.stdout.trim();
428
+ const contains = spawnSync("git", ["merge-base", "--is-ancestor", base, startSha], {
429
+ cwd: root,
430
+ encoding: "utf8",
431
+ timeout: 5_000,
432
+ });
433
+ if (contains.status !== 0) {
434
+ const behind = spawnSync("git", ["rev-list", `${startSha}..${base}`, "--count"], {
435
+ cwd: root,
436
+ encoding: "utf8",
437
+ timeout: 5_000,
438
+ });
439
+ const behindCount = parseInt((behind.stdout ?? "").trim(), 10);
440
+ return {
441
+ isBehind: true,
442
+ baseRef: base,
443
+ reason: `the explicit start point '${startPoint}' is ${isNaN(behindCount) ? "" : `${behindCount} `}commit(s) behind ${base}. Create the branch from ${base}, or from any ref that contains it, instead.`,
444
+ };
445
+ }
446
+ }
447
+ return undefined;
448
+ }
449
+
450
+ export function checkBranchBaseIsUpToDate(
451
+ root: string,
452
+ startPoints?: ReadonlyArray<string | undefined>,
453
+ ): {
339
454
  isBehind: boolean;
340
455
  count?: number;
341
456
  baseRef?: string;
342
457
  reason?: string;
343
458
  } {
459
+ const classified = startPoints && startPoints.length > 0 ? startPoints : undefined;
344
460
  for (const base of ["origin/HEAD", "origin/main", "origin/master"]) {
345
461
  const res = spawnSync("git", ["rev-list", `HEAD..${base}`, "--count"], {
346
462
  cwd: root,
347
463
  encoding: "utf8",
348
464
  timeout: 5_000,
349
465
  });
350
- if (res.status === 0 && res.stdout.trim()) {
351
- const count = parseInt(res.stdout.trim(), 10);
352
- if (!isNaN(count) && count > 0) {
353
- return {
354
- isBehind: true,
355
- count,
356
- baseRef: base,
357
- reason: `Local base branch is ${count} commit(s) behind remote (${base}). Run 'git pull' or 'git fetch' on main before creating a fresh feature branch to prevent upstream conflicts.`,
358
- };
359
- }
466
+ if (res.status !== 0 || !res.stdout.trim()) continue;
467
+ const count = parseInt(res.stdout.trim(), 10);
468
+ if (isNaN(count)) continue;
469
+ const explicit = (classified ?? []).filter((startPoint): startPoint is string => startPoint !== undefined);
470
+ if (explicit.length > 0) {
471
+ const startBlock = evaluateBranchCreationStarts(root, base, explicit);
472
+ if (startBlock) return startBlock;
473
+ }
474
+ if (count > 0 && (!classified || classified.some((startPoint) => startPoint === undefined))) {
475
+ const branch = currentGitBranch(root) || "detached HEAD";
476
+ return {
477
+ isBehind: true,
478
+ count,
479
+ baseRef: base,
480
+ reason: `current branch '${branch}' is ${count} commit(s) behind remote default (${base}), so a branch created from it starts stale. Update this branch first (git rebase ${base} or git merge --ff-only ${base}), or create the new branch from ${base} directly.`,
481
+ };
360
482
  }
483
+ return { isBehind: false };
361
484
  }
362
485
  return { isBehind: false };
363
486
  }
@@ -2,7 +2,7 @@
2
2
  * Workflow Guard TUI companion plugin for OpenCode V2 (CLI plugin API).
3
3
  *
4
4
  * - Registers the `/guard-options` palette + slash command to toggle project options.
5
- * - Renders the Workflow Guard badge in the home and prompt footer status slots.
5
+ * - Renders the Workflow Guard badge in the prompt footer status slot.
6
6
  *
7
7
  * The V1 entrypoint (`WorkflowGuardTui`) remains for OpenCode 1.x TUI clients.
8
8
  */
@@ -277,7 +277,6 @@ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
277
277
  },
278
278
  });
279
279
  };
280
- registerBadgeSlot("home.footer.status");
281
280
  registerBadgeSlot("prompt.footer.status");
282
281
  diagRender("probe-after-setup");
283
282
 
@@ -84,6 +84,9 @@ import {
84
84
  sessionReviews,
85
85
  setSessionWorkspace,
86
86
  getSessionWorkspace,
87
+ getSessionBoundWorkspace,
88
+ registerSessionWorkspaceFromToolCall,
89
+ resolveSessionBoundWorkspace,
87
90
  resolveEffectiveWorkspace,
88
91
  } from "./lib/state.ts";
89
92
  import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
@@ -116,6 +119,9 @@ export {
116
119
  resetReviewState,
117
120
  setSessionWorkspace,
118
121
  getSessionWorkspace,
122
+ getSessionBoundWorkspace,
123
+ registerSessionWorkspaceFromToolCall,
124
+ resolveSessionBoundWorkspace,
119
125
  resolveEffectiveWorkspace,
120
126
  findGitRoot,
121
127
  isSameGitRepo,
@@ -448,12 +454,16 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
448
454
  tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client: ctx.client }),
449
455
 
450
456
  "tool.execute.before": async (input, output) => {
451
- const toolWorktree =
457
+ const hostWorktree =
452
458
  (input as { worktree?: string })?.worktree ||
453
459
  (input as { directory?: string })?.directory ||
454
460
  (output as { worktree?: string })?.worktree ||
455
- (output as { directory?: string })?.directory ||
456
- effectiveRoot;
461
+ (output as { directory?: string })?.directory;
462
+ // Session-bound workspace before the plugin-instance fallback:
463
+ // concurrent loops share this process, and a fallback derived from
464
+ // the last-active instance must not re-anchor another session's
465
+ // policy context.
466
+ const toolWorktree = hostWorktree || getSessionBoundWorkspace(input.sessionID) || effectiveRoot;
457
467
  const toolAgent =
458
468
  (input as { agent?: string })?.agent ||
459
469
  (output as { agent?: string })?.agent;
@@ -480,11 +490,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
480
490
  const record = asRecord(args);
481
491
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
482
492
  const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
483
- const candidatePath = workdir ?? filePath ?? toolWorktree;
484
- if (candidatePath) {
485
- const gitRoot = findGitRoot(candidatePath);
486
- if (gitRoot) setSessionWorkspace(input.sessionID, gitRoot);
487
- }
493
+ registerSessionWorkspaceFromToolCall(input.sessionID, { workdir, filePath, hostWorktree, toolWorktree });
488
494
  }
489
495
  if (input.tool === "read") {
490
496
  const target = editTargets(args, toolWorktree)[0];