@tea-agent/loop-agent 0.16.24 → 0.16.26
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/CHANGELOG.md +20 -0
- package/dist/cli/command-definitions.js +13 -0
- package/dist/cli/program.js +4 -0
- package/dist/commands/coverage-report.js +50 -0
- package/dist/executors/dag-pi-executor.js +63 -9
- package/dist/executors/shell-executor.js +30 -0
- package/dist/executors/shell-write-guard.js +64 -2
- package/dist/worker/delivery/git-transaction.js +43 -8
- package/dist/worker/observe/paths.js +81 -0
- package/dist/worker/observe/routes.js +127 -19
- package/dist/worker/observe/spec-evidence.js +84 -0
- package/dist/worker/observe/static/api.js +23 -0
- package/dist/worker/observe/static/state.js +26 -0
- package/dist/worker/observe/static/styles.css +10 -0
- package/dist/worker/observe/static/views/dag-inspector.js +173 -6
- package/dist/workflows/dag/backend-test-coverage-contract.js +202 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +84 -18
- package/dist/workflows/dag/backend-test-stability-contract.js +57 -0
- package/dist/workflows/dag/init-hybrid.js +150 -13
- package/dist/workflows/dag/l5-report-metrics.js +36 -0
- package/dist/workflows/dag/node-execution.js +32 -5
- package/dist/workflows/dag/project-governance-context.js +508 -0
- package/dist/workflows/dag/prompt.js +46 -1
- package/dist/workflows/dag/skill-snapshot.js +1 -0
- package/dist/workflows/dag/types.js +10 -0
- package/dist/workflows/dag/validate.js +28 -0
- package/docs/architecture/evolution.md +3 -1
- package/docs/templates/agent-dag.schema.json +15 -0
- package/docs/templates/agent-dag.supervised-implementation.json +1 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +2 -0
- package/docs/templates/backend-test-dag.json +39 -6
- package/docs/templates/backend-test-dag.retrospect.prompt.md +36 -7
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
function unavailable(reason, numerator = null, denominator = null, threshold = null) {
|
|
2
|
+
return { numerator, denominator, ratio: null, threshold, status: "unavailable", reason };
|
|
3
|
+
}
|
|
4
|
+
function ratioMetric(numerator, denominator, threshold, label) {
|
|
5
|
+
if (denominator === 0)
|
|
6
|
+
return unavailable(`${label}-denominator-is-zero`, numerator, denominator, threshold);
|
|
7
|
+
const ratio = numerator / denominator;
|
|
8
|
+
return { numerator, denominator, ratio, threshold, status: ratio >= threshold ? "pass" : "fail", reason: ratio >= threshold ? null : `${label}-below-threshold` };
|
|
9
|
+
}
|
|
10
|
+
export function computeL5ReportMetrics(input) {
|
|
11
|
+
const executed = input.result.passed + input.result.failed + input.result.error;
|
|
12
|
+
const metrics = {
|
|
13
|
+
passRate: ratioMetric(input.result.passed, executed, 1, "pass-rate"),
|
|
14
|
+
acCoverage: input.manifest.coverageSummary
|
|
15
|
+
? input.manifest.coverageSummary.explicitAcCount === 0
|
|
16
|
+
? { numerator: 0, denominator: 0, ratio: 1, threshold: 1, status: "pass", reason: null }
|
|
17
|
+
: ratioMetric(input.manifest.coverageSummary.coveredAcCount, input.manifest.coverageSummary.explicitAcCount, 1, "ac-coverage")
|
|
18
|
+
: unavailable("case-manifest-coverage-missing", null, null, 1),
|
|
19
|
+
automationCoverage: input.manifest.coverageSummary
|
|
20
|
+
? ratioMetric(input.manifest.coverageSummary.generatedCount, input.manifest.coverageSummary.caseCount, 0.9, "automation-coverage")
|
|
21
|
+
: unavailable("case-manifest-coverage-missing", null, null, 0.9),
|
|
22
|
+
stability: input.stability?.status === "available" && input.stability.ratio !== null
|
|
23
|
+
? { numerator: input.stability.successfulRuns, denominator: input.stability.recordedRuns, ratio: input.stability.ratio, threshold: 0.95, status: input.stability.ratio >= 0.95 ? "pass" : "fail", reason: input.stability.ratio >= 0.95 ? null : "stability-below-threshold" }
|
|
24
|
+
: unavailable(input.stability?.reason ?? "stability-evidence-missing", input.stability?.successfulRuns ?? null, input.stability?.recordedRuns ?? null, 0.95),
|
|
25
|
+
lineCoverage: input.coverage?.metrics.line.status === "available" && input.coverage.metrics.line.ratio !== null
|
|
26
|
+
? { numerator: input.coverage.metrics.line.covered, denominator: input.coverage.metrics.line.total, ratio: input.coverage.metrics.line.ratio, threshold: 0.8, status: input.coverage.metrics.line.ratio >= 0.8 ? "pass" : "fail", reason: input.coverage.metrics.line.ratio >= 0.8 ? null : "line-coverage-below-threshold" }
|
|
27
|
+
: unavailable(input.coverage?.metrics.line.reason ?? "line-coverage-missing", input.coverage?.metrics.line.covered ?? null, input.coverage?.metrics.line.total ?? null, 0.8),
|
|
28
|
+
branchCoverage: input.coverage?.metrics.branch.status === "available" && input.coverage.metrics.branch.ratio !== null
|
|
29
|
+
? { numerator: input.coverage.metrics.branch.covered, denominator: input.coverage.metrics.branch.total, ratio: input.coverage.metrics.branch.ratio, threshold: 0.7, status: input.coverage.metrics.branch.ratio >= 0.7 ? "pass" : "fail", reason: input.coverage.metrics.branch.ratio >= 0.7 ? null : "branch-coverage-below-threshold" }
|
|
30
|
+
: unavailable(input.coverage?.metrics.branch.reason ?? "branch-coverage-missing", input.coverage?.metrics.branch.covered ?? null, input.coverage?.metrics.branch.total ?? null, 0.7),
|
|
31
|
+
skipped: { numerator: input.result.skipped, denominator: input.result.skipped, ratio: input.result.skipped === 0 ? 1 : 0, threshold: 1, status: input.result.skipped === 0 ? "pass" : "fail", reason: input.result.skipped === 0 ? null : "skipped-tests-present" },
|
|
32
|
+
criticalRisks: { numerator: input.criticalRiskCount, denominator: input.criticalRiskCount, ratio: input.criticalRiskCount === 0 ? 1 : 0, threshold: 1, status: input.criticalRiskCount === 0 ? "pass" : "fail", reason: input.criticalRiskCount === 0 ? null : "critical-risk-present" },
|
|
33
|
+
};
|
|
34
|
+
const blockingItems = Object.entries(metrics).filter(([, value]) => value.status !== "pass").map(([name, value]) => `${name}:${value.reason ?? value.status}`);
|
|
35
|
+
return { status: blockingItems.length === 0 ? "ready" : "not-ready", metrics, blockingItems };
|
|
36
|
+
}
|
|
@@ -9,11 +9,12 @@ import { buildDagNodePromptEnvelope } from "./prompt.js";
|
|
|
9
9
|
import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
|
|
10
10
|
import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
|
|
11
11
|
import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
12
|
+
import { buildProjectGovernanceContext, readCompletedWriterChangeManifests, writeProjectGovernanceContext, } from "./project-governance-context.js";
|
|
12
13
|
import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
|
|
13
14
|
import { resolveDagSkillInstructions, skillInstructionMetadata, } from "./skill-instructions.js";
|
|
14
15
|
import { parseRepairArtifactFromText, resolveRepairTaskForGate, validateRepairArtifactScope, } from "./repair-artifact.js";
|
|
15
16
|
import { resolveModelForTask, } from "./types.js";
|
|
16
|
-
export function buildNodePrompt(spec, task, upstream) {
|
|
17
|
+
export function buildNodePrompt(spec, task, upstream, options) {
|
|
17
18
|
const policy = resolveContextPolicy(spec);
|
|
18
19
|
return buildDagNodePromptEnvelope({
|
|
19
20
|
spec,
|
|
@@ -21,9 +22,10 @@ export function buildNodePrompt(spec, task, upstream) {
|
|
|
21
22
|
upstream,
|
|
22
23
|
resolvedSkills: policy.resolveSkills(spec, task),
|
|
23
24
|
maxUpstreamChars: policy.resolveMaxUpstreamChars(task),
|
|
25
|
+
projectGovernanceContext: options?.projectGovernanceContext,
|
|
24
26
|
});
|
|
25
27
|
}
|
|
26
|
-
export async function buildNodePromptWithResolvedSkillInstructions(spec, task, upstream, cwd) {
|
|
28
|
+
export async function buildNodePromptWithResolvedSkillInstructions(spec, task, upstream, cwd, options) {
|
|
27
29
|
const policy = resolveContextPolicy(spec);
|
|
28
30
|
const skillNames = policy.resolveSkills(spec, task);
|
|
29
31
|
const budget = policy.resolveSkillInstructionBudget(task);
|
|
@@ -43,6 +45,7 @@ export async function buildNodePromptWithResolvedSkillInstructions(spec, task, u
|
|
|
43
45
|
resolvedSkills: skillNames,
|
|
44
46
|
resolvedSkillInstructions,
|
|
45
47
|
maxUpstreamChars: policy.resolveMaxUpstreamChars(task),
|
|
48
|
+
projectGovernanceContext: options?.projectGovernanceContext,
|
|
46
49
|
}),
|
|
47
50
|
resolvedSkills: skillInstructionMetadata(resolvedSkillInstructions),
|
|
48
51
|
};
|
|
@@ -138,12 +141,12 @@ export async function executeDagNode(input) {
|
|
|
138
141
|
const { nodeId, tasksById, state, spec, cwd, runDir, executeNode } = input;
|
|
139
142
|
const task = tasksById.get(nodeId);
|
|
140
143
|
const node = state.nodes[nodeId];
|
|
141
|
-
const
|
|
144
|
+
const failBeforePrompt = async (error, failureCategory) => {
|
|
142
145
|
const failedAt = new Date().toISOString();
|
|
143
146
|
node.startedAt ??= failedAt;
|
|
144
147
|
node.status = "ERROR";
|
|
145
148
|
node.stderr = error instanceof Error ? error.message : String(error);
|
|
146
|
-
node.failureCategory =
|
|
149
|
+
node.failureCategory = failureCategory;
|
|
147
150
|
node.finishedAt = failedAt;
|
|
148
151
|
node.lastActivityAt = node.finishedAt;
|
|
149
152
|
node.durationMs = Math.max(0, Date.now() - new Date(node.startedAt).getTime());
|
|
@@ -152,6 +155,29 @@ export async function executeDagNode(input) {
|
|
|
152
155
|
await input.persistState();
|
|
153
156
|
await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
|
|
154
157
|
};
|
|
158
|
+
const failSkillSnapshot = (error) => failBeforePrompt(error, "skill-snapshot-integrity");
|
|
159
|
+
let projectGovernanceContext;
|
|
160
|
+
if (task.governanceStandardReview) {
|
|
161
|
+
try {
|
|
162
|
+
const changeManifest = await readCompletedWriterChangeManifests({
|
|
163
|
+
runDir,
|
|
164
|
+
spec,
|
|
165
|
+
state,
|
|
166
|
+
});
|
|
167
|
+
projectGovernanceContext = await buildProjectGovernanceContext({
|
|
168
|
+
runId: state.runId,
|
|
169
|
+
cwd,
|
|
170
|
+
changeManifest,
|
|
171
|
+
runDir,
|
|
172
|
+
});
|
|
173
|
+
state.projectGovernanceContextRef = await writeProjectGovernanceContext(runDir, projectGovernanceContext);
|
|
174
|
+
await input.persistState();
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
await failBeforePrompt(error, "project-governance-integrity");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
155
181
|
const isDynamicTask = Boolean(task.dynamicExpansion
|
|
156
182
|
|| task.dynamicReduction
|
|
157
183
|
|| task.dynamicCondition
|
|
@@ -169,6 +195,7 @@ export async function executeDagNode(input) {
|
|
|
169
195
|
task,
|
|
170
196
|
upstream: state.nodes,
|
|
171
197
|
snapshot: skillSnapshot,
|
|
198
|
+
projectGovernanceContext,
|
|
172
199
|
});
|
|
173
200
|
}
|
|
174
201
|
}
|
|
@@ -237,7 +264,7 @@ export async function executeDagNode(input) {
|
|
|
237
264
|
}
|
|
238
265
|
else {
|
|
239
266
|
({ prompt, resolvedSkills } =
|
|
240
|
-
await buildNodePromptWithResolvedSkillInstructions(spec, task, state.nodes, cwd));
|
|
267
|
+
await buildNodePromptWithResolvedSkillInstructions(spec, task, state.nodes, cwd, { projectGovernanceContext }));
|
|
241
268
|
}
|
|
242
269
|
}
|
|
243
270
|
catch (error) {
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { open, readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { writeTextAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
6
|
+
/**
|
|
7
|
+
* Project Governance Context resolver.
|
|
8
|
+
*
|
|
9
|
+
* Deterministic, repository-local resolution of the applicable `AGENTS.md`
|
|
10
|
+
* chain (root -> nearest) and its explicit code-standard references, scoped to
|
|
11
|
+
* the actual writer changeset of the current DAG run. No model search; no
|
|
12
|
+
* repository writes; never reads outside the repository.
|
|
13
|
+
*
|
|
14
|
+
* Mirrors the proven shape of skill-snapshot.ts: run-owned, schemaVersion,
|
|
15
|
+
* resolverVersion, sha256, cycle-safe, path-contained, bounded, auditable.
|
|
16
|
+
*/
|
|
17
|
+
export const PROJECT_GOVERNANCE_CONTEXT_SCHEMA_VERSION = 1;
|
|
18
|
+
export const PROJECT_GOVERNANCE_CONTEXT_RESOLVER_VERSION = 1;
|
|
19
|
+
export const PROJECT_GOVERNANCE_CONTEXT_REL_PATH = ".runtime/project-governance-context.json";
|
|
20
|
+
export const AGENTS_MD_FILENAME = "AGENTS.md";
|
|
21
|
+
const DAG_RUNS_PREFIX = ".harness/dag-runs/";
|
|
22
|
+
const MAX_AGENTS_MD_BYTES = 256 * 1024;
|
|
23
|
+
const MAX_STANDARD_BYTES = 256 * 1024;
|
|
24
|
+
const MAX_REFERENCED_STANDARDS = 32;
|
|
25
|
+
const MAX_GOVERNANCE_DISCOVERY_DIRECTORIES = 10_000;
|
|
26
|
+
const GOVERNANCE_DISCOVERY_IGNORED_DIRECTORIES = new Set([
|
|
27
|
+
".git",
|
|
28
|
+
".codegraph",
|
|
29
|
+
".harness",
|
|
30
|
+
".worktrees",
|
|
31
|
+
"node_modules",
|
|
32
|
+
"dist",
|
|
33
|
+
"build",
|
|
34
|
+
]);
|
|
35
|
+
const STANDARD_MARKER_RE = /<!--\s*standard:\s*([^\s>]+)\s*-->/gi;
|
|
36
|
+
const GOVERNANCE_REFERENCE_LINE_RE = /(?:代码|编码|开发|工程|测试|验证|架构|治理|规范|标准|约束|要求|原则|流程|code|coding|development|engineering|test|verification|architecture|governance|standard|style|convention|guideline|principle|workflow|requirement)/i;
|
|
37
|
+
const ADVISORY_REFERENCE_LINE_RE = /(?:可选|建议|参考|advisory|optional|recommend(?:ed|ation)?|for reference)/i;
|
|
38
|
+
const MARKDOWN_LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
|
|
39
|
+
const INLINE_MARKDOWN_PATH_RE = /`((?:\.?\.?[\\/])?[A-Za-z0-9_.@/\\-]+\.md(?:#[A-Za-z0-9_.-]+)?)`/gi;
|
|
40
|
+
const AT_MARKDOWN_PATH_RE = /(?:^|\s)@([^\s<>]+\.md(?:#[^\s<>]+)?)/gi;
|
|
41
|
+
export class DagProjectGovernanceIntegrityError extends Error {
|
|
42
|
+
constructor(message, options) {
|
|
43
|
+
super(message, options);
|
|
44
|
+
this.name = "DagProjectGovernanceIntegrityError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Cheap generation-time capability probe. Runtime applicability is still
|
|
49
|
+
* resolved from the writer changeset; this probe only prevents governance
|
|
50
|
+
* fields/gates from changing DAGs for repositories that have no AGENTS.md at
|
|
51
|
+
* all. Heavy generated/runtime directories are intentionally excluded.
|
|
52
|
+
*/
|
|
53
|
+
export async function discoverProjectGovernancePresence(repoRoot) {
|
|
54
|
+
const queue = [path.resolve(repoRoot)];
|
|
55
|
+
let visited = 0;
|
|
56
|
+
while (queue.length > 0 && visited < MAX_GOVERNANCE_DISCOVERY_DIRECTORIES) {
|
|
57
|
+
const directory = queue.shift();
|
|
58
|
+
visited += 1;
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (entries.some((entry) => entry.isFile() && entry.name === AGENTS_MD_FILENAME)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.isDirectory() &&
|
|
71
|
+
!entry.isSymbolicLink() &&
|
|
72
|
+
!GOVERNANCE_DISCOVERY_IGNORED_DIRECTORIES.has(entry.name)) {
|
|
73
|
+
queue.push(path.join(directory, entry.name));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
function sha256(content) {
|
|
80
|
+
return createHash("sha256").update(content).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
function toPosix(p) {
|
|
83
|
+
return p.replace(/\\/g, "/");
|
|
84
|
+
}
|
|
85
|
+
function normalizeRelPath(value) {
|
|
86
|
+
return toPosix(value).replace(/^\.\//, "");
|
|
87
|
+
}
|
|
88
|
+
function isWithinRepo(repoRoot, candidateAbs) {
|
|
89
|
+
const rel = path.relative(repoRoot, candidateAbs);
|
|
90
|
+
if (!rel)
|
|
91
|
+
return false; // equal to root
|
|
92
|
+
// On POSIX and Windows alike, an escaping relative path starts with ".."
|
|
93
|
+
// or is absolute on a different drive (path.relative yields an absolute path).
|
|
94
|
+
return !rel.startsWith("..") && !path.isAbsolute(toPosix(rel));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Walk from the changed file's directory up to repo root, returning the list of
|
|
98
|
+
* ancestor directories (root -> ... -> file's dir) that could contain an
|
|
99
|
+
* AGENTS.md. The file's own directory is included; repo root is included.
|
|
100
|
+
*/
|
|
101
|
+
function ancestorDirectories(repoRoot, fileRel) {
|
|
102
|
+
const fileAbs = path.resolve(repoRoot, normalizeRelPath(fileRel));
|
|
103
|
+
const fileDirAbs = path.dirname(fileAbs);
|
|
104
|
+
const dirs = [];
|
|
105
|
+
let current = fileDirAbs;
|
|
106
|
+
for (;;) {
|
|
107
|
+
dirs.push(current);
|
|
108
|
+
const parent = path.dirname(current);
|
|
109
|
+
if (parent === current)
|
|
110
|
+
break; // filesystem root
|
|
111
|
+
if (path.relative(repoRoot, current) === "")
|
|
112
|
+
break; // reached repo root
|
|
113
|
+
current = parent;
|
|
114
|
+
}
|
|
115
|
+
// dirs are fileDir -> ... -> repoRoot; reverse to root -> ... -> nearest
|
|
116
|
+
return dirs.reverse().filter((dir) => {
|
|
117
|
+
const rel = path.relative(repoRoot, dir);
|
|
118
|
+
return !rel.startsWith("..") && !path.isAbsolute(toPosix(rel));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function directoryRel(repoRoot, dirAbs) {
|
|
122
|
+
const rel = path.relative(repoRoot, dirAbs);
|
|
123
|
+
return normalizeRelPath(rel);
|
|
124
|
+
}
|
|
125
|
+
async function readBounded(fileAbs, maxBytes) {
|
|
126
|
+
const handle = await open(fileAbs, "r");
|
|
127
|
+
try {
|
|
128
|
+
const info = await handle.stat();
|
|
129
|
+
if (!info.isFile()) {
|
|
130
|
+
const error = new Error(`governance reference is not a regular file: ${fileAbs}`);
|
|
131
|
+
error.code = "EINVAL";
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (info.size > maxBytes) {
|
|
135
|
+
const error = new Error(`governance file exceeds ${maxBytes} byte limit: ${fileAbs} (${info.size} bytes)`);
|
|
136
|
+
error.code = "EFBIG";
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
const buffer = await handle.readFile();
|
|
140
|
+
if (buffer.length > maxBytes) {
|
|
141
|
+
const error = new Error(`governance file grew beyond ${maxBytes} byte limit while reading: ${fileAbs}`);
|
|
142
|
+
error.code = "EFBIG";
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
content: buffer.toString("utf-8"),
|
|
147
|
+
bytes: buffer.length,
|
|
148
|
+
sha256: sha256(buffer),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await handle.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function normalizeDeclaredReferenceToken(token) {
|
|
156
|
+
return token
|
|
157
|
+
.trim()
|
|
158
|
+
.replace(/^<|>$/g, "")
|
|
159
|
+
.split("#", 1)[0]
|
|
160
|
+
.split("?", 1)[0];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve both the explicit marker grammar and ordinary AGENTS.md indexes:
|
|
164
|
+
* Markdown links, backtick paths, and @path references are accepted only on
|
|
165
|
+
* lines that clearly describe governance/engineering requirements. This keeps
|
|
166
|
+
* traversal bounded while supporting normal project documentation maps.
|
|
167
|
+
*/
|
|
168
|
+
function parseStandardReferences(content) {
|
|
169
|
+
const out = [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
const push = (token, enforcement) => {
|
|
172
|
+
const normalized = normalizeDeclaredReferenceToken(token);
|
|
173
|
+
if (!normalized || seen.has(normalized))
|
|
174
|
+
return;
|
|
175
|
+
seen.add(normalized);
|
|
176
|
+
out.push({ path: normalized, enforcement });
|
|
177
|
+
};
|
|
178
|
+
let match;
|
|
179
|
+
STANDARD_MARKER_RE.lastIndex = 0;
|
|
180
|
+
while ((match = STANDARD_MARKER_RE.exec(content)) !== null) {
|
|
181
|
+
const token = match[1].trim();
|
|
182
|
+
if (token)
|
|
183
|
+
push(token, "mandatory");
|
|
184
|
+
}
|
|
185
|
+
for (const line of content.split(/\r?\n/)) {
|
|
186
|
+
if (!GOVERNANCE_REFERENCE_LINE_RE.test(line))
|
|
187
|
+
continue;
|
|
188
|
+
const enforcement = ADVISORY_REFERENCE_LINE_RE.test(line)
|
|
189
|
+
? "advisory"
|
|
190
|
+
: "mandatory";
|
|
191
|
+
for (const pattern of [MARKDOWN_LINK_RE, INLINE_MARKDOWN_PATH_RE, AT_MARKDOWN_PATH_RE]) {
|
|
192
|
+
pattern.lastIndex = 0;
|
|
193
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
194
|
+
push(match[1], enforcement);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
function isHarnessRunPath(fileRel) {
|
|
201
|
+
const normalized = normalizeRelPath(fileRel);
|
|
202
|
+
return normalized.startsWith(DAG_RUNS_PREFIX);
|
|
203
|
+
}
|
|
204
|
+
export async function buildProjectGovernanceContext(input) {
|
|
205
|
+
const repoRoot = path.resolve(input.cwd);
|
|
206
|
+
const now = (input.now ?? new Date()).toISOString();
|
|
207
|
+
// Aggregate changed files, excluding .harness/dag-runs/** (run-owned).
|
|
208
|
+
const allChanged = Array.from(new Set(input.changeManifest
|
|
209
|
+
.flatMap((entry) => entry.changedFiles)
|
|
210
|
+
.map((f) => normalizeRelPath(f))
|
|
211
|
+
.filter((f) => f.length > 0 && !isHarnessRunPath(f)))).sort();
|
|
212
|
+
if (allChanged.length === 0) {
|
|
213
|
+
return {
|
|
214
|
+
schemaVersion: PROJECT_GOVERNANCE_CONTEXT_SCHEMA_VERSION,
|
|
215
|
+
resolverVersion: PROJECT_GOVERNANCE_CONTEXT_RESOLVER_VERSION,
|
|
216
|
+
runId: input.runId,
|
|
217
|
+
createdAt: now,
|
|
218
|
+
applicable: false,
|
|
219
|
+
changeManifest: input.changeManifest,
|
|
220
|
+
agentsMdChain: [],
|
|
221
|
+
referencedStandards: [],
|
|
222
|
+
unresolvedReferences: [],
|
|
223
|
+
diagnostics: [],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// Collect candidate AGENTS.md directories: for each changed file, walk
|
|
227
|
+
// root -> nearest. Deduplicate by directory; do NOT scan unrelated dirs.
|
|
228
|
+
const dirToApplies = new Map();
|
|
229
|
+
for (const fileRel of allChanged) {
|
|
230
|
+
for (const dirAbs of ancestorDirectories(repoRoot, fileRel)) {
|
|
231
|
+
const dirRel = directoryRel(repoRoot, dirAbs);
|
|
232
|
+
let bucket = dirToApplies.get(dirRel);
|
|
233
|
+
if (!bucket) {
|
|
234
|
+
bucket = new Set();
|
|
235
|
+
dirToApplies.set(dirRel, bucket);
|
|
236
|
+
}
|
|
237
|
+
bucket.add(fileRel);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Order directories root -> nearest (shortest rel path first, then lex).
|
|
241
|
+
const orderedDirs = [...dirToApplies.entries()].sort((a, b) => {
|
|
242
|
+
if (a[0].length !== b[0].length)
|
|
243
|
+
return a[0].length - b[0].length;
|
|
244
|
+
return a[0].localeCompare(b[0]);
|
|
245
|
+
});
|
|
246
|
+
const agentsMdChain = [];
|
|
247
|
+
for (const [dirRel, appliesSet] of orderedDirs) {
|
|
248
|
+
const dirAbs = path.join(repoRoot, dirRel);
|
|
249
|
+
const agentsPath = path.join(dirAbs, AGENTS_MD_FILENAME);
|
|
250
|
+
let bytes;
|
|
251
|
+
let contentSha256;
|
|
252
|
+
try {
|
|
253
|
+
const read = await readBounded(agentsPath, MAX_AGENTS_MD_BYTES);
|
|
254
|
+
bytes = read.bytes;
|
|
255
|
+
contentSha256 = read.sha256;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
const code = error && typeof error === "object" && "code" in error
|
|
259
|
+
? error.code
|
|
260
|
+
: undefined;
|
|
261
|
+
if (code === "ENOENT") {
|
|
262
|
+
// No AGENTS.md in this directory; skip without error (bounded, no scan).
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
throw new DagProjectGovernanceIntegrityError(`failed to read applicable AGENTS.md at ${normalizeRelPath(path.relative(repoRoot, agentsPath))}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
266
|
+
}
|
|
267
|
+
const fileRel = dirRel === "" ? AGENTS_MD_FILENAME : `${dirRel}/${AGENTS_MD_FILENAME}`;
|
|
268
|
+
agentsMdChain.push({
|
|
269
|
+
directory: dirRel,
|
|
270
|
+
path: fileRel,
|
|
271
|
+
sha256: contentSha256,
|
|
272
|
+
bytes,
|
|
273
|
+
appliesTo: [...appliesSet].sort(),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const referencedStandards = [];
|
|
277
|
+
const unresolvedReferences = [];
|
|
278
|
+
const resolvedPaths = new Set();
|
|
279
|
+
const diagnostics = [];
|
|
280
|
+
for (const [entry, entryIndex] of agentsMdChain.map((e, i) => [e, i])) {
|
|
281
|
+
const dirAbs = path.join(repoRoot, entry.directory);
|
|
282
|
+
let content;
|
|
283
|
+
try {
|
|
284
|
+
const read = await readBounded(path.join(dirAbs, AGENTS_MD_FILENAME), MAX_AGENTS_MD_BYTES);
|
|
285
|
+
content = read.content;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// Already proven to exist above; treat as read error defensively.
|
|
289
|
+
diagnostics.push(`failed to re-read AGENTS.md at ${entry.path} for reference parsing`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const refs = parseStandardReferences(content);
|
|
293
|
+
for (const ref of refs) {
|
|
294
|
+
if (referencedStandards.length + unresolvedReferences.length >= MAX_REFERENCED_STANDARDS) {
|
|
295
|
+
diagnostics.push(`governance reference budget reached at ${MAX_REFERENCED_STANDARDS}; remaining references ignored`);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
const declaredPath = normalizeRelPath(ref.path);
|
|
299
|
+
if (path.isAbsolute(toPosix(ref.path)) ||
|
|
300
|
+
path.win32.isAbsolute(ref.path) ||
|
|
301
|
+
declaredPath.split("/").includes("..") ||
|
|
302
|
+
/^[a-z][a-z0-9+.-]*:/i.test(ref.path)) {
|
|
303
|
+
unresolvedReferences.push({
|
|
304
|
+
fromAgentsMd: entryIndex,
|
|
305
|
+
declaredPath: ref.path,
|
|
306
|
+
reason: "out-of-repo",
|
|
307
|
+
});
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// Standard references are repo-root-relative (project convention for
|
|
311
|
+
// AGENTS.md), not relative to the AGENTS.md's own directory.
|
|
312
|
+
const candidateAbs = path.resolve(repoRoot, declaredPath);
|
|
313
|
+
if (!isWithinRepo(repoRoot, candidateAbs)) {
|
|
314
|
+
unresolvedReferences.push({
|
|
315
|
+
fromAgentsMd: entryIndex,
|
|
316
|
+
declaredPath: ref.path,
|
|
317
|
+
reason: "out-of-repo",
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const candidateRel = normalizeRelPath(path.relative(repoRoot, candidateAbs));
|
|
322
|
+
if (resolvedPaths.has(candidateRel)) {
|
|
323
|
+
unresolvedReferences.push({
|
|
324
|
+
fromAgentsMd: entryIndex,
|
|
325
|
+
declaredPath: ref.path,
|
|
326
|
+
reason: "cycle",
|
|
327
|
+
});
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const read = await readBounded(candidateAbs, MAX_STANDARD_BYTES);
|
|
332
|
+
resolvedPaths.add(candidateRel);
|
|
333
|
+
referencedStandards.push({
|
|
334
|
+
fromAgentsMd: entryIndex,
|
|
335
|
+
path: candidateRel,
|
|
336
|
+
sha256: read.sha256,
|
|
337
|
+
bytes: read.bytes,
|
|
338
|
+
scope: declaredPath,
|
|
339
|
+
enforcement: ref.enforcement,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
const code = error && typeof error === "object" && "code" in error
|
|
344
|
+
? error.code
|
|
345
|
+
: undefined;
|
|
346
|
+
unresolvedReferences.push({
|
|
347
|
+
fromAgentsMd: entryIndex,
|
|
348
|
+
declaredPath: ref.path,
|
|
349
|
+
reason: code === "ENOENT" ? "missing" : "read-error",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const applicable = agentsMdChain.length > 0;
|
|
355
|
+
return {
|
|
356
|
+
schemaVersion: PROJECT_GOVERNANCE_CONTEXT_SCHEMA_VERSION,
|
|
357
|
+
resolverVersion: PROJECT_GOVERNANCE_CONTEXT_RESOLVER_VERSION,
|
|
358
|
+
runId: input.runId,
|
|
359
|
+
createdAt: now,
|
|
360
|
+
applicable,
|
|
361
|
+
changeManifest: input.changeManifest,
|
|
362
|
+
agentsMdChain,
|
|
363
|
+
referencedStandards,
|
|
364
|
+
unresolvedReferences,
|
|
365
|
+
diagnostics,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function serializeContext(ctx) {
|
|
369
|
+
return `${JSON.stringify(ctx, null, 2)}\n`;
|
|
370
|
+
}
|
|
371
|
+
const agentsMdEntrySchema = z.object({
|
|
372
|
+
directory: z.string(),
|
|
373
|
+
path: z.string().min(1),
|
|
374
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
375
|
+
bytes: z.number().int().nonnegative(),
|
|
376
|
+
appliesTo: z.array(z.string()),
|
|
377
|
+
}).strict();
|
|
378
|
+
const resolvedStandardSchema = z.object({
|
|
379
|
+
fromAgentsMd: z.number().int().nonnegative(),
|
|
380
|
+
path: z.string().min(1),
|
|
381
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
382
|
+
bytes: z.number().int().nonnegative(),
|
|
383
|
+
scope: z.string().min(1),
|
|
384
|
+
enforcement: z.enum(["mandatory", "advisory"]),
|
|
385
|
+
}).strict();
|
|
386
|
+
const unresolvedReferenceSchema = z.object({
|
|
387
|
+
fromAgentsMd: z.number().int().nonnegative(),
|
|
388
|
+
declaredPath: z.string().min(1),
|
|
389
|
+
reason: z.enum(["out-of-repo", "missing", "cycle", "invalid", "read-error"]),
|
|
390
|
+
}).strict();
|
|
391
|
+
const changeManifestSchema = z.object({
|
|
392
|
+
writerNodeId: z.string().min(1),
|
|
393
|
+
changedFiles: z.array(z.string()),
|
|
394
|
+
}).strict();
|
|
395
|
+
const projectGovernanceContextSchema = z.object({
|
|
396
|
+
schemaVersion: z.literal(PROJECT_GOVERNANCE_CONTEXT_SCHEMA_VERSION),
|
|
397
|
+
resolverVersion: z.literal(PROJECT_GOVERNANCE_CONTEXT_RESOLVER_VERSION),
|
|
398
|
+
runId: z.string().min(1),
|
|
399
|
+
createdAt: z.string().datetime(),
|
|
400
|
+
applicable: z.boolean(),
|
|
401
|
+
changeManifest: z.array(changeManifestSchema),
|
|
402
|
+
agentsMdChain: z.array(agentsMdEntrySchema),
|
|
403
|
+
referencedStandards: z.array(resolvedStandardSchema),
|
|
404
|
+
unresolvedReferences: z.array(unresolvedReferenceSchema),
|
|
405
|
+
diagnostics: z.array(z.string()),
|
|
406
|
+
}).strict();
|
|
407
|
+
const projectGovernanceContextRefSchema = z.object({
|
|
408
|
+
schemaVersion: z.literal(PROJECT_GOVERNANCE_CONTEXT_SCHEMA_VERSION),
|
|
409
|
+
resolverVersion: z.literal(PROJECT_GOVERNANCE_CONTEXT_RESOLVER_VERSION),
|
|
410
|
+
path: z.literal(PROJECT_GOVERNANCE_CONTEXT_REL_PATH),
|
|
411
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
412
|
+
createdAt: z.string().datetime(),
|
|
413
|
+
}).strict();
|
|
414
|
+
function parseContext(value) {
|
|
415
|
+
try {
|
|
416
|
+
return projectGovernanceContextSchema.parse(value);
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
throw new DagProjectGovernanceIntegrityError(`project governance context schema validation failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const writerChangeManifestArtifactSchema = z.object({
|
|
423
|
+
schemaVersion: z.literal(1),
|
|
424
|
+
writerNodeId: z.string().min(1),
|
|
425
|
+
changedFiles: z.array(z.string()),
|
|
426
|
+
beforeStatusSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
427
|
+
afterStatusSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
428
|
+
}).strict();
|
|
429
|
+
/**
|
|
430
|
+
* Read run-owned manifests only for Pi nodes that actually finished with the
|
|
431
|
+
* bounded write tool profile. A finished writer without its manifest fails
|
|
432
|
+
* closed: otherwise the reviewer could incorrectly conclude that no project
|
|
433
|
+
* governance applies.
|
|
434
|
+
*/
|
|
435
|
+
export async function readCompletedWriterChangeManifests(input) {
|
|
436
|
+
const manifests = [];
|
|
437
|
+
for (const task of input.spec.tasks) {
|
|
438
|
+
if (task.executor !== "pi" || task.toolProfile !== "write")
|
|
439
|
+
continue;
|
|
440
|
+
if (input.state.nodes[task.id]?.status !== "FINISHED")
|
|
441
|
+
continue;
|
|
442
|
+
const artifactPath = path.join(input.runDir, task.id, "change-manifest.json");
|
|
443
|
+
let decoded;
|
|
444
|
+
try {
|
|
445
|
+
decoded = JSON.parse(await readFile(artifactPath, "utf-8"));
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
throw new DagProjectGovernanceIntegrityError(`finished writer ${task.id} is missing a valid change-manifest.json: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
449
|
+
}
|
|
450
|
+
const parsed = writerChangeManifestArtifactSchema.safeParse(decoded);
|
|
451
|
+
if (!parsed.success || parsed.data.writerNodeId !== task.id) {
|
|
452
|
+
throw new DagProjectGovernanceIntegrityError(`writer change manifest ownership/schema mismatch for ${task.id}`);
|
|
453
|
+
}
|
|
454
|
+
manifests.push({
|
|
455
|
+
writerNodeId: parsed.data.writerNodeId,
|
|
456
|
+
changedFiles: [...new Set(parsed.data.changedFiles)].sort(),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return manifests;
|
|
460
|
+
}
|
|
461
|
+
export async function writeProjectGovernanceContext(runDir, ctx) {
|
|
462
|
+
const parsed = parseContext(ctx);
|
|
463
|
+
const raw = serializeContext(parsed);
|
|
464
|
+
await writeTextAtomic(path.join(runDir, ...PROJECT_GOVERNANCE_CONTEXT_REL_PATH.split("/")), raw);
|
|
465
|
+
return {
|
|
466
|
+
schemaVersion: PROJECT_GOVERNANCE_CONTEXT_SCHEMA_VERSION,
|
|
467
|
+
resolverVersion: PROJECT_GOVERNANCE_CONTEXT_RESOLVER_VERSION,
|
|
468
|
+
path: PROJECT_GOVERNANCE_CONTEXT_REL_PATH,
|
|
469
|
+
sha256: sha256(raw),
|
|
470
|
+
createdAt: parsed.createdAt,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
export async function readProjectGovernanceContext(runDir, refValue, options) {
|
|
474
|
+
let ref;
|
|
475
|
+
try {
|
|
476
|
+
ref = projectGovernanceContextRefSchema.parse(refValue);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
throw new DagProjectGovernanceIntegrityError(`project governance context ref is invalid: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
480
|
+
}
|
|
481
|
+
const ctxPath = path.join(runDir, ...ref.path.split("/"));
|
|
482
|
+
let raw;
|
|
483
|
+
try {
|
|
484
|
+
raw = await readFile(ctxPath);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
throw new DagProjectGovernanceIntegrityError(`project governance context missing or unreadable at ${ref.path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
488
|
+
}
|
|
489
|
+
const actualHash = sha256(raw);
|
|
490
|
+
if (actualHash !== ref.sha256) {
|
|
491
|
+
throw new DagProjectGovernanceIntegrityError(`project governance context integrity check failed at ${ref.path}: expected sha256 ${ref.sha256}, got ${actualHash}`);
|
|
492
|
+
}
|
|
493
|
+
let decoded;
|
|
494
|
+
try {
|
|
495
|
+
decoded = JSON.parse(raw.toString("utf-8"));
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
throw new DagProjectGovernanceIntegrityError(`project governance context JSON is invalid: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
499
|
+
}
|
|
500
|
+
const ctx = parseContext(decoded);
|
|
501
|
+
if (ctx.createdAt !== ref.createdAt) {
|
|
502
|
+
throw new DagProjectGovernanceIntegrityError("project governance context ref metadata does not match the artifact");
|
|
503
|
+
}
|
|
504
|
+
if (options?.expectedRunId && ctx.runId !== options.expectedRunId) {
|
|
505
|
+
throw new DagProjectGovernanceIntegrityError(`project governance context run ownership mismatch: expected ${options.expectedRunId}, got ${ctx.runId}`);
|
|
506
|
+
}
|
|
507
|
+
return ctx;
|
|
508
|
+
}
|