@tea-agent/loop-agent 0.16.25 → 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 +9 -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/init-hybrid.js +104 -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/templates/agent-dag.schema.json +15 -0
- package/docs/templates/agent-dag.supervised-implementation.json +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -98,8 +98,49 @@ function buildReadOnlyBoundary(writePolicy) {
|
|
|
98
98
|
"Return findings in the assistant response/stdout only; the DAG runner persists node artifacts under .harness/dag-runs/<state>/<run-id>/<node-id>/.",
|
|
99
99
|
];
|
|
100
100
|
}
|
|
101
|
+
function formatProjectGovernanceContext(ctx) {
|
|
102
|
+
if (!ctx || !ctx.applicable)
|
|
103
|
+
return undefined;
|
|
104
|
+
const lines = [];
|
|
105
|
+
lines.push("Deterministic project governance manifest for the actual writer changeset of this run. Interpret it for compliance review; do not invent rules beyond the files and hashes listed here.");
|
|
106
|
+
lines.push("Before issuing a verdict, use read-only tools to read every applicable AGENTS.md and every mandatory referenced standard listed below from the repository; hashes bind the exact inputs for audit.");
|
|
107
|
+
lines.push("Your first non-empty output line must be exactly VERDICT: pass or VERDICT: request-revision. Request revision while any applicable mandatory instruction is violated.");
|
|
108
|
+
lines.push("");
|
|
109
|
+
lines.push("Change manifest (writer nodes and their changed files):");
|
|
110
|
+
for (const entry of ctx.changeManifest) {
|
|
111
|
+
if (entry.changedFiles.length === 0)
|
|
112
|
+
continue;
|
|
113
|
+
lines.push(`- ${entry.writerNodeId}: ${entry.changedFiles.join(", ")}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push("");
|
|
116
|
+
lines.push("Applicable AGENTS.md chain (root -> nearest):");
|
|
117
|
+
for (const entry of ctx.agentsMdChain) {
|
|
118
|
+
lines.push(`- ${entry.path} (enforcement=mandatory, directory=${entry.directory || "/"}, sha256=${entry.sha256}, bytes=${entry.bytes}) applies to: ${entry.appliesTo.join(", ") || "(none)"}`);
|
|
119
|
+
}
|
|
120
|
+
if (ctx.referencedStandards.length > 0) {
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push("Referenced repository-local code standards:");
|
|
123
|
+
for (const std of ctx.referencedStandards) {
|
|
124
|
+
lines.push(`- ${std.path} (enforcement=${std.enforcement}, from ${ctx.agentsMdChain[std.fromAgentsMd]?.path ?? "AGENTS.md"}, sha256=${std.sha256}, bytes=${std.bytes}, scope=${std.scope})`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (ctx.unresolvedReferences.length > 0) {
|
|
128
|
+
lines.push("");
|
|
129
|
+
lines.push("Unresolved reference diagnostics (structured, not read): for awareness only.");
|
|
130
|
+
for (const ref of ctx.unresolvedReferences) {
|
|
131
|
+
lines.push(`- ${ref.declaredPath}: ${ref.reason} (from ${ctx.agentsMdChain[ref.fromAgentsMd]?.path ?? "AGENTS.md"})`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (ctx.diagnostics.length > 0) {
|
|
135
|
+
lines.push("");
|
|
136
|
+
lines.push("Resolver diagnostics:");
|
|
137
|
+
for (const diag of ctx.diagnostics)
|
|
138
|
+
lines.push(`- ${diag}`);
|
|
139
|
+
}
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
101
142
|
export function buildDagNodePromptEnvelope(input) {
|
|
102
|
-
const { spec, task, upstream, resolvedSkills = [], resolvedSkillInstructions = [], maxUpstreamChars = MAX_UPSTREAM_CHARS, } = input;
|
|
143
|
+
const { spec, task, upstream, resolvedSkills = [], resolvedSkillInstructions = [], maxUpstreamChars = MAX_UPSTREAM_CHARS, projectGovernanceContext, } = input;
|
|
103
144
|
const objective = spec.objective ?? spec.title;
|
|
104
145
|
const successCriteria = formatBulletList(spec.successCriteria, "(none specified)");
|
|
105
146
|
const globalConstraints = formatBulletList(spec.globalConstraints, "(none specified)");
|
|
@@ -150,6 +191,10 @@ export function buildDagNodePromptEnvelope(input) {
|
|
|
150
191
|
else {
|
|
151
192
|
sections.push("<upstream_context>\n(none)\n</upstream_context>");
|
|
152
193
|
}
|
|
194
|
+
const governanceSection = formatProjectGovernanceContext(projectGovernanceContext);
|
|
195
|
+
if (governanceSection) {
|
|
196
|
+
sections.push(`<project_governance_context>\n${governanceSection}\n</project_governance_context>`);
|
|
197
|
+
}
|
|
153
198
|
sections.push(`<task>\n${task.subtask_prompt}\n</task>`);
|
|
154
199
|
return sections.join("\n\n");
|
|
155
200
|
}
|
|
@@ -524,6 +524,7 @@ export function buildNodePromptFromSnapshot(input) {
|
|
|
524
524
|
resolvedSkills: skillNames,
|
|
525
525
|
resolvedSkillInstructions,
|
|
526
526
|
maxUpstreamChars: policy.resolveMaxUpstreamChars(input.task),
|
|
527
|
+
projectGovernanceContext: input.projectGovernanceContext,
|
|
527
528
|
}),
|
|
528
529
|
resolvedSkills: resolvedSkillInstructions.map(stripPromptText),
|
|
529
530
|
};
|
|
@@ -152,6 +152,9 @@ export const dagShellConfigSchema = z.object({
|
|
|
152
152
|
commands: z.array(z.string()).default([]),
|
|
153
153
|
preset: dagShellPresetSchema.optional(),
|
|
154
154
|
verdictGate: dagVerdictGateSchema.optional(),
|
|
155
|
+
projectGovernanceGate: z.object({
|
|
156
|
+
contextPath: z.literal(".runtime/project-governance-context.json"),
|
|
157
|
+
}).strict().optional(),
|
|
155
158
|
requirementCoverageGate: dagRequirementCoverageGateSchema.optional(),
|
|
156
159
|
jsonArtifactGate: dagJsonArtifactGateSchema.optional(),
|
|
157
160
|
backendTestPipeline: dagBackendTestPipelineSchema.optional(),
|
|
@@ -311,6 +314,13 @@ export const dagTaskSchema = z.object({
|
|
|
311
314
|
static: dagStaticConfigSchema.optional(),
|
|
312
315
|
outputContract: z.string().optional(),
|
|
313
316
|
firstProtocolLine: z.string().min(1).optional(),
|
|
317
|
+
/**
|
|
318
|
+
* Explicit opt-in for the deterministic project governance context resolver
|
|
319
|
+
* (AGENTS.md chain + referenced code standards). Only tasks that set this
|
|
320
|
+
* to `true` receive a `<project_governance_context>` prompt section and
|
|
321
|
+
* closeout-blocking gate behavior. Never inferred from role/name.
|
|
322
|
+
*/
|
|
323
|
+
governanceStandardReview: z.boolean().optional(),
|
|
314
324
|
allowedPaths: z.array(z.string()).optional().default([]),
|
|
315
325
|
forbiddenPaths: z.array(z.string()).optional().default([]),
|
|
316
326
|
decisionGate: dagDecisionGateSchema.optional(),
|
|
@@ -653,12 +653,40 @@ export function validateDagSpec(spec) {
|
|
|
653
653
|
validateStaticTaskConfig(task, issues);
|
|
654
654
|
validateDecisionGateTaskConfig(task, issues);
|
|
655
655
|
validateRetryPolicyTaskConfig(task, issues);
|
|
656
|
+
validateProjectGovernanceTaskConfig(task, spec, issues);
|
|
656
657
|
}
|
|
657
658
|
validateSameRankWriteSetConflicts(spec, ranks, issues);
|
|
658
659
|
validateSameRankAgentAttributionRisks(spec, ranks, issues);
|
|
659
660
|
validateWriterSourceBinding(spec, issues);
|
|
660
661
|
return issues;
|
|
661
662
|
}
|
|
663
|
+
function validateProjectGovernanceTaskConfig(task, spec, issues) {
|
|
664
|
+
if (task.governanceStandardReview) {
|
|
665
|
+
if (task.executor !== "pi" ||
|
|
666
|
+
task.toolProfile === "write" ||
|
|
667
|
+
!["read-only", "none"].includes((task.writePolicy ?? "read-only"))) {
|
|
668
|
+
issues.push({
|
|
669
|
+
type: "invalid-project-governance-config",
|
|
670
|
+
message: `task ${task.id} governanceStandardReview requires a read-only Pi node`,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!task.shell?.projectGovernanceGate)
|
|
675
|
+
return;
|
|
676
|
+
const verdictGate = task.shell.verdictGate;
|
|
677
|
+
const source = verdictGate
|
|
678
|
+
? spec.tasks.find((candidate) => candidate.id === verdictGate.fromNodeId)
|
|
679
|
+
: undefined;
|
|
680
|
+
if (task.executor !== "shell" ||
|
|
681
|
+
!verdictGate ||
|
|
682
|
+
!task.depends_on.includes(verdictGate.fromNodeId) ||
|
|
683
|
+
!source?.governanceStandardReview) {
|
|
684
|
+
issues.push({
|
|
685
|
+
type: "invalid-project-governance-config",
|
|
686
|
+
message: `task ${task.id} projectGovernanceGate requires verdictGate over a directly-dependent governanceStandardReview node`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
662
690
|
export function assertValidDagSpec(spec, options = {}) {
|
|
663
691
|
const issues = collectBlockingIssues(options.issues ?? validateDagSpec(spec), options);
|
|
664
692
|
if (issues.length > 0) {
|
|
@@ -241,6 +241,17 @@
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
},
|
|
244
|
+
"projectGovernanceGate": {
|
|
245
|
+
"type": "object",
|
|
246
|
+
"additionalProperties": false,
|
|
247
|
+
"required": ["contextPath"],
|
|
248
|
+
"properties": {
|
|
249
|
+
"contextPath": {
|
|
250
|
+
"const": ".runtime/project-governance-context.json",
|
|
251
|
+
"description": "Run-owned project governance context. When applicable=false the gate is a deterministic no-op; otherwise verdictGate remains authoritative."
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
244
255
|
"requirementCoverageGate": {
|
|
245
256
|
"type": "object", "additionalProperties": false,
|
|
246
257
|
"required": ["fromNodeIds", "requiredIds"],
|
|
@@ -370,6 +381,10 @@
|
|
|
370
381
|
"items": { "type": "string", "minLength": 1 }
|
|
371
382
|
},
|
|
372
383
|
"toolProfile": { "$ref": "#/$defs/toolProfile" },
|
|
384
|
+
"governanceStandardReview": {
|
|
385
|
+
"type": "boolean",
|
|
386
|
+
"description": "Explicitly opts this node into writer-change-scoped AGENTS.md and repository-local code-standard review. Never inferred from node id or role."
|
|
387
|
+
},
|
|
373
388
|
"writePolicy": { "$ref": "#/$defs/writePolicy" },
|
|
374
389
|
"writeSet": {
|
|
375
390
|
"type": "array",
|