@tea-agent/loop-agent 0.14.0 → 0.16.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/AGENTS.md +1 -1
- package/CHANGELOG.md +98 -11
- package/README.md +24 -5
- package/dist/application/evaluation/alias.js +184 -0
- package/dist/application/evaluation/budget.js +192 -0
- package/dist/application/evaluation/campaign-hash.js +47 -0
- package/dist/application/evaluation/campaign-matrix.js +372 -0
- package/dist/application/evaluation/campaign-scorecard.js +135 -0
- package/dist/application/evaluation/campaign.js +370 -0
- package/dist/application/evaluation/candidate.js +23 -6
- package/dist/application/evaluation/corpus-hash.js +38 -0
- package/dist/application/evaluation/corpus.js +56 -0
- package/dist/application/evaluation/experiment.js +294 -0
- package/dist/application/evaluation/ignition.js +198 -0
- package/dist/application/evaluation/integrity-audit.js +162 -0
- package/dist/application/evaluation/outer-loop.js +132 -0
- package/dist/application/evaluation/pi-cell-executor.js +39 -0
- package/dist/application/evaluation/private-verifier.js +46 -0
- package/dist/application/evaluation/promotion-policy.js +151 -0
- package/dist/application/evaluation/proposer.js +98 -0
- package/dist/application/evaluation/types.js +522 -0
- package/dist/cli/command-definitions.js +19 -3
- package/dist/commands/eval.js +1176 -13
- package/dist/commands/init.js +4 -1
- package/dist/infrastructure/evaluation/alias-store.js +199 -0
- package/dist/infrastructure/evaluation/campaign-store.js +154 -0
- package/dist/infrastructure/evaluation/corpus-store.js +181 -0
- package/dist/infrastructure/evaluation/experiment-store.js +124 -0
- package/dist/infrastructure/evaluation/ignition-store.js +82 -0
- package/dist/infrastructure/evaluation/private-verifier-store.js +145 -0
- package/dist/infrastructure/evaluation/proposer-store.js +78 -0
- package/dist/worker/cli.js +6 -3
- package/dist/worker/delivery/final-verification.js +96 -8
- package/dist/worker/delivery/package.js +23 -4
- package/dist/worker/delivery/verification-bundle.js +521 -0
- package/dist/worker/feature/fullstack-validate.js +337 -0
- package/dist/worker/feature/profile-schema.js +44 -0
- package/dist/worker/feature/ready-plan-projection.js +1 -0
- package/dist/worker/feature/reducer.js +2 -0
- package/dist/worker/feature/review.js +106 -11
- package/dist/worker/materialize/harness-task-materializer.js +5 -0
- package/dist/worker/observability/read-model.js +7 -0
- package/dist/worker/observe/static/views/task.js +1 -0
- package/dist/worker/outcomes/adapters.js +144 -0
- package/dist/worker/outcomes/evidence-tokens.js +29 -0
- package/dist/worker/outcomes/gate.js +40 -0
- package/dist/worker/outcomes/projector.js +185 -0
- package/dist/worker/outcomes/registry.js +1 -0
- package/dist/worker/outcomes/store.js +131 -0
- package/dist/worker/outcomes/types.js +79 -0
- package/dist/worker/report/morning-report.js +4 -3
- package/dist/worker/run-task/run-task.js +85 -2
- package/dist/worker/runner/run-ready.js +32 -1
- package/dist/worker/task-graph/acceptance-schema.js +12 -0
- package/dist/worker/task-graph/ready-planner.js +131 -0
- package/dist/worker/task-graph/task-graph-schema.js +31 -0
- package/dist/worker/task-graph/validate.js +44 -4
- package/dist/worker/task-spec/schema.js +9 -0
- package/dist/worker/task-spec/validate.js +39 -0
- package/dist/worker/task-spec/workflow-routing.js +149 -0
- package/dist/workflows/dag/budget-enforcement.js +67 -0
- package/dist/workflows/dag/context-policy.js +137 -0
- package/dist/workflows/dag/init-hybrid.js +27 -11
- package/dist/workflows/dag/knowledge-curator.js +3 -0
- package/dist/workflows/dag/node-execution.js +11 -4
- package/dist/workflows/dag/prompt.js +1 -1
- package/dist/workflows/dag/runner.js +43 -16
- package/dist/workflows/dag/skill-snapshot.js +11 -7
- package/dist/workflows/dag/types.js +18 -0
- package/docs/README.md +1 -0
- package/docs/init-surface.manifest.json +7 -7
- package/docs/templates/branch-merge-report.md +0 -1
- package/docs/templates/evaluation/campaign-budget-v1.json +12 -0
- package/docs/templates/evaluation/campaign-dogfood-v0.json +24 -0
- package/docs/templates/evaluation/campaign-evidence-v1.json +44 -0
- package/docs/templates/evaluation/context-policy-baseline-v1.json +17 -0
- package/docs/templates/evaluation/context-policy-role-specialized-v1.json +28 -0
- package/docs/templates/evaluation/corpus-dogfood-v0.manifest.json +118 -0
- package/docs/templates/evaluation/matrix-dag-dry-run-v1.json +21 -0
- package/docs/templates/evaluation/matrix-fixture-v1.json +10 -0
- package/docs/templates/evaluation/private-verifier-dogfood-v0.json +16 -0
- package/docs/templates/product-line/AGENTS.md +1 -0
- package/docs/templates/product-line/README.md +17 -0
- package/docs/templates/product-line/acceptance.yaml +9 -0
- package/docs/templates/product-line/feature.yaml +11 -0
- package/docs/templates/product-line/task-graph.yaml +8 -0
- package/docs/templates/product-line/task.yaml +4 -0
- package/package.json +6 -16
- package/skills/browser-tools/SKILL.md +2 -2
- package/skills/frontend-design-review/references/review-checklist.md +27 -45
- package/skills/frontend-implementation/references/node-contracts.md +4 -4
- package/skills/frontend-review/SKILL.md +3 -1
- package/skills/frontend-review/references/review-findings.md +2 -1
- package/skills/frontend-verification/SKILL.md +3 -1
- package/skills/frontend-verification/references/verification-checklist.md +13 -22
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/docs/agent-dag-recovery-playbook.md +0 -195
- package/docs/agent-dag-runner.md +0 -67
- package/docs/cursor-prompt-sidecar.md +0 -36
- package/docs/decisions/README.md +0 -18
- package/docs/design/README.md +0 -167
- package/docs/development-principles.md +0 -73
- package/docs/exec-plans/README.md +0 -6
- package/docs/exec-plans/active/README.md +0 -13
- package/docs/exec-plans/completed/README.md +0 -108
- package/docs/feature-workflow.md +0 -414
- package/docs/loop-agent-harness.md +0 -142
- package/docs/production-readiness.md +0 -96
- package/docs/progress/README.md +0 -81
- package/docs/reports/README.md +0 -163
- package/docs/verification-matrix.md +0 -70
- package/scripts/check-product-line-docs.sh +0 -29
- package/scripts/check-task-pool-root.sh +0 -32
- package/scripts/kb-graph-incremental-prepare.sh +0 -5
- package/scripts/kb-graph-materialize.sh +0 -4
- package/scripts/kb-graph-promote.sh +0 -4
- package/scripts/kb-query.sh +0 -5
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createCampaign, validateCampaign, } from "./campaign.js";
|
|
4
|
+
import { buildScorecardFromMatrixEvidence, formatMatrixRunMarkdown, runCampaignMatrix, } from "./campaign-matrix.js";
|
|
5
|
+
import { formatProposerRunMarkdown, proposeFromCurate } from "./proposer.js";
|
|
6
|
+
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
7
|
+
import { campaignManifestInputSchema } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* M7 outer-loop orchestration:
|
|
10
|
+
* optional bounded propose → ensure/create campaign → matrix → optional scorecard.
|
|
11
|
+
* Never calls promote/alias APIs.
|
|
12
|
+
*/
|
|
13
|
+
export async function runOuterLoop(input) {
|
|
14
|
+
let proposerRun;
|
|
15
|
+
if (input.proposeFromCurate) {
|
|
16
|
+
proposerRun = await proposeFromCurate({
|
|
17
|
+
repoRoot: input.repoRoot,
|
|
18
|
+
maxCandidates: input.maxProposals ?? 1,
|
|
19
|
+
dryRun: false,
|
|
20
|
+
now: input.now,
|
|
21
|
+
});
|
|
22
|
+
if (proposerRun.proposals.length === 0) {
|
|
23
|
+
throw new Error(`outer-loop propose produced no candidates: ${proposerRun.skippedReasons.join("; ")}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
let campaignId = input.campaignId;
|
|
27
|
+
if (input.campaignManifestPath) {
|
|
28
|
+
const absolute = path.resolve(input.repoRoot, input.campaignManifestPath);
|
|
29
|
+
const raw = JSON.parse(await readFile(absolute, "utf-8"));
|
|
30
|
+
if (proposerRun && proposerRun.proposals.length > 0) {
|
|
31
|
+
raw.challengerCandidateIds = proposerRun.proposals.map((row) => row.candidateId);
|
|
32
|
+
}
|
|
33
|
+
const parsed = campaignManifestInputSchema.parse(raw);
|
|
34
|
+
if (campaignId && parsed.campaignId !== campaignId) {
|
|
35
|
+
throw new Error(`campaignId mismatch: --campaign-id=${campaignId}, manifest=${parsed.campaignId}`);
|
|
36
|
+
}
|
|
37
|
+
campaignId = parsed.campaignId;
|
|
38
|
+
const staging = path.join(input.repoRoot, ".harness/evaluation/outer-loop", `${parsed.campaignId}.manifest.json`);
|
|
39
|
+
await writeJsonAtomic(staging, parsed, { repoRoot: input.repoRoot });
|
|
40
|
+
await validateCampaign({
|
|
41
|
+
repoRoot: input.repoRoot,
|
|
42
|
+
manifestPath: staging,
|
|
43
|
+
});
|
|
44
|
+
await createCampaign({
|
|
45
|
+
repoRoot: input.repoRoot,
|
|
46
|
+
manifestPath: staging,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (!campaignId) {
|
|
50
|
+
throw new Error("outer-loop requires --campaign-id or --campaign-manifest");
|
|
51
|
+
}
|
|
52
|
+
if (proposerRun && !input.campaignManifestPath) {
|
|
53
|
+
// Campaign must already list proposed challengers.
|
|
54
|
+
const { showCampaign } = await import("./campaign.js");
|
|
55
|
+
const existing = await showCampaign({
|
|
56
|
+
repoRoot: input.repoRoot,
|
|
57
|
+
campaignId,
|
|
58
|
+
});
|
|
59
|
+
for (const proposal of proposerRun.proposals) {
|
|
60
|
+
if (!existing.manifest.challengerCandidateIds.includes(proposal.candidateId)) {
|
|
61
|
+
throw new Error(`proposed candidate ${proposal.candidateId} is not in campaign ${campaignId} challengers; recreate campaign with --campaign-manifest or add challenger ids`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const matrixResult = await runCampaignMatrix({
|
|
66
|
+
repoRoot: input.repoRoot,
|
|
67
|
+
campaignId,
|
|
68
|
+
mode: input.matrixMode,
|
|
69
|
+
fixturePath: input.fixturePath,
|
|
70
|
+
maxPublicCells: input.maxPublicCells,
|
|
71
|
+
includePrivateChecks: true,
|
|
72
|
+
writeEvidenceDraft: true,
|
|
73
|
+
piExecutor: input.piExecutor,
|
|
74
|
+
now: input.now,
|
|
75
|
+
});
|
|
76
|
+
let scorecardBuilt = false;
|
|
77
|
+
let promotionEligible;
|
|
78
|
+
if (input.buildScorecard && matrixResult.evidence) {
|
|
79
|
+
// Scorecard from matrix draft alone is usually insufficient for held_out;
|
|
80
|
+
// still persist for inspection when requested.
|
|
81
|
+
try {
|
|
82
|
+
await buildScorecardFromMatrixEvidence({
|
|
83
|
+
repoRoot: input.repoRoot,
|
|
84
|
+
campaignId,
|
|
85
|
+
evidence: matrixResult.evidence,
|
|
86
|
+
});
|
|
87
|
+
scorecardBuilt = true;
|
|
88
|
+
const { readCampaignScorecard } = await import("../../infrastructure/evaluation/campaign-store.js");
|
|
89
|
+
const scorecard = await readCampaignScorecard(input.repoRoot, campaignId);
|
|
90
|
+
promotionEligible = scorecard?.policy.promotionEligible;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
scorecardBuilt = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const result = {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
proposerRun,
|
|
99
|
+
campaignId,
|
|
100
|
+
matrix: matrixResult.matrix,
|
|
101
|
+
scorecardBuilt,
|
|
102
|
+
promotionEligible,
|
|
103
|
+
autoPromote: false,
|
|
104
|
+
next: [
|
|
105
|
+
`loop-agent eval campaign show --campaign-id ${campaignId} --json`,
|
|
106
|
+
`loop-agent eval campaign scorecard --campaign-id ${campaignId} --evidence .harness/evaluation/campaigns/${campaignId}/evidence-draft.json --json`,
|
|
107
|
+
`loop-agent eval promote --alias <name> --to-candidate <id> --campaign-id ${campaignId} --reason <text> --dry-run`,
|
|
108
|
+
"NEVER auto-promote: human --apply required after policy pass",
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
export function formatOuterLoopMarkdown(result) {
|
|
114
|
+
const lines = [
|
|
115
|
+
`# Outer Loop: ${result.campaignId}`,
|
|
116
|
+
"",
|
|
117
|
+
`- autoPromote: \`${result.autoPromote}\``,
|
|
118
|
+
`- scorecardBuilt: ${result.scorecardBuilt}`,
|
|
119
|
+
`- promotionEligible: ${result.promotionEligible ?? "(n/a)"}`,
|
|
120
|
+
"",
|
|
121
|
+
];
|
|
122
|
+
if (result.proposerRun) {
|
|
123
|
+
lines.push(formatProposerRunMarkdown(result.proposerRun));
|
|
124
|
+
}
|
|
125
|
+
lines.push(formatMatrixRunMarkdown(result.matrix));
|
|
126
|
+
lines.push("## Next", "");
|
|
127
|
+
for (const step of result.next) {
|
|
128
|
+
lines.push(`- ${step}`);
|
|
129
|
+
}
|
|
130
|
+
lines.push("");
|
|
131
|
+
return `${lines.join("\n")}\n`;
|
|
132
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { runDagUseCase } from "../dag/run-dag.js";
|
|
4
|
+
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
5
|
+
/**
|
|
6
|
+
* M7 live-Pi wiring (bounded): materialize a per-cell DagSpec with evaluation
|
|
7
|
+
* binding and run `runDagUseCase` in dry-run mode only.
|
|
8
|
+
*
|
|
9
|
+
* Never auto-promotes. Full live Pi execute remains opt-in via custom PiCellExecutor.
|
|
10
|
+
*/
|
|
11
|
+
export function createDagDryRunPiCellExecutor(input) {
|
|
12
|
+
return async (ctx) => {
|
|
13
|
+
const absoluteTemplate = path.resolve(ctx.repoRoot, input.dagTemplatePath);
|
|
14
|
+
const raw = JSON.parse(await readFile(absoluteTemplate, "utf-8"));
|
|
15
|
+
raw.version = 3;
|
|
16
|
+
raw.evaluation = ctx.evaluation;
|
|
17
|
+
raw.title =
|
|
18
|
+
typeof raw.title === "string"
|
|
19
|
+
? `${raw.title} :: ${ctx.cell.cellId}`
|
|
20
|
+
: `eval-matrix-${ctx.cell.cellId}`;
|
|
21
|
+
const cellDagPath = path.join(ctx.repoRoot, path.dirname(ctx.jobPath), `${ctx.cell.cellId}.dag.json`);
|
|
22
|
+
await writeJsonAtomic(cellDagPath, raw, { repoRoot: ctx.repoRoot });
|
|
23
|
+
const result = await runDagUseCase({
|
|
24
|
+
repoRoot: ctx.repoRoot,
|
|
25
|
+
dagPath: cellDagPath,
|
|
26
|
+
cwd: ctx.repoRoot,
|
|
27
|
+
initOnly: false,
|
|
28
|
+
dryRun: true,
|
|
29
|
+
});
|
|
30
|
+
if (!("mode" in result) || result.mode !== "dry-run") {
|
|
31
|
+
throw new Error("createDagDryRunPiCellExecutor expected dry-run result; refuse non-dry execution");
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
verifyPassed: false,
|
|
35
|
+
runId: result.runId,
|
|
36
|
+
detail: `pi dry-run prepared runDir=${result.runDir}; verifyPassed left false until live shell evidence`,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { listPrivateVerifierIds, loadPrivateVerifierManifestFromPath, materializePrivateVerifierManifest, readPrivateVerifierManifest, registerPrivateVerifierManifest, } from "../../infrastructure/evaluation/private-verifier-store.js";
|
|
2
|
+
export async function validatePrivateVerifier(input) {
|
|
3
|
+
const raw = await loadPrivateVerifierManifestFromPath(input.repoRoot, input.manifestPath);
|
|
4
|
+
const manifest = await materializePrivateVerifierManifest(raw);
|
|
5
|
+
return { manifest };
|
|
6
|
+
}
|
|
7
|
+
export async function registerPrivateVerifier(input) {
|
|
8
|
+
const validated = await validatePrivateVerifier(input);
|
|
9
|
+
return registerPrivateVerifierManifest({
|
|
10
|
+
repoRoot: input.repoRoot,
|
|
11
|
+
manifest: validated.manifest,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export async function showPrivateVerifier(input) {
|
|
15
|
+
const manifest = await readPrivateVerifierManifest(input.repoRoot, input.privateVerifierId);
|
|
16
|
+
return { manifest };
|
|
17
|
+
}
|
|
18
|
+
export async function listPrivateVerifiers(input) {
|
|
19
|
+
const ids = await listPrivateVerifierIds(input.repoRoot);
|
|
20
|
+
const rows = [];
|
|
21
|
+
for (const privateVerifierId of ids) {
|
|
22
|
+
const manifest = await readPrivateVerifierManifest(input.repoRoot, privateVerifierId);
|
|
23
|
+
rows.push({
|
|
24
|
+
privateVerifierId,
|
|
25
|
+
manifestHash: manifest.manifestHash,
|
|
26
|
+
checkCount: manifest.checks.length,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return rows;
|
|
30
|
+
}
|
|
31
|
+
export function formatPrivateVerifierMarkdown(manifest) {
|
|
32
|
+
return [
|
|
33
|
+
`# Private Verifier: ${manifest.privateVerifierId}`,
|
|
34
|
+
"",
|
|
35
|
+
`- manifestHash: \`${manifest.manifestHash}\``,
|
|
36
|
+
`- checks: ${manifest.checks.length}`,
|
|
37
|
+
`- createdAt: \`${manifest.createdAt}\``,
|
|
38
|
+
"",
|
|
39
|
+
"> Controller-only. Must never enter candidate prompt, skill snapshot, or contentRefs.",
|
|
40
|
+
"",
|
|
41
|
+
"## Checks",
|
|
42
|
+
"",
|
|
43
|
+
...manifest.checks.map((check) => `- \`${check.checkId}\` (${check.kind}): command arity=${check.command.length}, timeoutMs=${check.timeoutMs}`),
|
|
44
|
+
"",
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { runIntegrityAudits } from "./integrity-audit.js";
|
|
2
|
+
export const PROMOTION_POLICY_VERSION = "promotion-policy-v1";
|
|
3
|
+
function emptyTally() {
|
|
4
|
+
return { incumbentWins: 0, challengerWins: 0, ties: 0 };
|
|
5
|
+
}
|
|
6
|
+
function pairKey(cell) {
|
|
7
|
+
return `${cell.split}\u0000${cell.taskRef}\u0000${cell.seed}`;
|
|
8
|
+
}
|
|
9
|
+
function tallyPaired(input) {
|
|
10
|
+
const byKey = new Map();
|
|
11
|
+
for (const cell of input.cells) {
|
|
12
|
+
if (cell.split !== input.split)
|
|
13
|
+
continue;
|
|
14
|
+
const key = pairKey(cell);
|
|
15
|
+
const row = byKey.get(key) ?? {};
|
|
16
|
+
if (cell.candidateId === input.incumbentCandidateId) {
|
|
17
|
+
row.incumbent = cell.verifyPassed;
|
|
18
|
+
}
|
|
19
|
+
else if (cell.candidateId === input.challengerCandidateId) {
|
|
20
|
+
row.challenger = cell.verifyPassed;
|
|
21
|
+
}
|
|
22
|
+
byKey.set(key, row);
|
|
23
|
+
}
|
|
24
|
+
const tally = emptyTally();
|
|
25
|
+
for (const row of byKey.values()) {
|
|
26
|
+
if (row.incumbent === undefined || row.challenger === undefined)
|
|
27
|
+
continue;
|
|
28
|
+
if (row.challenger === row.incumbent) {
|
|
29
|
+
tally.ties += 1;
|
|
30
|
+
}
|
|
31
|
+
else if (row.challenger && !row.incumbent) {
|
|
32
|
+
tally.challengerWins += 1;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
tally.incumbentWins += 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return tally;
|
|
39
|
+
}
|
|
40
|
+
function privateImprovementStatus(tally) {
|
|
41
|
+
const compared = tally.incumbentWins + tally.challengerWins + tally.ties;
|
|
42
|
+
if (compared === 0)
|
|
43
|
+
return "insufficient";
|
|
44
|
+
if (tally.challengerWins > tally.incumbentWins)
|
|
45
|
+
return "pass";
|
|
46
|
+
return "fail";
|
|
47
|
+
}
|
|
48
|
+
function heldOutNonRegressionStatus(tally) {
|
|
49
|
+
const compared = tally.incumbentWins + tally.challengerWins + tally.ties;
|
|
50
|
+
if (compared === 0)
|
|
51
|
+
return "insufficient";
|
|
52
|
+
// Non-regression: challenger must not lose more pairs than incumbent.
|
|
53
|
+
if (tally.challengerWins + tally.ties >= tally.incumbentWins)
|
|
54
|
+
return "pass";
|
|
55
|
+
return "fail";
|
|
56
|
+
}
|
|
57
|
+
function budgetStatusGate(status) {
|
|
58
|
+
if (status === "ok")
|
|
59
|
+
return "pass";
|
|
60
|
+
if (status === "breached")
|
|
61
|
+
return "fail";
|
|
62
|
+
return "insufficient";
|
|
63
|
+
}
|
|
64
|
+
function safetyStatus(findings) {
|
|
65
|
+
if (findings.some((f) => f.severity === "fail"))
|
|
66
|
+
return "fail";
|
|
67
|
+
if (findings.some((f) => f.severity === "review"))
|
|
68
|
+
return "review";
|
|
69
|
+
return "pass";
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Evaluate Promotion Policy v1 (W5.1).
|
|
73
|
+
* Never auto-promotes; human gate remains mandatory at apply time.
|
|
74
|
+
*/
|
|
75
|
+
export function evaluatePromotionPolicy(input) {
|
|
76
|
+
if (input.evidence.campaignId !== input.campaignId) {
|
|
77
|
+
throw new Error(`evidence campaignId mismatch: expected ${input.campaignId}, got ${input.evidence.campaignId}`);
|
|
78
|
+
}
|
|
79
|
+
const findings = runIntegrityAudits({
|
|
80
|
+
evidence: input.evidence,
|
|
81
|
+
incumbentCandidateId: input.incumbentCandidateId,
|
|
82
|
+
});
|
|
83
|
+
const paired = {
|
|
84
|
+
private: tallyPaired({
|
|
85
|
+
incumbentCandidateId: input.incumbentCandidateId,
|
|
86
|
+
challengerCandidateId: input.evidence.challengerCandidateId,
|
|
87
|
+
cells: input.evidence.cells,
|
|
88
|
+
split: "private",
|
|
89
|
+
}),
|
|
90
|
+
held_out: tallyPaired({
|
|
91
|
+
incumbentCandidateId: input.incumbentCandidateId,
|
|
92
|
+
challengerCandidateId: input.evidence.challengerCandidateId,
|
|
93
|
+
cells: input.evidence.cells,
|
|
94
|
+
split: "held_out",
|
|
95
|
+
}),
|
|
96
|
+
public: tallyPaired({
|
|
97
|
+
incumbentCandidateId: input.incumbentCandidateId,
|
|
98
|
+
challengerCandidateId: input.evidence.challengerCandidateId,
|
|
99
|
+
cells: input.evidence.cells,
|
|
100
|
+
split: "public",
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
const safetyOk = safetyStatus(findings);
|
|
104
|
+
const gates = {
|
|
105
|
+
privateImprovement: privateImprovementStatus(paired.private),
|
|
106
|
+
heldOutNonRegression: heldOutNonRegressionStatus(paired.held_out),
|
|
107
|
+
budgetOk: budgetStatusGate(input.evidence.budgetStatus.challenger),
|
|
108
|
+
safetyOk,
|
|
109
|
+
humanRequired: true,
|
|
110
|
+
};
|
|
111
|
+
const reasons = [];
|
|
112
|
+
if (gates.privateImprovement !== "pass") {
|
|
113
|
+
reasons.push(`privateImprovement=${gates.privateImprovement} (challengerWins=${paired.private.challengerWins}, incumbentWins=${paired.private.incumbentWins}, ties=${paired.private.ties})`);
|
|
114
|
+
}
|
|
115
|
+
if (gates.heldOutNonRegression !== "pass") {
|
|
116
|
+
reasons.push(`heldOutNonRegression=${gates.heldOutNonRegression} (challengerWins=${paired.held_out.challengerWins}, incumbentWins=${paired.held_out.incumbentWins}, ties=${paired.held_out.ties})`);
|
|
117
|
+
}
|
|
118
|
+
if (gates.budgetOk !== "pass") {
|
|
119
|
+
reasons.push(`budgetOk=${gates.budgetOk}`);
|
|
120
|
+
}
|
|
121
|
+
if (gates.safetyOk === "fail") {
|
|
122
|
+
reasons.push(`safetyOk=fail (${findings.filter((f) => f.severity === "fail").map((f) => f.code).join(", ")})`);
|
|
123
|
+
}
|
|
124
|
+
else if (gates.safetyOk === "review") {
|
|
125
|
+
if (input.acknowledgeReview) {
|
|
126
|
+
reasons.push(`safetyOk=review acknowledged via --acknowledge-review (${findings.filter((f) => f.severity === "review").map((f) => f.code).join(", ")})`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
reasons.push(`safetyOk=review (${findings.filter((f) => f.severity === "review").map((f) => f.code).join(", ")}); re-run with --acknowledge-review after human review`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
reasons.push("humanRequired=true (apply still needs --reason)");
|
|
133
|
+
const safetyAllows = gates.safetyOk === "pass" ||
|
|
134
|
+
(gates.safetyOk === "review" && Boolean(input.acknowledgeReview));
|
|
135
|
+
const promotionEligible = gates.privateImprovement === "pass" &&
|
|
136
|
+
gates.heldOutNonRegression === "pass" &&
|
|
137
|
+
gates.budgetOk === "pass" &&
|
|
138
|
+
safetyAllows;
|
|
139
|
+
return {
|
|
140
|
+
schemaVersion: 1,
|
|
141
|
+
policyVersion: PROMOTION_POLICY_VERSION,
|
|
142
|
+
campaignId: input.campaignId,
|
|
143
|
+
incumbentCandidateId: input.incumbentCandidateId,
|
|
144
|
+
challengerCandidateId: input.evidence.challengerCandidateId,
|
|
145
|
+
promotionEligible,
|
|
146
|
+
gates,
|
|
147
|
+
findings,
|
|
148
|
+
reasons,
|
|
149
|
+
paired,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { writeProposerRun } from "../../infrastructure/evaluation/proposer-store.js";
|
|
3
|
+
import { createExperimentFromCurate } from "./experiment.js";
|
|
4
|
+
/**
|
|
5
|
+
* Controlled outer-loop proposer (M7).
|
|
6
|
+
* Only proposes bounded experimental candidates from knowledge-curate.
|
|
7
|
+
* Never modifies evaluator/kernel/private verifier and never auto-promotes.
|
|
8
|
+
*/
|
|
9
|
+
export async function proposeFromCurate(input) {
|
|
10
|
+
const maxCandidates = input.maxCandidates ?? 1;
|
|
11
|
+
if (maxCandidates < 1 || maxCandidates > 3) {
|
|
12
|
+
throw new Error("proposer maxCandidates must be between 1 and 3 (bounded)");
|
|
13
|
+
}
|
|
14
|
+
const dryRun = input.dryRun !== false;
|
|
15
|
+
const now = input.now ?? new Date().toISOString();
|
|
16
|
+
const proposerRunId = input.proposerRunId ??
|
|
17
|
+
`propose-${createHash("sha256").update(now).digest("hex").slice(0, 12)}`;
|
|
18
|
+
const proposals = [];
|
|
19
|
+
const skippedReasons = [];
|
|
20
|
+
if (dryRun) {
|
|
21
|
+
skippedReasons.push("dry-run: no candidate registered; re-run with --apply to materialize bounded proposals");
|
|
22
|
+
const run = {
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
proposerRunId,
|
|
25
|
+
source: "knowledge-curate",
|
|
26
|
+
createdAt: now,
|
|
27
|
+
maxCandidates,
|
|
28
|
+
proposals,
|
|
29
|
+
skippedReasons,
|
|
30
|
+
autoPromote: false,
|
|
31
|
+
};
|
|
32
|
+
return run;
|
|
33
|
+
}
|
|
34
|
+
for (let i = 0; i < maxCandidates; i += 1) {
|
|
35
|
+
try {
|
|
36
|
+
const created = await createExperimentFromCurate({
|
|
37
|
+
repoRoot: input.repoRoot,
|
|
38
|
+
now,
|
|
39
|
+
candidateId: `proposed-${proposerRunId}-${i + 1}`,
|
|
40
|
+
experimentId: `exp-${proposerRunId}-${i + 1}`,
|
|
41
|
+
});
|
|
42
|
+
proposals.push({
|
|
43
|
+
candidateId: created.candidate.manifest.candidateId,
|
|
44
|
+
experimentId: created.record.experimentId,
|
|
45
|
+
proposalPath: created.record.proposalPath,
|
|
46
|
+
bundleHash: created.candidate.manifest.bundleHash,
|
|
47
|
+
status: "proposed_experimenting",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
skippedReasons.push(error instanceof Error ? error.message : String(error));
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const run = {
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
proposerRunId,
|
|
58
|
+
source: "knowledge-curate",
|
|
59
|
+
createdAt: now,
|
|
60
|
+
maxCandidates,
|
|
61
|
+
proposals,
|
|
62
|
+
skippedReasons,
|
|
63
|
+
autoPromote: false,
|
|
64
|
+
};
|
|
65
|
+
await writeProposerRun({ repoRoot: input.repoRoot, run });
|
|
66
|
+
return run;
|
|
67
|
+
}
|
|
68
|
+
export function formatProposerRunMarkdown(run) {
|
|
69
|
+
const lines = [
|
|
70
|
+
`# Proposer Run: ${run.proposerRunId}`,
|
|
71
|
+
"",
|
|
72
|
+
`- source: \`${run.source}\``,
|
|
73
|
+
`- maxCandidates: ${run.maxCandidates}`,
|
|
74
|
+
`- proposals: ${run.proposals.length}`,
|
|
75
|
+
`- autoPromote: \`${run.autoPromote}\``,
|
|
76
|
+
"",
|
|
77
|
+
"## Proposals",
|
|
78
|
+
"",
|
|
79
|
+
];
|
|
80
|
+
if (run.proposals.length === 0) {
|
|
81
|
+
lines.push("(none)", "");
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
for (const proposal of run.proposals) {
|
|
85
|
+
lines.push(`- \`${proposal.candidateId}\` experiment=\`${proposal.experimentId}\` hash=\`${proposal.bundleHash}\``);
|
|
86
|
+
}
|
|
87
|
+
lines.push("");
|
|
88
|
+
}
|
|
89
|
+
if (run.skippedReasons.length > 0) {
|
|
90
|
+
lines.push("## Skipped / notes", "");
|
|
91
|
+
for (const reason of run.skippedReasons) {
|
|
92
|
+
lines.push(`- ${reason}`);
|
|
93
|
+
}
|
|
94
|
+
lines.push("");
|
|
95
|
+
}
|
|
96
|
+
lines.push("> M7: proposer only registers experimenting candidates; promotion remains human-gated.", "");
|
|
97
|
+
return `${lines.join("\n")}\n`;
|
|
98
|
+
}
|