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/README.md +4 -4
- package/docs/installation.md +23 -0
- package/docs/policies.md +31 -3
- package/docs/testing.md +3 -1
- package/package.json +2 -2
- package/src/lib/audit.ts +52 -2
- package/src/lib/custom-tools.ts +154 -3
- package/src/lib/dogfood.ts +147 -0
- package/src/lib/evidence.ts +19 -1
- package/src/lib/guard-dispatcher.ts +134 -13
- package/src/lib/permission-seat.ts +79 -1
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +301 -2
- package/src/lib/types.ts +71 -1
- package/src/lib/v2-plugin.ts +32 -1
- package/src/policies/boundary.ts +307 -24
- package/src/policies/git.ts +144 -22
- package/src/policies/interpreter.ts +93 -16
- package/src/workflow-guard.ts +56 -5
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +1286 -20
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 {
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -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
|
}
|