opencode-workflow-guard 1.18.1 → 1.19.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/src/lib/state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, unlinkSync } from "node:fs";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
- import { audit, getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
4
+ import { audit, getDogfoodCacheFilePath, getReviewCacheFilePath, loadDogfoodCache, loadReviewCache, persistDogfoodCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
5
  import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
6
6
  import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
7
7
  import { normalizeLiveControlPlaneRoots } from "./live-control-plane.ts";
@@ -10,6 +10,7 @@ import type {
10
10
  ProjectConfig,
11
11
  VerifyResult,
12
12
  ReviewResult,
13
+ DogfoodResult,
13
14
  } from "./types.ts";
14
15
 
15
16
  export let workspaceRoot = process.cwd();
@@ -80,6 +81,10 @@ export function clearTodoSessionState(sessionID: string): void {
80
81
  // instance must never outrank the call's own context or the host fallback.
81
82
  const sessionWorkspaceBound = new Set<string>();
82
83
 
84
+ // Last directory the HOST named per session, so a repeated `session.updated`
85
+ // costs one string comparison instead of a `git rev-parse` child process.
86
+ const sessionHostDirectories = new Map<string, string>();
87
+
83
88
  export function setSessionWorkspace(sessionID: string, workspace: string): void {
84
89
  if (!sessionID || !workspace) return;
85
90
  const gitRoot = findGitRoot(workspace);
@@ -87,6 +92,18 @@ export function setSessionWorkspace(sessionID: string, workspace: string): void
87
92
  sessionWorkspaceBound.add(sessionID);
88
93
  }
89
94
 
95
+ /**
96
+ * Drop a deleted session's workspace binding and host-directory cache entry.
97
+ *
98
+ * Both are keyed by session id, so a deleted session can never read them again.
99
+ * Leaving them would grow both maps for the life of a long-lived server.
100
+ */
101
+ export function forgetSessionWorkspace(sessionID: string): void {
102
+ sessionWorkspaces.delete(sessionID);
103
+ sessionWorkspaceBound.delete(sessionID);
104
+ sessionHostDirectories.delete(sessionID);
105
+ }
106
+
90
107
  export function getSessionWorkspace(sessionID?: string): string | undefined {
91
108
  if (!sessionID) return undefined;
92
109
  return sessionWorkspaces.get(sessionID);
@@ -235,11 +252,74 @@ function auditWorkspaceRebind(sessionID: string, from: string | undefined, to: s
235
252
  tool: "workspace.binding",
236
253
  decision: "allow",
237
254
  phase: "event",
238
- reason: "workspace_rebind_host_worktree",
255
+ reason: source === "session-move" ? "workspace_rebind_session_move" : "workspace_rebind_host_worktree",
239
256
  input: { from, to, source },
240
257
  });
241
258
  }
242
259
 
260
+ /**
261
+ * Rebind a session to the workspace the HOST says it now occupies.
262
+ *
263
+ * `session_move` (and the equivalent host move command) relocates a session
264
+ * between checkouts. Without this the binding stayed on the old checkout, so
265
+ * every later boundary, branch, and PR decision judged the tree the session had
266
+ * left: the move looked like it had no effect. A host move is an explicit
267
+ * boundary statement from the host, in the same class as a per-call worktree,
268
+ * so it may widen an already-bound session. It is journaled for that reason.
269
+ *
270
+ * The hard part is telling a move from a routine update. `session.updated` is a
271
+ * hot event, emitted for title and cost changes, and it names the session's
272
+ * creation directory. A session bound to a LINKED WORKTREE by its own tool-call
273
+ * `workdir` legitimately differs from that directory, so treating every update
274
+ * as a move pulled the session back to the checkout it had deliberately left.
275
+ * Three signals therefore count, and nothing else does:
276
+ *
277
+ * - The host announced the relocation itself (`moved: true`, from
278
+ * `session.moved`).
279
+ * - The directory the host names now differs from the one it named last for
280
+ * this session.
281
+ * - The named repository is a DIFFERENT repository from the bound workspace. A
282
+ * linked worktree is the same repository, so this catches the cross-repository
283
+ * move that a first observation would otherwise miss on OpenCode 1.x, which
284
+ * has no move event.
285
+ *
286
+ * The last directory is remembered so a repeated update costs one string
287
+ * comparison instead of a `git rev-parse` child process. The cache stores a
288
+ * directory that actually resolved to a repository root: an unresolved
289
+ * directory is not cached, so a later event for the same path retries instead
290
+ * of being suppressed forever.
291
+ */
292
+ export function rebindSessionWorkspaceFromHost(
293
+ sessionID: string | undefined,
294
+ directory: string | undefined,
295
+ options: { moved?: boolean } = {},
296
+ ): void {
297
+ if (!sessionID || !directory) return;
298
+ const gitRoot = findGitRoot(directory);
299
+ if (!gitRoot) return;
300
+ const previousHostDirectory = sessionHostDirectories.get(sessionID);
301
+ sessionHostDirectories.set(sessionID, directory);
302
+ const previous = sessionWorkspaces.get(sessionID);
303
+ const moved =
304
+ options.moved === true
305
+ || (previousHostDirectory !== undefined && previousHostDirectory !== directory)
306
+ || (previous !== undefined && gitRoot !== previous && !isSameGitRepo(gitRoot, previous));
307
+ if (!moved) {
308
+ if (sessionWorkspaceBound.has(sessionID)) return;
309
+ // A bootstrap entry that was never bound still needs binding: the host
310
+ // just named the session's real directory.
311
+ sessionWorkspaces.set(sessionID, gitRoot);
312
+ sessionWorkspaceBound.add(sessionID);
313
+ return;
314
+ }
315
+ if (previous === gitRoot) {
316
+ sessionWorkspaceBound.add(sessionID);
317
+ return;
318
+ }
319
+ setSessionWorkspace(sessionID, gitRoot);
320
+ auditWorkspaceRebind(sessionID, previous, gitRoot, "session-move");
321
+ }
322
+
243
323
  /**
244
324
  * Durable record of a refused widening. The refusal itself is the boundary
245
325
  * policy's decision; this entry exists so an operator can tell whether a
@@ -454,6 +534,177 @@ export function resetReviewState(): void {
454
534
  } catch {}
455
535
  }
456
536
 
537
+ let dogfoodRecords: DogfoodResult[] = [];
538
+ /**
539
+ * Retention per workspace and host. This bounds in-memory and merged history so
540
+ * one busy key cannot grow without limit. Only the newest record per key is ever
541
+ * durable, so this cap never decides what survives a restart.
542
+ */
543
+ const MAX_DOGFOOD_RECORDS_PER_WORKSPACE_HOST = 3;
544
+
545
+ /**
546
+ * Retention key. Workspace is part of the key because the durable cache is
547
+ * shared across repositories: keying on the host label alone would let a busy
548
+ * host in one repository evict another repository's record for the same label.
549
+ */
550
+ function dogfoodRetentionKey(record: DogfoodResult): string {
551
+ return `${record.workspace ?? ""}\u0000${record.host.label}`;
552
+ }
553
+
554
+ function retainNewestPerWorkspaceHost(records: DogfoodResult[]): DogfoodResult[] {
555
+ const byKey = new Map<string, DogfoodResult[]>();
556
+ for (const record of records) {
557
+ const key = dogfoodRetentionKey(record);
558
+ byKey.set(key, [...(byKey.get(key) ?? []), record]);
559
+ }
560
+ return [...byKey.values()].flatMap((keyRecords) =>
561
+ keyRecords
562
+ .sort((left, right) => right.timestamp - left.timestamp)
563
+ .slice(0, MAX_DOGFOOD_RECORDS_PER_WORKSPACE_HOST),
564
+ );
565
+ }
566
+
567
+ /**
568
+ * Every dogfood record this process can see: memory plus the durable cache,
569
+ * deduplicated and retained. The eviction path and the read path must agree
570
+ * about which records exist, so both call this rather than the pieces.
571
+ */
572
+ function mergedDogfoodRecords(): DogfoodResult[] {
573
+ return retainNewestPerWorkspaceHost(dedupeDogfoodRecords([...dogfoodRecords, ...loadDogfoodCache()]));
574
+ }
575
+
576
+ /**
577
+ * The records that may be durable, given everything currently known.
578
+ *
579
+ * One per workspace and host: the newest. Persisting an older pass would
580
+ * resurrect it after a restart even when a newer HOLD already superseded it,
581
+ * because the HOLD itself is never written to disk. Keeping only the newest
582
+ * record per key means a HOLD tombstones that key by leaving nothing behind.
583
+ */
584
+ function durableDogfoodRecords(records: DogfoodResult[]): DogfoodResult[] {
585
+ const newest = new Map<string, DogfoodResult>();
586
+ for (const record of records) {
587
+ const key = dogfoodRetentionKey(record);
588
+ const seen = newest.get(key);
589
+ // A same-millisecond tie must resolve the same way the gate resolves it,
590
+ // to the HOLD. Otherwise a pass could be persisted alongside a HOLD that
591
+ // supersedes it, and a restart would serve the pass.
592
+ if (!seen || record.timestamp > seen.timestamp || (record.timestamp === seen.timestamp && record.verdict === "hold")) {
593
+ newest.set(key, record);
594
+ }
595
+ }
596
+ return [...newest.values()].filter((record) => record.verdict === "pass");
597
+ }
598
+
599
+ /**
600
+ * Records a dogfood run for a workspace.
601
+ *
602
+ * A HOLD is kept in memory so its taxonomy class stays diagnosable, and it is
603
+ * never persisted: caching a failed run would let a later preflight read it as
604
+ * evidence. A HOLD does evict that host's previously cached passes, because a
605
+ * restart would otherwise drop the in-memory HOLD and serve the older pass.
606
+ *
607
+ * Only the newest record per workspace and host is durable, but records for
608
+ * different hosts accumulate, because a deploy matrix needs one pass per host.
609
+ */
610
+ export function recordDogfoodResult(result: Omit<DogfoodResult, "workspace" | "commitHash" | "gitStatus" | "worktreeFingerprint" | "timestamp"> & { workspace?: string }, targetSessionID?: string): DogfoodResult {
611
+ const root = projectRootKey(result.workspace ?? getWorkspaceRoot());
612
+ const stored: DogfoodResult = {
613
+ ...result,
614
+ timestamp: Date.now(),
615
+ workspace: root,
616
+ commitHash: getCurrentGitCommitHash(root),
617
+ gitStatus: getGitStatusSummary(root),
618
+ // Bind to tracked content only, matching review: the dogfood run
619
+ // covered the change, and untracked scratch files are not part of it.
620
+ worktreeFingerprint: getTrackedWorktreeFingerprint(root),
621
+ sessionID: targetSessionID,
622
+ };
623
+ dogfoodRecords = retainNewestPerWorkspaceHost([stored, ...dogfoodRecords]);
624
+ // Only the newest record per workspace and host may be durable. A HOLD is
625
+ // never written, so it tombstones its key by leaving nothing behind. That
626
+ // also stops a later PASS for a different host from re-persisting a pass
627
+ // that a newer HOLD already superseded: after a restart the HOLD is gone,
628
+ // and that resurrected pass would re-open the gate on a held host.
629
+ //
630
+ // The durable set comes from merged state, never memory alone: in a cold
631
+ // process memory holds only the record just made, so using memory alone
632
+ // would delete every other host's cached pass.
633
+ const durable = durableDogfoodRecords(mergedDogfoodRecords());
634
+ // A failed write here is not cosmetic: after a HOLD it leaves the stale pass
635
+ // on disk, and a restart would serve it as evidence. Journal the failure so
636
+ // the durable state disagreeing with memory is visible.
637
+ if (!persistDogfoodCache(durable)) {
638
+ audit({
639
+ ts: new Date().toISOString(),
640
+ sessionID: targetSessionID,
641
+ tool: "dogfood.cache",
642
+ decision: "allow",
643
+ phase: "event",
644
+ reason: "dogfood_cache_write_failed",
645
+ evidence: { dogfoodVerdict: stored.verdict === "hold" ? "hold" : "pass", dogfoodHost: stored.host.label },
646
+ });
647
+ }
648
+ return stored;
649
+ }
650
+
651
+ /**
652
+ * Collapses records that describe the same run, so merging memory with disk
653
+ * does not double count. Retention and the durable cache both count records,
654
+ * so a duplicate would let itself evict a sibling host's only pass.
655
+ */
656
+ function dedupeDogfoodRecords(records: DogfoodResult[]): DogfoodResult[] {
657
+ const seen = new Set<string>();
658
+ const unique: DogfoodResult[] = [];
659
+ for (const record of records) {
660
+ // Workspace is part of the identity: the cache is shared across
661
+ // repositories, so two runs of the same scenario on the same host label
662
+ // in different workspaces are distinct evidence. Verdict is part of it
663
+ // too, so a PASS and a HOLD recorded in the same millisecond stay
664
+ // distinct and the HOLD can win.
665
+ const identity = `${record.workspace ?? ""}\u0000${record.host.label}\u0000${record.scenarioId}\u0000${record.scenarioSha256}\u0000${record.taxonomyClass ?? ""}\u0000${record.verdict}\u0000${record.timestamp}\u0000${record.commitHash ?? ""}`;
666
+ if (seen.has(identity)) continue;
667
+ seen.add(identity);
668
+ unique.push(record);
669
+ }
670
+ return unique;
671
+ }
672
+
673
+ /**
674
+ * Candidate dogfood records for one worktree.
675
+ *
676
+ * Memory and disk are merged, so a pass recorded in a previous process is
677
+ * still a candidate next to one recorded now.
678
+ *
679
+ * Exact-worktree candidates win over same-repository ones, so a sibling
680
+ * worktree's run cannot displace this worktree's evidence. Same-repository
681
+ * candidates are returned when this worktree has none, which is what lets a
682
+ * run in a linked worktree satisfy the parent when the tracked content matches.
683
+ * Freshness is judged per record by the caller, never here.
684
+ */
685
+ export function getDogfoodRecordsForWorkspace(root: string): DogfoodResult[] {
686
+ const workspace = projectRootKey(root);
687
+ const all = mergedDogfoodRecords().filter(
688
+ (result): result is DogfoodResult & { workspace: string } => result?.workspace != null,
689
+ );
690
+ const exact = all.filter((result) => projectRootKey(result.workspace) === workspace);
691
+ if (exact.length > 0) return exact;
692
+ return all.filter((result) => isSameGitRepo(result.workspace, root));
693
+ }
694
+
695
+ export function getLastDogfoodResultForWorkspace(root: string): DogfoodResult | undefined {
696
+ const records = getDogfoodRecordsForWorkspace(root);
697
+ return records.reduce<DogfoodResult | undefined>((best, result) => !best || result.timestamp > best.timestamp ? result : best, undefined);
698
+ }
699
+
700
+ export function resetDogfoodState(): void {
701
+ dogfoodRecords = [];
702
+ try {
703
+ const p = getDogfoodCacheFilePath();
704
+ if (existsSync(p)) unlinkSync(p);
705
+ } catch {}
706
+ }
707
+
457
708
  export async function resolveEffectiveWorkspace(options: {
458
709
  sessionID?: string;
459
710
  directory?: string;
@@ -543,6 +794,54 @@ export function isDocumentationRequired(root: string): boolean {
543
794
  return cfg.requireDocumentation === true;
544
795
  }
545
796
 
797
+ /**
798
+ * Dogfood gate (pre-deployment evidence). Off by default, per the rule that
799
+ * every write path ships opt-in first: a repository opts in only once its
800
+ * runner exists, and an unset flag keeps today's PR behavior.
801
+ */
802
+ export function isDogfoodRequired(root: string): boolean {
803
+ const env = process.env.WORKFLOW_GUARD_REQUIRE_DOGFOOD?.toLowerCase();
804
+ if (env === "0" || env === "false" || env === "off") return false;
805
+ if (env === "1" || env === "true" || env === "on") return true;
806
+ return getProjectConfig(root).requireDogfood === true;
807
+ }
808
+
809
+ /**
810
+ * Host labels that must each carry a passing dogfood record. Unset means the
811
+ * recorded host alone satisfies the gate, so a single-machine setup needs no
812
+ * matrix. Blank and duplicate labels are dropped; a non-array value is
813
+ * ignored rather than throwing, matching the other config readers.
814
+ */
815
+ export function getDogfoodDeployMatrix(root: string): string[] {
816
+ const env = process.env.WORKFLOW_GUARD_DOGFOOD_DEPLOY_MATRIX;
817
+ const raw = env !== undefined && env.trim() !== "" ? env.split(",") : getProjectConfig(root).dogfoodDeployMatrix;
818
+ if (!Array.isArray(raw)) return [];
819
+ return [...new Set(raw.filter((value): value is string => typeof value === "string").map((value) => value.trim()).filter(Boolean))];
820
+ }
821
+
822
+ /**
823
+ * Dogfood recursion depth for this process. The runner sets
824
+ * `WORKFLOW_GUARD_DOGFOOD_DEPTH` on the actor it spawns, so the marker is
825
+ * inherited rather than handshaken. Unparseable or negative values fail
826
+ * closed to depth 1, because a corrupt marker must not read as a clean root.
827
+ */
828
+ export function getDogfoodDepth(): number {
829
+ const env = process.env.WORKFLOW_GUARD_DOGFOOD_DEPTH;
830
+ if (env === undefined || env.trim() === "") return 0;
831
+ const parsed = Number(env.trim());
832
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 1;
833
+ }
834
+
835
+ /**
836
+ * True when this process is a dogfood actor, that is, a session spawned by the
837
+ * runner to be tested. Every policy still applies; the marker only refuses
838
+ * self-certification and stops dogfood evidence from being persisted.
839
+ */
840
+ export function isDogfoodActor(): boolean {
841
+ if (process.env.WORKFLOW_GUARD_DOGFOOD_ACTOR === "1") return true;
842
+ return getDogfoodDepth() > 0;
843
+ }
844
+
546
845
  /**
547
846
  * Policy 25 (live-documentation research floor): enabled by default. The env
548
847
  * override mirrors `isReviewRequired`; project config can opt out. The floor
package/src/lib/types.ts CHANGED
@@ -50,6 +50,19 @@ export interface ProjectConfig {
50
50
  */
51
51
  requireSubagentReview?: boolean;
52
52
  requireDocumentation?: boolean;
53
+ /**
54
+ * Dogfood gate (pre-deployment evidence). Off by default: a repository
55
+ * opts in once its runner exists. When true, PR preflight requires a fresh
56
+ * passing dogfood record bound to the tree the PR would publish.
57
+ */
58
+ requireDogfood?: boolean;
59
+ /**
60
+ * Host labels that must each carry a fresh passing dogfood record before a
61
+ * PR. Unset means the recorded host alone satisfies the gate. A matrix is
62
+ * a configuration fact, not a code path: a Linux pass never vouches for
63
+ * Windows.
64
+ */
65
+ dogfoodDeployMatrix?: string[];
53
66
  /**
54
67
  * Policy 25 (live-documentation research floor). Enabled by default: after
55
68
  * `liveDocFailureThreshold` failed tool calls with no live-documentation
@@ -120,9 +133,53 @@ export interface ReviewResult {
120
133
 
121
134
  export type EvidenceConfidence = "deterministic_observation" | "attestation" | "derived_state" | "agent_assertion";
122
135
 
136
+ export type DogfoodVerdict = "pass" | "hold";
137
+
138
+ /**
139
+ * One deterministic check row. `observed` is what the runner actually saw and
140
+ * `expected` is what the scenario declared, so a HOLD is diagnosable without
141
+ * re-running the scenario. `detail` carries the failing artifact excerpt.
142
+ */
143
+ export interface DogfoodCheck {
144
+ id: string;
145
+ status: "pass" | "hold" | "skip";
146
+ expected?: string;
147
+ observed?: string;
148
+ detail?: string;
149
+ }
150
+
151
+ export interface DogfoodHost {
152
+ /** Stable per-machine label the deploy matrix is written against. */
153
+ label: string;
154
+ os?: string;
155
+ arch?: string;
156
+ opencodeVersion?: string;
157
+ actorModel?: string;
158
+ judgeModel?: string;
159
+ /** Hash of the staged harness tree the actor actually ran against. */
160
+ stagedTreeHash?: string;
161
+ }
162
+
163
+ export interface DogfoodResult {
164
+ verdict: DogfoodVerdict;
165
+ scenarioId: string;
166
+ scenarioSha256: string;
167
+ taxonomySha256: string;
168
+ taxonomyClass?: string;
169
+ checks: DogfoodCheck[];
170
+ runCount: number;
171
+ host: DogfoodHost;
172
+ timestamp: number;
173
+ workspace?: string;
174
+ commitHash?: string;
175
+ gitStatus?: string;
176
+ worktreeFingerprint?: string;
177
+ sessionID?: string;
178
+ }
179
+
123
180
  export interface EvidenceRecord {
124
181
  id: string;
125
- kind: "verification" | "review" | "tool_outcome" | "policy_evaluation" | "lifecycle_state" | "agent_assertion";
182
+ kind: "verification" | "review" | "dogfood" | "tool_outcome" | "policy_evaluation" | "lifecycle_state" | "agent_assertion";
126
183
  confidence: EvidenceConfidence;
127
184
  observedAt: number;
128
185
  subject: {
@@ -167,6 +224,12 @@ export interface AuditEntry {
167
224
  };
168
225
  allowLive?: boolean;
169
226
  reviewVerdict?: "approved" | "changes_requested" | "rejected";
227
+ /** Dogfood verdicts: pass/hold are recorded, rejected is refused. */
228
+ dogfoodVerdict?: "pass" | "hold" | "rejected";
229
+ dogfoodScenarioId?: string;
230
+ dogfoodHost?: string;
231
+ /** Every host a satisfied deploy matrix covered, not just the newest. */
232
+ dogfoodCoveredHosts?: string[];
170
233
  evidenceId?: string;
171
234
  reviewer?: string;
172
235
  reviewTimestamp?: number;
@@ -184,6 +247,13 @@ export interface AuditEntry {
184
247
  export interface GitInvocation {
185
248
  repoDir: string;
186
249
  rest: string;
250
+ /**
251
+ * True when the command text itself names the repository (`-C`,
252
+ * `--git-dir`, `--work-tree`, `GIT_DIR=`). False means `repoDir` is only
253
+ * the invocation's assumed base directory, which is the shell's working
254
+ * directory and not a fact the command string carries.
255
+ */
256
+ explicitRepoDir?: boolean;
187
257
  }
188
258
 
189
259
  export interface ShellMutation {
@@ -28,6 +28,8 @@ import {
28
28
  setWorkspaceRoot,
29
29
  setSdkClient,
30
30
  getSessionBoundWorkspace,
31
+ rebindSessionWorkspaceFromHost,
32
+ forgetSessionWorkspace,
31
33
  registerSessionWorkspaceFromToolCall,
32
34
  runWithRuntimeState,
33
35
  getSessionMutationCount,
@@ -41,7 +43,9 @@ import {
41
43
  isLiveDocsRequired,
42
44
  getLiveDocFailureThreshold,
43
45
  getResearchToolPatterns,
46
+ isDogfoodRequired,
44
47
  } from "./state.ts";
48
+
45
49
  import {
46
50
  openProjectMemory,
47
51
  ensureProjectMemoryExcluded,
@@ -72,7 +76,7 @@ import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
72
76
  import { loadedPluginVersion } from "./version.ts";
73
77
  import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
74
78
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
75
- import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
79
+ import { buildCompactionContext, evaluateDogfoodGate, guardToolCall } from "../workflow-guard.ts";
76
80
  import { registerPluginInstance, releasePluginInstance } from "./plugin-instance.ts";
77
81
  import { claimToolCall, releaseToolCallClaim } from "./call-claims.ts";
78
82
  import { evaluatePermissionSeat } from "./permission-seat.ts";
@@ -459,6 +463,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
459
463
  projectMemoryEnabled,
460
464
  learningEnabled,
461
465
  recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
466
+ dogfoodRequired: isDogfoodRequired(effectiveRoot),
462
467
  isReadOnly,
463
468
  });
464
469
  event.system.push({ type: "text", text: guidance });
@@ -518,6 +523,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
518
523
  `- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
519
524
  );
520
525
  }
526
+ const dogfoodCtxGate = evaluateDogfoodGate(effectiveRoot);
527
+ if (dogfoodCtxGate.required) {
528
+ stateLines.push(
529
+ dogfoodCtxGate.missingHosts.length === 0
530
+ ? `- Dogfood Evidence: PASS on ${(dogfoodCtxGate.matchedRecords ?? []).map((record) => record.host.label).join(", ") || "recorded host"}`
531
+ : `- Dogfood Evidence: MISSING for ${dogfoodCtxGate.missingHosts.join(", ")} (required before PR creation)`,
532
+ );
533
+ }
521
534
  let openFollowups: ReturnType<typeof listReviewFollowups> = [];
522
535
  try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
523
536
  if (openFollowups.length > 0) {
@@ -830,6 +843,23 @@ async function handleV2Event(
830
843
  return;
831
844
  }
832
845
 
846
+ if (event.type === "session.moved") {
847
+ // `session_move` relocates a session between checkouts, and the host
848
+ // announces it. On the pinned 2.2.0 SDK the V2 union carries
849
+ // `session.moved` and no `session.updated`, and the move event itself
850
+ // names the destination: `data.sessionID` plus
851
+ // `data.location.directory`. Without this rebind the session kept
852
+ // judging the tree it left.
853
+ const data = event.data as {
854
+ sessionID?: unknown;
855
+ location?: { directory?: unknown };
856
+ } | undefined;
857
+ const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
858
+ const directory = typeof data?.location?.directory === "string" ? data.location.directory : undefined;
859
+ rebindSessionWorkspaceFromHost(sessionID, directory, { moved: true });
860
+ return;
861
+ }
862
+
833
863
  if (event.type === "session.idle") {
834
864
  const data = event.data as { sessionID?: unknown } | undefined;
835
865
  const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
@@ -859,6 +889,7 @@ async function handleV2Event(
859
889
  clearTodoSessionState(sessionID);
860
890
  toolLifecycle.takeRecoveryRun(sessionID);
861
891
  await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
892
+ forgetSessionWorkspace(sessionID);
862
893
  }
863
894
  return;
864
895
  }