@tea-agent/loop-agent 0.16.25 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +14 -3
  3. package/dist/cli/command-definitions.js +43 -0
  4. package/dist/cli/program.js +26 -0
  5. package/dist/commands/dag-approve.js +4 -0
  6. package/dist/commands/dag-resume.js +1 -0
  7. package/dist/commands/dag-validate.js +6 -0
  8. package/dist/commands/operator.js +44 -0
  9. package/dist/commands/task-contract.js +271 -0
  10. package/dist/executors/dag-pi-executor.js +118 -16
  11. package/dist/executors/pi-executor.js +206 -13
  12. package/dist/executors/pi-sdk-executor.js +21 -6
  13. package/dist/executors/shell-executor.js +85 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/executors/shell-write-guard.js +64 -2
  16. package/dist/shared/operator/capabilities.js +255 -0
  17. package/dist/shared/operator/envelope.js +59 -0
  18. package/dist/shared/operator/index.js +4 -0
  19. package/dist/shared/operator/registry.js +38 -0
  20. package/dist/shared/operator/types.js +5 -0
  21. package/dist/task/contract/adopt.js +166 -0
  22. package/dist/task/contract/apply.js +326 -0
  23. package/dist/task/contract/canonicalize.js +60 -0
  24. package/dist/task/contract/constants.js +29 -0
  25. package/dist/task/contract/diff.js +177 -0
  26. package/dist/task/contract/hash.js +42 -0
  27. package/dist/task/contract/import-revision.js +96 -0
  28. package/dist/task/contract/index.js +17 -0
  29. package/dist/task/contract/journal.js +155 -0
  30. package/dist/task/contract/lock.js +153 -0
  31. package/dist/task/contract/observe.js +296 -0
  32. package/dist/task/contract/paths.js +19 -0
  33. package/dist/task/contract/project.js +170 -0
  34. package/dist/task/contract/recover.js +312 -0
  35. package/dist/task/contract/request-ledger.js +37 -0
  36. package/dist/task/contract/schema.js +151 -0
  37. package/dist/task/contract/transaction.js +160 -0
  38. package/dist/task/contract/types.js +1 -0
  39. package/dist/task/contract/validate-draft.js +106 -0
  40. package/dist/task/index.js +3 -0
  41. package/dist/task/operator/capabilities.js +6 -0
  42. package/dist/task/operator/envelope.js +2 -0
  43. package/dist/task/operator/index.js +5 -0
  44. package/dist/task/operator/registry.js +2 -0
  45. package/dist/task/operator/types.js +1 -0
  46. package/dist/task/runtime.js +5 -1
  47. package/dist/task/source-references.js +7 -0
  48. package/dist/worker/cli.js +150 -32
  49. package/dist/worker/console/app-data.js +185 -0
  50. package/dist/worker/console/dag-confirmation.js +313 -0
  51. package/dist/worker/console/doctor.js +169 -0
  52. package/dist/worker/console/draft-store.js +80 -0
  53. package/dist/worker/console/index.js +15 -0
  54. package/dist/worker/console/interview/assessment.js +67 -0
  55. package/dist/worker/console/interview/session.js +100 -0
  56. package/dist/worker/console/interview/tools.js +109 -0
  57. package/dist/worker/console/loopback.js +16 -0
  58. package/dist/worker/console/observe-health-match.js +174 -0
  59. package/dist/worker/console/observe-link.js +33 -0
  60. package/dist/worker/console/operation-runner.js +166 -0
  61. package/dist/worker/console/operation-sse.js +158 -0
  62. package/dist/worker/console/operation-store.js +147 -0
  63. package/dist/worker/console/operator-actions.js +769 -0
  64. package/dist/worker/console/pi-readiness.js +94 -0
  65. package/dist/worker/console/recovery-cta.js +133 -0
  66. package/dist/worker/console/repo-fingerprint.js +29 -0
  67. package/dist/worker/console/resource-loader.js +95 -0
  68. package/dist/worker/console/routes.js +368 -0
  69. package/dist/worker/console/security.js +126 -0
  70. package/dist/worker/console/server.js +149 -0
  71. package/dist/worker/console/sibling-controller.js +28 -0
  72. package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
  73. package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
  74. package/dist/worker/console/static/index.html +13 -0
  75. package/dist/worker/console/vite.config.js +27 -0
  76. package/dist/worker/delivery/git-transaction.js +43 -8
  77. package/dist/worker/observe/health.js +57 -0
  78. package/dist/worker/observe/paths.js +81 -0
  79. package/dist/worker/observe/routes.js +142 -27
  80. package/dist/worker/observe/spec-evidence.js +84 -0
  81. package/dist/worker/observe/static/api.js +23 -0
  82. package/dist/worker/observe/static/state.js +26 -0
  83. package/dist/worker/observe/static/styles.css +10 -0
  84. package/dist/worker/observe/static/views/dag-inspector.js +173 -6
  85. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  86. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  87. package/dist/workflows/dag/frontend-repair.js +1 -10
  88. package/dist/workflows/dag/init-hybrid.js +372 -115
  89. package/dist/workflows/dag/node-execution.js +57 -6
  90. package/dist/workflows/dag/project-governance-context.js +508 -0
  91. package/dist/workflows/dag/prompt.js +46 -1
  92. package/dist/workflows/dag/retry-policy.js +16 -1
  93. package/dist/workflows/dag/runner.js +9 -0
  94. package/dist/workflows/dag/skill-snapshot.js +1 -0
  95. package/dist/workflows/dag/task-contract-binding.js +138 -0
  96. package/dist/workflows/dag/types.js +84 -10
  97. package/dist/workflows/dag/validate.js +53 -7
  98. package/docs/README.md +2 -0
  99. package/docs/architecture/evolution.md +2 -0
  100. package/docs/architecture/system-overview.md +6 -0
  101. package/docs/architecture/worker-and-feature.md +7 -0
  102. package/docs/templates/agent-dag.schema.json +64 -2
  103. package/docs/templates/agent-dag.supervised-implementation.json +1 -0
  104. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  105. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  106. package/docs/templates/backend-test-dag.json +26 -154
  107. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  108. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  109. package/package.json +8 -2
  110. package/skills/agent-worker/SKILL.md +1 -0
  111. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  112. package/skills/frontend-design-review/SKILL.md +25 -16
  113. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  114. package/skills/loop-agent/references/command-reference.md +48 -1
  115. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -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,27 @@ 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
+ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory) {
29
+ if (attemptNumber <= 1 ||
30
+ task.outputMode !== "structured-required" ||
31
+ previousFailureCategory !== "output-too-large") {
32
+ return basePrompt;
33
+ }
34
+ return [
35
+ basePrompt,
36
+ "",
37
+ "<retry_instruction>",
38
+ "Previous attempt exceeded the structured output size limit.",
39
+ "Return only the compact structured artifact required by this node's output contract.",
40
+ "Do not include explanatory prose, duplicated upstream context, long evidence excerpts, or additional markdown sections.",
41
+ "If a fenced JSON object is required, output exactly one fenced json block and nothing else.",
42
+ "</retry_instruction>",
43
+ ].join("\n");
44
+ }
45
+ export async function buildNodePromptWithResolvedSkillInstructions(spec, task, upstream, cwd, options) {
27
46
  const policy = resolveContextPolicy(spec);
28
47
  const skillNames = policy.resolveSkills(spec, task);
29
48
  const budget = policy.resolveSkillInstructionBudget(task);
@@ -43,6 +62,7 @@ export async function buildNodePromptWithResolvedSkillInstructions(spec, task, u
43
62
  resolvedSkills: skillNames,
44
63
  resolvedSkillInstructions,
45
64
  maxUpstreamChars: policy.resolveMaxUpstreamChars(task),
65
+ projectGovernanceContext: options?.projectGovernanceContext,
46
66
  }),
47
67
  resolvedSkills: skillInstructionMetadata(resolvedSkillInstructions),
48
68
  };
@@ -138,12 +158,12 @@ export async function executeDagNode(input) {
138
158
  const { nodeId, tasksById, state, spec, cwd, runDir, executeNode } = input;
139
159
  const task = tasksById.get(nodeId);
140
160
  const node = state.nodes[nodeId];
141
- const failSkillSnapshot = async (error) => {
161
+ const failBeforePrompt = async (error, failureCategory) => {
142
162
  const failedAt = new Date().toISOString();
143
163
  node.startedAt ??= failedAt;
144
164
  node.status = "ERROR";
145
165
  node.stderr = error instanceof Error ? error.message : String(error);
146
- node.failureCategory = "skill-snapshot-integrity";
166
+ node.failureCategory = failureCategory;
147
167
  node.finishedAt = failedAt;
148
168
  node.lastActivityAt = node.finishedAt;
149
169
  node.durationMs = Math.max(0, Date.now() - new Date(node.startedAt).getTime());
@@ -152,6 +172,29 @@ export async function executeDagNode(input) {
152
172
  await input.persistState();
153
173
  await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
154
174
  };
175
+ const failSkillSnapshot = (error) => failBeforePrompt(error, "skill-snapshot-integrity");
176
+ let projectGovernanceContext;
177
+ if (task.governanceStandardReview) {
178
+ try {
179
+ const changeManifest = await readCompletedWriterChangeManifests({
180
+ runDir,
181
+ spec,
182
+ state,
183
+ });
184
+ projectGovernanceContext = await buildProjectGovernanceContext({
185
+ runId: state.runId,
186
+ cwd,
187
+ changeManifest,
188
+ runDir,
189
+ });
190
+ state.projectGovernanceContextRef = await writeProjectGovernanceContext(runDir, projectGovernanceContext);
191
+ await input.persistState();
192
+ }
193
+ catch (error) {
194
+ await failBeforePrompt(error, "project-governance-integrity");
195
+ return;
196
+ }
197
+ }
155
198
  const isDynamicTask = Boolean(task.dynamicExpansion
156
199
  || task.dynamicReduction
157
200
  || task.dynamicCondition
@@ -169,6 +212,7 @@ export async function executeDagNode(input) {
169
212
  task,
170
213
  upstream: state.nodes,
171
214
  snapshot: skillSnapshot,
215
+ projectGovernanceContext,
172
216
  });
173
217
  }
174
218
  }
@@ -237,7 +281,7 @@ export async function executeDagNode(input) {
237
281
  }
238
282
  else {
239
283
  ({ prompt, resolvedSkills } =
240
- await buildNodePromptWithResolvedSkillInstructions(spec, task, state.nodes, cwd));
284
+ await buildNodePromptWithResolvedSkillInstructions(spec, task, state.nodes, cwd, { projectGovernanceContext }));
241
285
  }
242
286
  }
243
287
  catch (error) {
@@ -256,6 +300,7 @@ export async function executeDagNode(input) {
256
300
  const attempts = [];
257
301
  let totalBackoffMs = 0;
258
302
  let terminalResult;
303
+ let previousFailureCategory;
259
304
  for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
260
305
  const attemptStartedAt = new Date().toISOString();
261
306
  const attemptStarted = Date.now();
@@ -266,7 +311,12 @@ export async function executeDagNode(input) {
266
311
  tasksById,
267
312
  state,
268
313
  });
269
- result = await executeNode({ task, cwd, model, prompt });
314
+ result = await executeNode({
315
+ task,
316
+ cwd,
317
+ model,
318
+ prompt: buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory),
319
+ });
270
320
  }
271
321
  catch (error) {
272
322
  result = {
@@ -329,6 +379,7 @@ export async function executeDagNode(input) {
329
379
  await input.persistState();
330
380
  }
331
381
  terminalResult = result;
382
+ previousFailureCategory = result.failureCategory;
332
383
  if (result.ok)
333
384
  break;
334
385
  const canRetry = retryPolicy !== undefined && attemptNumber < maxAttempts;
@@ -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
+ }