opencode-workflow-guard 1.18.1 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/docs/installation.md +23 -0
- package/docs/policies.md +20 -0
- package/docs/testing.md +1 -0
- 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 +30 -0
- package/src/lib/state.ts +221 -1
- package/src/lib/types.ts +64 -1
- package/src/lib/v2-plugin.ts +12 -1
- package/src/workflow-guard.ts +29 -2
- package/test/test.mts +626 -0
package/src/lib/evidence.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { ToolOutcome } from "./tool-outcomes.ts";
|
|
3
|
-
import type { EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
|
|
3
|
+
import type { DogfoodResult, EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
|
|
4
4
|
import { isSameGitRepo } from "./project-config.ts";
|
|
5
5
|
|
|
6
6
|
function evidenceId(kind: EvidenceRecord["kind"], observedAt: number, subject: EvidenceRecord["subject"], source: Record<string, unknown>): string {
|
|
@@ -19,6 +19,24 @@ export function reviewEvidence(result: ReviewResult): EvidenceRecord {
|
|
|
19
19
|
return { id: evidenceId("review", result.timestamp, subject, source), kind: "review", confidence: "attestation", observedAt: result.timestamp, subject, source };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function dogfoodEvidence(result: DogfoodResult): EvidenceRecord {
|
|
23
|
+
const subject = { workspace: result.workspace, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID: result.sessionID, actor: result.host.label };
|
|
24
|
+
// The digest pair is part of the source, so replaying a passing run onto a
|
|
25
|
+
// different scenario or taxonomy changes the evidence id.
|
|
26
|
+
const source = {
|
|
27
|
+
verdict: result.verdict,
|
|
28
|
+
scenarioId: result.scenarioId,
|
|
29
|
+
scenarioSha256: result.scenarioSha256,
|
|
30
|
+
taxonomySha256: result.taxonomySha256,
|
|
31
|
+
taxonomyClass: result.taxonomyClass,
|
|
32
|
+
checks: result.checks,
|
|
33
|
+
runCount: result.runCount,
|
|
34
|
+
host: result.host,
|
|
35
|
+
gitStatus: result.gitStatus,
|
|
36
|
+
};
|
|
37
|
+
return { id: evidenceId("dogfood", result.timestamp, subject, source), kind: "dogfood", confidence: "attestation", observedAt: result.timestamp, subject, source };
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
export function toolOutcomeEvidence(outcome: ToolOutcome, observedAt: number, workspace?: string): EvidenceRecord {
|
|
23
41
|
const subject = { workspace, sessionID: outcome.sessionID, callID: outcome.callID };
|
|
24
42
|
const source = { tool: outcome.tool, status: outcome.status, durationMs: outcome.durationMs, repeatedFailureCount: outcome.repeatedFailureCount };
|
|
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { audit, loadVerifyCache } from "./audit.ts";
|
|
4
4
|
import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
|
|
5
5
|
import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
|
|
6
|
+
import { evaluateDogfoodGate } from "./dogfood.ts";
|
|
6
7
|
import {
|
|
7
8
|
getLastReviewResultForWorkspace,
|
|
8
9
|
getLiveControlPlaneRoots,
|
|
@@ -686,6 +687,35 @@ export async function guardToolCallImpl(
|
|
|
686
687
|
}
|
|
687
688
|
}
|
|
688
689
|
if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
|
|
690
|
+
// Dogfood gate (pre-deployment evidence), off by default. The
|
|
691
|
+
// evaluation lives in lib/dogfood.ts so preflight and guard_status
|
|
692
|
+
// cannot disagree about whether the gate is satisfied.
|
|
693
|
+
const dogfoodGate = evaluateDogfoodGate(prRoot);
|
|
694
|
+
if (dogfoodGate.matched && !context?.simulate) {
|
|
695
|
+
audit({
|
|
696
|
+
ts: new Date().toISOString(),
|
|
697
|
+
sessionID: context?.sessionID,
|
|
698
|
+
tool: "pr-preflight.dogfood-binding",
|
|
699
|
+
decision: "allow",
|
|
700
|
+
phase: "event",
|
|
701
|
+
reason: "dogfood_evidence_matched",
|
|
702
|
+
evidence: {
|
|
703
|
+
evidenceId: dogfoodGate.evidenceId,
|
|
704
|
+
dogfoodScenarioId: dogfoodGate.matched.scenarioId,
|
|
705
|
+
dogfoodHost: dogfoodGate.matched.host.label,
|
|
706
|
+
// Journal every host the matrix required, not just the
|
|
707
|
+
// newest one. A single-host entry would understate a
|
|
708
|
+
// multi-host pass and mislead an auditor later.
|
|
709
|
+
dogfoodCoveredHosts: (dogfoodGate.matchedRecords ?? []).map((record) => record.host.label),
|
|
710
|
+
binding: {
|
|
711
|
+
workspace: projectRootKey(prRoot),
|
|
712
|
+
commitHash: getCurrentGitCommitHash(prRoot),
|
|
713
|
+
worktreeFingerprint: getTrackedWorktreeFingerprint(prRoot)?.slice(0, 16),
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
if (dogfoodGate.missingHosts.length > 0 && dogfoodGate.reason) preflightFailures.push(dogfoodGate.reason);
|
|
689
719
|
const branchChangelog = branchHasChangelogChange(prRoot);
|
|
690
720
|
let shellCwdKnown = true;
|
|
691
721
|
const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
|
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();
|
|
@@ -454,6 +455,177 @@ export function resetReviewState(): void {
|
|
|
454
455
|
} catch {}
|
|
455
456
|
}
|
|
456
457
|
|
|
458
|
+
let dogfoodRecords: DogfoodResult[] = [];
|
|
459
|
+
/**
|
|
460
|
+
* Retention per workspace and host. This bounds in-memory and merged history so
|
|
461
|
+
* one busy key cannot grow without limit. Only the newest record per key is ever
|
|
462
|
+
* durable, so this cap never decides what survives a restart.
|
|
463
|
+
*/
|
|
464
|
+
const MAX_DOGFOOD_RECORDS_PER_WORKSPACE_HOST = 3;
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Retention key. Workspace is part of the key because the durable cache is
|
|
468
|
+
* shared across repositories: keying on the host label alone would let a busy
|
|
469
|
+
* host in one repository evict another repository's record for the same label.
|
|
470
|
+
*/
|
|
471
|
+
function dogfoodRetentionKey(record: DogfoodResult): string {
|
|
472
|
+
return `${record.workspace ?? ""}\u0000${record.host.label}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function retainNewestPerWorkspaceHost(records: DogfoodResult[]): DogfoodResult[] {
|
|
476
|
+
const byKey = new Map<string, DogfoodResult[]>();
|
|
477
|
+
for (const record of records) {
|
|
478
|
+
const key = dogfoodRetentionKey(record);
|
|
479
|
+
byKey.set(key, [...(byKey.get(key) ?? []), record]);
|
|
480
|
+
}
|
|
481
|
+
return [...byKey.values()].flatMap((keyRecords) =>
|
|
482
|
+
keyRecords
|
|
483
|
+
.sort((left, right) => right.timestamp - left.timestamp)
|
|
484
|
+
.slice(0, MAX_DOGFOOD_RECORDS_PER_WORKSPACE_HOST),
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Every dogfood record this process can see: memory plus the durable cache,
|
|
490
|
+
* deduplicated and retained. The eviction path and the read path must agree
|
|
491
|
+
* about which records exist, so both call this rather than the pieces.
|
|
492
|
+
*/
|
|
493
|
+
function mergedDogfoodRecords(): DogfoodResult[] {
|
|
494
|
+
return retainNewestPerWorkspaceHost(dedupeDogfoodRecords([...dogfoodRecords, ...loadDogfoodCache()]));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The records that may be durable, given everything currently known.
|
|
499
|
+
*
|
|
500
|
+
* One per workspace and host: the newest. Persisting an older pass would
|
|
501
|
+
* resurrect it after a restart even when a newer HOLD already superseded it,
|
|
502
|
+
* because the HOLD itself is never written to disk. Keeping only the newest
|
|
503
|
+
* record per key means a HOLD tombstones that key by leaving nothing behind.
|
|
504
|
+
*/
|
|
505
|
+
function durableDogfoodRecords(records: DogfoodResult[]): DogfoodResult[] {
|
|
506
|
+
const newest = new Map<string, DogfoodResult>();
|
|
507
|
+
for (const record of records) {
|
|
508
|
+
const key = dogfoodRetentionKey(record);
|
|
509
|
+
const seen = newest.get(key);
|
|
510
|
+
// A same-millisecond tie must resolve the same way the gate resolves it,
|
|
511
|
+
// to the HOLD. Otherwise a pass could be persisted alongside a HOLD that
|
|
512
|
+
// supersedes it, and a restart would serve the pass.
|
|
513
|
+
if (!seen || record.timestamp > seen.timestamp || (record.timestamp === seen.timestamp && record.verdict === "hold")) {
|
|
514
|
+
newest.set(key, record);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return [...newest.values()].filter((record) => record.verdict === "pass");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Records a dogfood run for a workspace.
|
|
522
|
+
*
|
|
523
|
+
* A HOLD is kept in memory so its taxonomy class stays diagnosable, and it is
|
|
524
|
+
* never persisted: caching a failed run would let a later preflight read it as
|
|
525
|
+
* evidence. A HOLD does evict that host's previously cached passes, because a
|
|
526
|
+
* restart would otherwise drop the in-memory HOLD and serve the older pass.
|
|
527
|
+
*
|
|
528
|
+
* Only the newest record per workspace and host is durable, but records for
|
|
529
|
+
* different hosts accumulate, because a deploy matrix needs one pass per host.
|
|
530
|
+
*/
|
|
531
|
+
export function recordDogfoodResult(result: Omit<DogfoodResult, "workspace" | "commitHash" | "gitStatus" | "worktreeFingerprint" | "timestamp"> & { workspace?: string }, targetSessionID?: string): DogfoodResult {
|
|
532
|
+
const root = projectRootKey(result.workspace ?? getWorkspaceRoot());
|
|
533
|
+
const stored: DogfoodResult = {
|
|
534
|
+
...result,
|
|
535
|
+
timestamp: Date.now(),
|
|
536
|
+
workspace: root,
|
|
537
|
+
commitHash: getCurrentGitCommitHash(root),
|
|
538
|
+
gitStatus: getGitStatusSummary(root),
|
|
539
|
+
// Bind to tracked content only, matching review: the dogfood run
|
|
540
|
+
// covered the change, and untracked scratch files are not part of it.
|
|
541
|
+
worktreeFingerprint: getTrackedWorktreeFingerprint(root),
|
|
542
|
+
sessionID: targetSessionID,
|
|
543
|
+
};
|
|
544
|
+
dogfoodRecords = retainNewestPerWorkspaceHost([stored, ...dogfoodRecords]);
|
|
545
|
+
// Only the newest record per workspace and host may be durable. A HOLD is
|
|
546
|
+
// never written, so it tombstones its key by leaving nothing behind. That
|
|
547
|
+
// also stops a later PASS for a different host from re-persisting a pass
|
|
548
|
+
// that a newer HOLD already superseded: after a restart the HOLD is gone,
|
|
549
|
+
// and that resurrected pass would re-open the gate on a held host.
|
|
550
|
+
//
|
|
551
|
+
// The durable set comes from merged state, never memory alone: in a cold
|
|
552
|
+
// process memory holds only the record just made, so using memory alone
|
|
553
|
+
// would delete every other host's cached pass.
|
|
554
|
+
const durable = durableDogfoodRecords(mergedDogfoodRecords());
|
|
555
|
+
// A failed write here is not cosmetic: after a HOLD it leaves the stale pass
|
|
556
|
+
// on disk, and a restart would serve it as evidence. Journal the failure so
|
|
557
|
+
// the durable state disagreeing with memory is visible.
|
|
558
|
+
if (!persistDogfoodCache(durable)) {
|
|
559
|
+
audit({
|
|
560
|
+
ts: new Date().toISOString(),
|
|
561
|
+
sessionID: targetSessionID,
|
|
562
|
+
tool: "dogfood.cache",
|
|
563
|
+
decision: "allow",
|
|
564
|
+
phase: "event",
|
|
565
|
+
reason: "dogfood_cache_write_failed",
|
|
566
|
+
evidence: { dogfoodVerdict: stored.verdict === "hold" ? "hold" : "pass", dogfoodHost: stored.host.label },
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return stored;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Collapses records that describe the same run, so merging memory with disk
|
|
574
|
+
* does not double count. Retention and the durable cache both count records,
|
|
575
|
+
* so a duplicate would let itself evict a sibling host's only pass.
|
|
576
|
+
*/
|
|
577
|
+
function dedupeDogfoodRecords(records: DogfoodResult[]): DogfoodResult[] {
|
|
578
|
+
const seen = new Set<string>();
|
|
579
|
+
const unique: DogfoodResult[] = [];
|
|
580
|
+
for (const record of records) {
|
|
581
|
+
// Workspace is part of the identity: the cache is shared across
|
|
582
|
+
// repositories, so two runs of the same scenario on the same host label
|
|
583
|
+
// in different workspaces are distinct evidence. Verdict is part of it
|
|
584
|
+
// too, so a PASS and a HOLD recorded in the same millisecond stay
|
|
585
|
+
// distinct and the HOLD can win.
|
|
586
|
+
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 ?? ""}`;
|
|
587
|
+
if (seen.has(identity)) continue;
|
|
588
|
+
seen.add(identity);
|
|
589
|
+
unique.push(record);
|
|
590
|
+
}
|
|
591
|
+
return unique;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Candidate dogfood records for one worktree.
|
|
596
|
+
*
|
|
597
|
+
* Memory and disk are merged, so a pass recorded in a previous process is
|
|
598
|
+
* still a candidate next to one recorded now.
|
|
599
|
+
*
|
|
600
|
+
* Exact-worktree candidates win over same-repository ones, so a sibling
|
|
601
|
+
* worktree's run cannot displace this worktree's evidence. Same-repository
|
|
602
|
+
* candidates are returned when this worktree has none, which is what lets a
|
|
603
|
+
* run in a linked worktree satisfy the parent when the tracked content matches.
|
|
604
|
+
* Freshness is judged per record by the caller, never here.
|
|
605
|
+
*/
|
|
606
|
+
export function getDogfoodRecordsForWorkspace(root: string): DogfoodResult[] {
|
|
607
|
+
const workspace = projectRootKey(root);
|
|
608
|
+
const all = mergedDogfoodRecords().filter(
|
|
609
|
+
(result): result is DogfoodResult & { workspace: string } => result?.workspace != null,
|
|
610
|
+
);
|
|
611
|
+
const exact = all.filter((result) => projectRootKey(result.workspace) === workspace);
|
|
612
|
+
if (exact.length > 0) return exact;
|
|
613
|
+
return all.filter((result) => isSameGitRepo(result.workspace, root));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function getLastDogfoodResultForWorkspace(root: string): DogfoodResult | undefined {
|
|
617
|
+
const records = getDogfoodRecordsForWorkspace(root);
|
|
618
|
+
return records.reduce<DogfoodResult | undefined>((best, result) => !best || result.timestamp > best.timestamp ? result : best, undefined);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function resetDogfoodState(): void {
|
|
622
|
+
dogfoodRecords = [];
|
|
623
|
+
try {
|
|
624
|
+
const p = getDogfoodCacheFilePath();
|
|
625
|
+
if (existsSync(p)) unlinkSync(p);
|
|
626
|
+
} catch {}
|
|
627
|
+
}
|
|
628
|
+
|
|
457
629
|
export async function resolveEffectiveWorkspace(options: {
|
|
458
630
|
sessionID?: string;
|
|
459
631
|
directory?: string;
|
|
@@ -543,6 +715,54 @@ export function isDocumentationRequired(root: string): boolean {
|
|
|
543
715
|
return cfg.requireDocumentation === true;
|
|
544
716
|
}
|
|
545
717
|
|
|
718
|
+
/**
|
|
719
|
+
* Dogfood gate (pre-deployment evidence). Off by default, per the rule that
|
|
720
|
+
* every write path ships opt-in first: a repository opts in only once its
|
|
721
|
+
* runner exists, and an unset flag keeps today's PR behavior.
|
|
722
|
+
*/
|
|
723
|
+
export function isDogfoodRequired(root: string): boolean {
|
|
724
|
+
const env = process.env.WORKFLOW_GUARD_REQUIRE_DOGFOOD?.toLowerCase();
|
|
725
|
+
if (env === "0" || env === "false" || env === "off") return false;
|
|
726
|
+
if (env === "1" || env === "true" || env === "on") return true;
|
|
727
|
+
return getProjectConfig(root).requireDogfood === true;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Host labels that must each carry a passing dogfood record. Unset means the
|
|
732
|
+
* recorded host alone satisfies the gate, so a single-machine setup needs no
|
|
733
|
+
* matrix. Blank and duplicate labels are dropped; a non-array value is
|
|
734
|
+
* ignored rather than throwing, matching the other config readers.
|
|
735
|
+
*/
|
|
736
|
+
export function getDogfoodDeployMatrix(root: string): string[] {
|
|
737
|
+
const env = process.env.WORKFLOW_GUARD_DOGFOOD_DEPLOY_MATRIX;
|
|
738
|
+
const raw = env !== undefined && env.trim() !== "" ? env.split(",") : getProjectConfig(root).dogfoodDeployMatrix;
|
|
739
|
+
if (!Array.isArray(raw)) return [];
|
|
740
|
+
return [...new Set(raw.filter((value): value is string => typeof value === "string").map((value) => value.trim()).filter(Boolean))];
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Dogfood recursion depth for this process. The runner sets
|
|
745
|
+
* `WORKFLOW_GUARD_DOGFOOD_DEPTH` on the actor it spawns, so the marker is
|
|
746
|
+
* inherited rather than handshaken. Unparseable or negative values fail
|
|
747
|
+
* closed to depth 1, because a corrupt marker must not read as a clean root.
|
|
748
|
+
*/
|
|
749
|
+
export function getDogfoodDepth(): number {
|
|
750
|
+
const env = process.env.WORKFLOW_GUARD_DOGFOOD_DEPTH;
|
|
751
|
+
if (env === undefined || env.trim() === "") return 0;
|
|
752
|
+
const parsed = Number(env.trim());
|
|
753
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 1;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* True when this process is a dogfood actor, that is, a session spawned by the
|
|
758
|
+
* runner to be tested. Every policy still applies; the marker only refuses
|
|
759
|
+
* self-certification and stops dogfood evidence from being persisted.
|
|
760
|
+
*/
|
|
761
|
+
export function isDogfoodActor(): boolean {
|
|
762
|
+
if (process.env.WORKFLOW_GUARD_DOGFOOD_ACTOR === "1") return true;
|
|
763
|
+
return getDogfoodDepth() > 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
546
766
|
/**
|
|
547
767
|
* Policy 25 (live-documentation research floor): enabled by default. The env
|
|
548
768
|
* 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;
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -41,7 +41,9 @@ import {
|
|
|
41
41
|
isLiveDocsRequired,
|
|
42
42
|
getLiveDocFailureThreshold,
|
|
43
43
|
getResearchToolPatterns,
|
|
44
|
+
isDogfoodRequired,
|
|
44
45
|
} from "./state.ts";
|
|
46
|
+
|
|
45
47
|
import {
|
|
46
48
|
openProjectMemory,
|
|
47
49
|
ensureProjectMemoryExcluded,
|
|
@@ -72,7 +74,7 @@ import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
|
|
|
72
74
|
import { loadedPluginVersion } from "./version.ts";
|
|
73
75
|
import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
|
|
74
76
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
|
|
75
|
-
import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
|
|
77
|
+
import { buildCompactionContext, evaluateDogfoodGate, guardToolCall } from "../workflow-guard.ts";
|
|
76
78
|
import { registerPluginInstance, releasePluginInstance } from "./plugin-instance.ts";
|
|
77
79
|
import { claimToolCall, releaseToolCallClaim } from "./call-claims.ts";
|
|
78
80
|
import { evaluatePermissionSeat } from "./permission-seat.ts";
|
|
@@ -459,6 +461,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
459
461
|
projectMemoryEnabled,
|
|
460
462
|
learningEnabled,
|
|
461
463
|
recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
|
|
464
|
+
dogfoodRequired: isDogfoodRequired(effectiveRoot),
|
|
462
465
|
isReadOnly,
|
|
463
466
|
});
|
|
464
467
|
event.system.push({ type: "text", text: guidance });
|
|
@@ -518,6 +521,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
518
521
|
`- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
|
|
519
522
|
);
|
|
520
523
|
}
|
|
524
|
+
const dogfoodCtxGate = evaluateDogfoodGate(effectiveRoot);
|
|
525
|
+
if (dogfoodCtxGate.required) {
|
|
526
|
+
stateLines.push(
|
|
527
|
+
dogfoodCtxGate.missingHosts.length === 0
|
|
528
|
+
? `- Dogfood Evidence: PASS on ${(dogfoodCtxGate.matchedRecords ?? []).map((record) => record.host.label).join(", ") || "recorded host"}`
|
|
529
|
+
: `- Dogfood Evidence: MISSING for ${dogfoodCtxGate.missingHosts.join(", ")} (required before PR creation)`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
521
532
|
let openFollowups: ReturnType<typeof listReviewFollowups> = [];
|
|
522
533
|
try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
|
|
523
534
|
if (openFollowups.length > 0) {
|
package/src/workflow-guard.ts
CHANGED
|
@@ -24,9 +24,11 @@ import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
|
24
24
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
25
25
|
import { guardToolCallImpl, isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
|
|
26
26
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
27
|
+
import { evaluateDogfoodGate } from "./lib/dogfood.ts";
|
|
27
28
|
import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
|
|
28
29
|
export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
29
30
|
export { extractReviewFollowups, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
31
|
+
export { evaluateDogfoodGate } from "./lib/dogfood.ts";
|
|
30
32
|
|
|
31
33
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
32
34
|
export type {
|
|
@@ -47,7 +49,7 @@ export type {
|
|
|
47
49
|
LearningOpportunity,
|
|
48
50
|
} from "./lib/types.ts";
|
|
49
51
|
export type { TodoSdkClient };
|
|
50
|
-
export { verificationEvidence, reviewEvidence, toolOutcomeEvidence, policyDecisionEvidence, lifecycleStateEvidence, agentAssertionEvidence, evidenceMatchesSubject, isEvidenceFresh } from "./lib/evidence.ts";
|
|
52
|
+
export { verificationEvidence, reviewEvidence, dogfoodEvidence, toolOutcomeEvidence, policyDecisionEvidence, lifecycleStateEvidence, agentAssertionEvidence, evidenceMatchesSubject, isEvidenceFresh } from "./lib/evidence.ts";
|
|
51
53
|
export { getRalphOutcome } from "./policies/continuation.ts";
|
|
52
54
|
|
|
53
55
|
// ── State & runtime context ──────────────────────────────────────────────────
|
|
@@ -76,6 +78,10 @@ import {
|
|
|
76
78
|
isReviewRequired,
|
|
77
79
|
isSubagentReviewRequired,
|
|
78
80
|
isDocumentationRequired,
|
|
81
|
+
isDogfoodRequired,
|
|
82
|
+
getDogfoodDeployMatrix,
|
|
83
|
+
getDogfoodDepth,
|
|
84
|
+
isDogfoodActor,
|
|
79
85
|
isLiveDocsRequired,
|
|
80
86
|
getLiveDocFailureThreshold,
|
|
81
87
|
getResearchToolPatterns,
|
|
@@ -88,6 +94,10 @@ import {
|
|
|
88
94
|
getLastReviewResultForWorkspace,
|
|
89
95
|
resetReviewState,
|
|
90
96
|
sessionReviews,
|
|
97
|
+
recordDogfoodResult,
|
|
98
|
+
getLastDogfoodResultForWorkspace,
|
|
99
|
+
getDogfoodRecordsForWorkspace,
|
|
100
|
+
resetDogfoodState,
|
|
91
101
|
setSessionWorkspace,
|
|
92
102
|
getSessionWorkspace,
|
|
93
103
|
getSessionBoundWorkspace,
|
|
@@ -117,6 +127,10 @@ export {
|
|
|
117
127
|
isReviewRequired,
|
|
118
128
|
isSubagentReviewRequired,
|
|
119
129
|
isDocumentationRequired,
|
|
130
|
+
isDogfoodRequired,
|
|
131
|
+
getDogfoodDeployMatrix,
|
|
132
|
+
getDogfoodDepth,
|
|
133
|
+
isDogfoodActor,
|
|
120
134
|
isLiveDocsRequired,
|
|
121
135
|
getLiveDocFailureThreshold,
|
|
122
136
|
getResearchToolPatterns,
|
|
@@ -127,6 +141,10 @@ export {
|
|
|
127
141
|
getLastReviewResult,
|
|
128
142
|
getLastReviewResultForWorkspace,
|
|
129
143
|
resetReviewState,
|
|
144
|
+
recordDogfoodResult,
|
|
145
|
+
getLastDogfoodResultForWorkspace,
|
|
146
|
+
getDogfoodRecordsForWorkspace,
|
|
147
|
+
resetDogfoodState,
|
|
130
148
|
setSessionWorkspace,
|
|
131
149
|
getSessionWorkspace,
|
|
132
150
|
getSessionBoundWorkspace,
|
|
@@ -194,7 +212,7 @@ import {
|
|
|
194
212
|
summarizeInput,
|
|
195
213
|
} from "./lib/audit.ts";
|
|
196
214
|
|
|
197
|
-
export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
|
|
215
|
+
export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getDogfoodCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, persistDogfoodCache, loadDogfoodCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
|
|
198
216
|
export { summarizeInput };
|
|
199
217
|
|
|
200
218
|
export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -694,6 +712,14 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
694
712
|
`- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
|
|
695
713
|
);
|
|
696
714
|
}
|
|
715
|
+
const dogfoodCtxGate = evaluateDogfoodGate(effectiveRoot);
|
|
716
|
+
if (dogfoodCtxGate.required) {
|
|
717
|
+
stateLines.push(
|
|
718
|
+
dogfoodCtxGate.missingHosts.length === 0
|
|
719
|
+
? `- Dogfood Evidence: PASS on ${(dogfoodCtxGate.matchedRecords ?? []).map((record) => record.host.label).join(", ") || "recorded host"}`
|
|
720
|
+
: `- Dogfood Evidence: MISSING for ${dogfoodCtxGate.missingHosts.join(", ")} (required before PR creation)`,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
697
723
|
let openFollowups: ReturnType<typeof listReviewFollowups> = [];
|
|
698
724
|
try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
|
|
699
725
|
if (openFollowups.length > 0) {
|
|
@@ -838,6 +864,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
838
864
|
projectMemoryEnabled,
|
|
839
865
|
learningEnabled,
|
|
840
866
|
recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
|
|
867
|
+
dogfoodRequired: isDogfoodRequired(effectiveRoot),
|
|
841
868
|
isReadOnly,
|
|
842
869
|
});
|
|
843
870
|
output.system.push(guidance);
|