@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/cli/command-definitions.js +13 -0
  3. package/dist/cli/program.js +4 -0
  4. package/dist/commands/coverage-report.js +50 -0
  5. package/dist/executors/dag-pi-executor.js +63 -9
  6. package/dist/executors/shell-executor.js +30 -0
  7. package/dist/executors/shell-write-guard.js +64 -2
  8. package/dist/worker/delivery/git-transaction.js +43 -8
  9. package/dist/worker/observe/paths.js +81 -0
  10. package/dist/worker/observe/routes.js +127 -19
  11. package/dist/worker/observe/spec-evidence.js +84 -0
  12. package/dist/worker/observe/static/api.js +23 -0
  13. package/dist/worker/observe/static/state.js +26 -0
  14. package/dist/worker/observe/static/styles.css +10 -0
  15. package/dist/worker/observe/static/views/dag-inspector.js +173 -6
  16. package/dist/workflows/dag/backend-test-coverage-contract.js +202 -0
  17. package/dist/workflows/dag/backend-test-execution-contract.js +84 -18
  18. package/dist/workflows/dag/backend-test-stability-contract.js +57 -0
  19. package/dist/workflows/dag/init-hybrid.js +150 -13
  20. package/dist/workflows/dag/l5-report-metrics.js +36 -0
  21. package/dist/workflows/dag/node-execution.js +32 -5
  22. package/dist/workflows/dag/project-governance-context.js +508 -0
  23. package/dist/workflows/dag/prompt.js +46 -1
  24. package/dist/workflows/dag/skill-snapshot.js +1 -0
  25. package/dist/workflows/dag/types.js +10 -0
  26. package/dist/workflows/dag/validate.js +28 -0
  27. package/docs/architecture/evolution.md +3 -1
  28. package/docs/templates/agent-dag.schema.json +15 -0
  29. package/docs/templates/agent-dag.supervised-implementation.json +1 -0
  30. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +2 -0
  31. package/docs/templates/backend-test-dag.json +39 -6
  32. package/docs/templates/backend-test-dag.retrospect.prompt.md +36 -7
  33. package/package.json +1 -1
  34. 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 failSkillSnapshot = async (error) => {
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 = "skill-snapshot-integrity";
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
+ }