@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.
@@ -132,6 +132,90 @@ async function readSessionEvents(filePath) {
132
132
  return [];
133
133
  }
134
134
  }
135
+ /**
136
+ * Coerce a `tool_execution_end` result body (toolResult.content or result.content)
137
+ * into a string. Accepts plain strings, `{type:"text", text}[]` arrays, and
138
+ * message.content shapes. Returns null when no usable body is present.
139
+ */
140
+ function resultContentToString(content) {
141
+ if (content == null)
142
+ return null;
143
+ if (typeof content === "string")
144
+ return content;
145
+ if (Array.isArray(content)) {
146
+ const parts = [];
147
+ for (const part of content) {
148
+ if (typeof part === "string") {
149
+ parts.push(part);
150
+ }
151
+ else if (part && typeof part === "object" && typeof part.text === "string") {
152
+ parts.push(part.text);
153
+ }
154
+ }
155
+ return parts.length > 0 ? parts.join("\n") : null;
156
+ }
157
+ return null;
158
+ }
159
+ /**
160
+ * Scan session events for successful paired `read` tool calls and return the
161
+ * most recent result body + timestamp whose repo-relative path matches
162
+ * `relPath` exactly. Used for AC-002: show the content the model actually
163
+ * received at read time, never the current workspace file.
164
+ */
165
+ export async function extractSpecReadContent(repoRoot, dagRunId, nodeId, relPath) {
166
+ if (!relPath || !isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
167
+ return null;
168
+ }
169
+ // The preview endpoint must expose exactly the same membership as the
170
+ // `specReads` list, not every file the node happened to read.
171
+ if (!isSpecFilePath(relPath))
172
+ return null;
173
+ const eventsPath = resolveSessionEventsPath(repoRoot, dagRunId, nodeId);
174
+ if (!eventsPath)
175
+ return null;
176
+ const events = await readSessionEvents(eventsPath);
177
+ const startMap = new Map();
178
+ let latest = null;
179
+ for (const event of events) {
180
+ const type = event.type;
181
+ const toolName = resolveToolName(event);
182
+ const toolCallId = event.toolCallId;
183
+ if (type === "tool_execution_start" && toolCallId) {
184
+ startMap.set(toolCallId, { toolName, input: eventToolArgs(event) });
185
+ continue;
186
+ }
187
+ if (type !== "tool_execution_end" || toolName !== "read" || !toolCallId)
188
+ continue;
189
+ const start = startMap.get(toolCallId);
190
+ if (!start || start.toolName !== "read")
191
+ continue;
192
+ const isErrored = event.isError === true ||
193
+ event.toolResult?.error != null ||
194
+ event.toolResult?.ok === false ||
195
+ event.result?.error != null ||
196
+ event.result?.ok === false;
197
+ if (isErrored) {
198
+ startMap.delete(toolCallId);
199
+ continue;
200
+ }
201
+ const startInput = start.input ?? {};
202
+ const filePath = typeof startInput.path === "string" ? startInput.path : undefined;
203
+ if (!filePath) {
204
+ startMap.delete(toolCallId);
205
+ continue;
206
+ }
207
+ const rel = toRepoRelative(repoRoot, filePath);
208
+ startMap.delete(toolCallId);
209
+ if (!rel || rel !== relPath)
210
+ continue;
211
+ const body = resultContentToString(event.toolResult?.content) ??
212
+ resultContentToString(event.result?.content);
213
+ if (body == null)
214
+ continue;
215
+ latest = { content: body, timestamp: eventTimestamp(event) };
216
+ }
217
+ return latest;
218
+ }
135
219
  export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
136
220
  if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
137
221
  return null;
@@ -29,6 +29,29 @@ export async function fetchJsonResult(url) {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Fetch a spec-evidence file preview (binding source or successful read).
34
+ * Resolves to the parsed JSON body, or throws an Error carrying the HTTP
35
+ * status and server message so the UI can render an accessible error state.
36
+ */
37
+ export async function fetchSpecEvidenceFile(runId, nodeId, source, relPath) {
38
+ const url = `/api/dag-runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/spec-evidence/file?source=${encodeURIComponent(source)}&path=${encodeURIComponent(relPath)}`;
39
+ const res = await fetch(url);
40
+ let body = null;
41
+ try {
42
+ body = await res.json();
43
+ } catch {
44
+ body = null;
45
+ }
46
+ if (!res.ok) {
47
+ const message = body?.error || `加载失败:HTTP ${res.status}`;
48
+ const error = new Error(message);
49
+ error.status = res.status;
50
+ throw error;
51
+ }
52
+ return body;
53
+ }
54
+
32
55
  export function artifactUrl(artifactPath) {
33
56
  return `/api/artifacts?path=${encodeURIComponent(artifactPath)}&tail=200`;
34
57
  }
@@ -46,6 +46,13 @@ export const uiState = {
46
46
  dagTimelineViewportState: null,
47
47
  dagNodeOutputViewportState: null,
48
48
  runOutputViewportState: null,
49
+ /**
50
+ * Spec-evidence file preview drill-down. null = list view; otherwise
51
+ * { source, path, loading, error, data, triggerId } for the active preview.
52
+ */
53
+ specEvidenceDetail: null,
54
+ /** Last-rendered spec-evidence list, used to restore the list synchronously. */
55
+ specEvidenceListEvidence: null,
49
56
  pollingGeneration: 0,
50
57
  };
51
58
 
@@ -271,3 +278,22 @@ export function bumpPollingGeneration() {
271
278
  uiState.pollingGeneration += 1;
272
279
  return uiState.pollingGeneration;
273
280
  }
281
+
282
+ /** Open an in-inspector spec-evidence file preview (AC-001/002). */
283
+ export function openSpecEvidenceDetail(source, path, triggerEl) {
284
+ uiState.specEvidenceDetail = {
285
+ source,
286
+ path,
287
+ loading: true,
288
+ error: null,
289
+ data: null,
290
+ triggerId: triggerEl?.id ?? null,
291
+ };
292
+ }
293
+
294
+ /** Close the preview; caller restores focus to the trigger element. */
295
+ export function closeSpecEvidenceDetail() {
296
+ const detail = uiState.specEvidenceDetail;
297
+ uiState.specEvidenceDetail = null;
298
+ return detail;
299
+ }
@@ -1863,6 +1863,16 @@ body.is-resizing-dag-graph {
1863
1863
  .spec-evidence-time { margin-left: auto; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
1864
1864
  .spec-evidence-warning { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--space-4); padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--amber) 50%, var(--hairline)); border-radius: var(--radius-sm); background: #fff8e7; color: var(--body); font-size: 12px; line-height: 1.6; }
1865
1865
  .spec-evidence-warning > i { flex-shrink: 0; margin-top: 2px; color: var(--amber); }
1866
+ .spec-evidence-file-btn { display: flex; align-items: flex-start; gap: 6px; width: 100%; padding: 4px 0; background: none; border: none; text-align: left; cursor: pointer; color: inherit; font: inherit; line-height: 1.5; overflow-wrap: anywhere; border-radius: var(--radius-sm); }
1867
+ .spec-evidence-file-btn:hover, .spec-evidence-file-btn:focus-visible { background: color-mix(in srgb, var(--accent, #2563eb) 8%, transparent); outline: none; }
1868
+ .spec-evidence-file-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent, #2563eb); }
1869
+ .spec-evidence-detail { display: grid; gap: var(--space-3); }
1870
+ .spec-evidence-back { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid var(--hairline); border-radius: var(--radius-sm); background: var(--surface, #fff); color: var(--ink); font-size: 12px; cursor: pointer; }
1871
+ .spec-evidence-back:hover, .spec-evidence-back:focus-visible { background: color-mix(in srgb, var(--accent, #2563eb) 8%, var(--surface, #fff)); }
1872
+ .spec-evidence-detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: var(--body); }
1873
+ .spec-evidence-detail-content { min-height: 0; max-width: 100%; margin: 0; padding: var(--space-3); border: 1px solid var(--hairline); border-radius: var(--radius-sm); background: var(--surface, #fff); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.6; }
1874
+ .spec-evidence-detail-error { margin: 0; color: var(--red, #c00); font-size: 12px; }
1875
+ .spec-evidence-detail-truncated { margin: 0; color: var(--muted); font-size: 11px; }
1866
1876
 
1867
1877
  .run-layer-process {
1868
1878
  display: grid;
@@ -10,12 +10,14 @@ import {
10
10
  UI_TEXT,
11
11
  } from "../constants.js";
12
12
  import { clearNode, el } from "../dom.js";
13
- import { fetchJson } from "../api.js";
13
+ import { fetchJson, fetchSpecEvidenceFile } from "../api.js";
14
14
  import { badge } from "../format.js";
15
15
  import { renderMarkdown } from "../markdown-render.js";
16
16
  import {
17
17
  uiState,
18
18
  makeSessionEventIdentity,
19
+ openSpecEvidenceDetail,
20
+ closeSpecEvidenceDetail,
19
21
  updateDagTimelineViewportState,
20
22
  updateDagOutputViewportState,
21
23
  computeFollowLatest,
@@ -44,6 +46,10 @@ export function selectDagNode(dagRunId, nodeId) {
44
46
  if (nodeChanged) {
45
47
  uiState.sessionEventOffset = 0;
46
48
  uiState.sessionEvents = [];
49
+ // Drop any open spec-evidence preview so a stale A detail cannot survive
50
+ // into node B's spec-evidence panel (AC-007 regression guard).
51
+ uiState.specEvidenceDetail = null;
52
+ uiState.specEvidenceListEvidence = null;
47
53
  // Bump the session-event identity token so any in-flight response for the
48
54
  // previous node is rejected before it can mutate the current node cache.
49
55
  uiState.sessionEventIdentity = makeSessionEventIdentity(dagRunId, nodeId);
@@ -188,15 +194,20 @@ export function captureDagTimelineViewportState(panel) {
188
194
  }
189
195
  }
190
196
 
191
- async function renderSpecEvidence(content, dagRunId, nodeId) {
192
- const evidence = await fetchJson(
197
+ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
198
+ const evidence = cachedEvidence ?? await fetchJson(
193
199
  `/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(nodeId)}/spec-evidence`,
194
200
  );
195
201
  if (!content.isConnected || content.dataset.dagNodeId !== String(nodeId)) return;
202
+ if (uiState.specEvidenceDetail) {
203
+ renderSpecEvidenceDetail(content, dagRunId, nodeId);
204
+ return;
205
+ }
196
206
  if (!evidence) {
197
207
  content.appendChild(el("p", "empty", "无法加载规范证据。"));
198
208
  return;
199
209
  }
210
+ uiState.specEvidenceListEvidence = evidence;
200
211
 
201
212
  const statusLabels = {
202
213
  "spec-read": "已读取规范",
@@ -241,9 +252,24 @@ async function renderSpecEvidence(content, dagRunId, nodeId) {
241
252
  });
242
253
  appendListSection("已绑定任务源", evidence.sourceBinding?.sources, (source) => {
243
254
  const item = el("li", null);
255
+ const button = document.createElement("button");
256
+ button.type = "button";
257
+ button.className = "spec-evidence-file-btn";
258
+ button.setAttribute("data-source", "binding");
259
+ button.setAttribute("data-path", source.path);
260
+ button.id = `spec-evidence-btn-binding-${source.path}`;
244
261
  const icon = el("i", "ri-links-line");
245
262
  icon.setAttribute("aria-hidden", "true");
246
- item.append(icon, document.createTextNode(` ${source.kind}: `), el("code", null, source.path), document.createTextNode(` · sha256 ${source.sha256.slice(0, 12)}…`));
263
+ button.append(
264
+ icon,
265
+ document.createTextNode(` ${source.kind}: `),
266
+ el("code", null, source.path),
267
+ document.createTextNode(` · sha256 ${source.sha256.slice(0, 12)}…`),
268
+ );
269
+ button.addEventListener("click", () =>
270
+ onSpecEvidenceFileClick(content, dagRunId, nodeId, "binding", source.path, button),
271
+ );
272
+ item.appendChild(button);
247
273
  return item;
248
274
  });
249
275
  appendListSection("显式需求编号", evidence.sourceBinding?.requirementIds, (id) => el("li", null, id));
@@ -252,11 +278,20 @@ async function renderSpecEvidence(content, dagRunId, nodeId) {
252
278
  evidence.specReads,
253
279
  (read) => {
254
280
  const item = el("li", null);
281
+ const button = document.createElement("button");
282
+ button.type = "button";
283
+ button.className = "spec-evidence-file-btn";
284
+ button.setAttribute("data-source", "read");
285
+ button.setAttribute("data-path", read.path);
286
+ if (read.timestamp) {
287
+ button.setAttribute("data-read-at", read.timestamp);
288
+ }
289
+ button.id = `spec-evidence-btn-read-${read.path}`;
255
290
  const icon = el("i", "ri-file-text-line");
256
291
  icon.setAttribute("aria-hidden", "true");
257
- item.append(icon, el("code", null, read.path));
292
+ button.append(icon, el("code", null, read.path));
258
293
  if (read.timestamp) {
259
- item.appendChild(
294
+ button.appendChild(
260
295
  el(
261
296
  "span",
262
297
  "spec-evidence-time",
@@ -264,6 +299,10 @@ async function renderSpecEvidence(content, dagRunId, nodeId) {
264
299
  ),
265
300
  );
266
301
  }
302
+ button.addEventListener("click", () =>
303
+ onSpecEvidenceFileClick(content, dagRunId, nodeId, "read", read.path, button),
304
+ );
305
+ item.appendChild(button);
267
306
  return item;
268
307
  },
269
308
  );
@@ -308,7 +347,135 @@ async function renderSpecEvidence(content, dagRunId, nodeId) {
308
347
  }
309
348
  }
310
349
 
350
+ /**
351
+ * Click handler for spec-evidence file buttons (AC-001/002). Opens the detail
352
+ * view in the same spec-evidence panel (no modal), records the trigger button
353
+ * so the back action can restore keyboard focus (AC-005).
354
+ */
355
+ function onSpecEvidenceFileClick(content, dagRunId, nodeId, source, relPath, button) {
356
+ openSpecEvidenceDetail(source, relPath, button);
357
+ clearNode(content);
358
+ const detail = uiState.specEvidenceDetail;
359
+ renderSpecEvidenceDetail(content, dagRunId, nodeId);
360
+ void (async () => {
361
+ try {
362
+ const data = await fetchSpecEvidenceFile(dagRunId, nodeId, source, relPath);
363
+ if (
364
+ !content.isConnected ||
365
+ content.dataset.dagNodeId !== String(nodeId) ||
366
+ !uiState.specEvidenceDetail ||
367
+ uiState.specEvidenceDetail.source !== source ||
368
+ uiState.specEvidenceDetail.path !== relPath
369
+ ) {
370
+ return;
371
+ }
372
+ detail.loading = false;
373
+ detail.data = data;
374
+ } catch (error) {
375
+ if (
376
+ !content.isConnected ||
377
+ content.dataset.dagNodeId !== String(nodeId) ||
378
+ !uiState.specEvidenceDetail ||
379
+ uiState.specEvidenceDetail.source !== source ||
380
+ uiState.specEvidenceDetail.path !== relPath
381
+ ) {
382
+ return;
383
+ }
384
+ detail.loading = false;
385
+ detail.error = error instanceof Error ? error.message : String(error);
386
+ }
387
+ renderSpecEvidenceDetail(content, dagRunId, nodeId);
388
+ })();
389
+ }
390
+
391
+ /**
392
+ * Render the in-inspector file preview (AC-005). Single scroll container for
393
+ * the body, always-visible back button, and accessible loading/error states.
394
+ */
395
+ function renderSpecEvidenceDetail(content, dagRunId, nodeId) {
396
+ const detail = uiState.specEvidenceDetail;
397
+ clearNode(content);
398
+ if (!detail) {
399
+ renderSpecEvidence(content, dagRunId, nodeId, uiState.specEvidenceListEvidence);
400
+ return;
401
+ }
402
+
403
+ const wrapper = el("div", "spec-evidence-detail");
404
+ const back = document.createElement("button");
405
+ back.type = "button";
406
+ back.className = "spec-evidence-back";
407
+ back.setAttribute("aria-label", "返回规范证据");
408
+ const backIcon = el("i", "ri-arrow-left-line");
409
+ backIcon.setAttribute("aria-hidden", "true");
410
+ back.append(backIcon, document.createTextNode(" 返回规范证据"));
411
+ back.addEventListener("click", () => {
412
+ const closed = closeSpecEvidenceDetail();
413
+ clearNode(content);
414
+ // Restore the list synchronously from the cached evidence so the user
415
+ // sees no flicker and the trigger button is immediately focusable.
416
+ renderSpecEvidence(content, dagRunId, nodeId, uiState.specEvidenceListEvidence);
417
+ if (closed?.triggerId) {
418
+ const trigger = document.getElementById(closed.triggerId);
419
+ trigger?.focus();
420
+ }
421
+ });
422
+ wrapper.appendChild(back);
423
+
424
+ const meta = el("div", "spec-evidence-detail-meta");
425
+ meta.appendChild(el("code", null, detail.path));
426
+ if (detail.source === "binding" && detail.data) {
427
+ meta.appendChild(
428
+ el(
429
+ "span",
430
+ "spec-evidence-detail-hash",
431
+ `绑定 sha256 ${String(detail.data.sha256 ?? "").slice(0, 12)}… · 当前 ${detail.data.hashMatch ? "一致" : "不一致"}`,
432
+ ),
433
+ );
434
+ } else if (detail.source === "read" && detail.data?.readAt) {
435
+ meta.appendChild(
436
+ el(
437
+ "span",
438
+ "spec-evidence-detail-time",
439
+ `读取于 ${formatSessionEventTime({ timestamp: detail.data.readAt })}`,
440
+ ),
441
+ );
442
+ }
443
+ wrapper.appendChild(meta);
444
+
445
+ if (detail.loading) {
446
+ const loading = el("p", "muted", "正在加载文件内容…");
447
+ loading.setAttribute("role", "status");
448
+ loading.setAttribute("aria-live", "polite");
449
+ wrapper.appendChild(loading);
450
+ } else if (detail.error) {
451
+ const error = el("p", "spec-evidence-detail-error", `加载失败:${detail.error}`);
452
+ error.setAttribute("role", "alert");
453
+ wrapper.appendChild(error);
454
+ } else if (detail.data) {
455
+ const body = el("pre", "spec-evidence-detail-content");
456
+ body.textContent = detail.data.content ?? "";
457
+ wrapper.appendChild(body);
458
+ if (detail.data.truncated) {
459
+ const note = el(
460
+ "p",
461
+ "spec-evidence-detail-truncated",
462
+ `已截断,仅显示前 ${detail.data.maxBytes ?? 0} 字节。`,
463
+ );
464
+ wrapper.appendChild(note);
465
+ }
466
+ }
467
+ content.appendChild(wrapper);
468
+ }
469
+
311
470
  function fillDagInspectorContent(content, dagRunId, node) {
471
+ const previousRenderedNode = content.dataset.dagNodeId ?? "";
472
+ const nodeChanged = previousRenderedNode !== String(node.nodeId ?? "");
473
+ if (nodeChanged) {
474
+ // Drop any open spec-evidence preview + cached list so a stale A detail
475
+ // cannot survive into node B (AC-007 regression guard).
476
+ uiState.specEvidenceDetail = null;
477
+ uiState.specEvidenceListEvidence = null;
478
+ }
312
479
  const activeTab = ["timeline", "spec-evidence"].includes(
313
480
  uiState.dagInspectorTab,
314
481
  )
@@ -12,6 +12,7 @@ import { resolveAdapter } from "../../adapters/index.js";
12
12
  import { loadHarnessManifest } from "../../governance/harness.js";
13
13
  import { buildAuthoritySurfaceAuditNode, buildAuthoritySurfaceGateNode, resolveAuthoritySurfaceAudit, } from "./authority-surface.js";
14
14
  import { applySddEmbeddedEnhancements, probeRepoLocalSddSkills, } from "./sdd-embedded.js";
15
+ import { discoverProjectGovernancePresence } from "./project-governance-context.js";
15
16
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
16
17
  import { materializeTaskReferenceDocs } from "../../task/source-references.js";
17
18
  import { resolveVerifyPreset } from "../../executors/shell-verification.js";
@@ -1105,6 +1106,7 @@ export async function loadTaskHybridSources(repoRoot, taskId) {
1105
1106
  executorModelMatrix: resolveExecutorModelMatrices(manifest),
1106
1107
  verifyCommands,
1107
1108
  sddEmbeddedSkills: await probeRepoLocalSddSkills(repoRoot),
1109
+ projectGovernancePresent: await discoverProjectGovernancePresence(repoRoot),
1108
1110
  };
1109
1111
  return sources;
1110
1112
  }
@@ -4663,11 +4665,35 @@ function buildHybridDagForTemplate(sources, template) {
4663
4665
  else
4664
4666
  spec = buildSupervisedHybridDag(standard, sources);
4665
4667
  }
4668
+ applyProjectGovernanceReview(spec, template, sources);
4666
4669
  spec.sourceBinding = buildDagSourceBinding(sources);
4670
+ assertNoGovernanceFlagOnDisallowedTemplate(spec, template);
4667
4671
  parseDagSpec(spec);
4668
4672
  assertValidDagSpec(spec);
4669
4673
  return spec;
4670
4674
  }
4675
+ const GOVERNANCE_DISALLOWED_TEMPLATES = new Set([
4676
+ "frontend-implementation",
4677
+ "frontend-test-dag",
4678
+ "backend-test-dag",
4679
+ "knowledge-sync-dag",
4680
+ "knowledge-graph-bootstrap-dag",
4681
+ ]);
4682
+ /**
4683
+ * Generate-time guard (constraint 2 / AC-007): the governance standard review
4684
+ * flag must never appear on knowledge-sync, knowledge-graph-bootstrap,
4685
+ * backend-test, or frontend tasks. Only standard verify-pi and the shared
4686
+ * review-pi (reviewed/supervised) opt in explicitly.
4687
+ */
4688
+ function assertNoGovernanceFlagOnDisallowedTemplate(spec, template) {
4689
+ if (!GOVERNANCE_DISALLOWED_TEMPLATES.has(template))
4690
+ return;
4691
+ for (const task of spec.tasks) {
4692
+ if (task.governanceStandardReview) {
4693
+ throw new Error(`governanceStandardReview must not be set on ${template} task ${task.id}`);
4694
+ }
4695
+ }
4696
+ }
4671
4697
  export function buildHybridDagFromTask(sources, options = {}) {
4672
4698
  const selection = resolveTaskDagTemplateSelection({
4673
4699
  taskKind: sources.taskConfig.taskKind,
@@ -4791,6 +4817,84 @@ function buildReviewGateNode(sources) {
4791
4817
  },
4792
4818
  };
4793
4819
  }
4820
+ function enableProjectGovernanceOnNode(task) {
4821
+ task.governanceStandardReview = true;
4822
+ }
4823
+ /**
4824
+ * Apply governance only to general implementation DAGs, and only when the
4825
+ * target repository actually contains AGENTS.md. Standard reuses verify-pi;
4826
+ * reviewed/supervised reuse review-pi and their existing review verdict gate.
4827
+ */
4828
+ function applyProjectGovernanceReview(spec, template, sources) {
4829
+ if (!sources.projectGovernancePresent)
4830
+ return;
4831
+ if (template === "standard-dag") {
4832
+ const verify = spec.tasks.find((task) => task.id === "verify-pi");
4833
+ if (!verify)
4834
+ return;
4835
+ enableProjectGovernanceOnNode(verify);
4836
+ insertGovernanceStandardGate(spec, sources);
4837
+ return;
4838
+ }
4839
+ if (template === "review-gated-dag" || template === "supervised-implementation") {
4840
+ const review = spec.tasks.find((task) => task.id === "review-pi");
4841
+ if (review)
4842
+ enableProjectGovernanceOnNode(review);
4843
+ }
4844
+ }
4845
+ /**
4846
+ * Deterministic governance standard gate for standard-dag closeout. It reuses
4847
+ * the standard `verdictGate` preset (fromNodeId=verify-pi, accept
4848
+ * ["VERDICT: pass"], first-verdict-line). When no applicable AGENTS.md/standard
4849
+ * exists, projectGovernanceGate returns success without parsing a verdict.
4850
+ * When an applicable mandatory standard is violated, verify-pi must emit
4851
+ * VERDICT: request-revision and the gate blocks closeout. No new model node.
4852
+ */
4853
+ function buildGovernanceStandardGateNode(sources) {
4854
+ return {
4855
+ id: "governance-standard-gate-shell",
4856
+ depends_on: ["verify-pi"],
4857
+ role: "verifier",
4858
+ executor: "shell",
4859
+ complexity: "LOW",
4860
+ writePolicy: "read-only",
4861
+ allowedPaths: commonReadOnlyPaths(sources),
4862
+ forbiddenPaths: commonForbiddenPaths(sources),
4863
+ outputContract: "Deterministic governance verdict gate: exit 0 only when verify-pi first VERDICT line is pass (covers applicable mandatory standard compliance). No file writes.",
4864
+ subtask_prompt: "Deterministic gate: block closeout unless verify-pi emitted VERDICT: pass, which requires no unresolved applicable mandatory governance-standard violation. Read-only: do not modify files.",
4865
+ shell: {
4866
+ commands: [],
4867
+ projectGovernanceGate: {
4868
+ contextPath: ".runtime/project-governance-context.json",
4869
+ },
4870
+ verdictGate: {
4871
+ fromNodeId: "verify-pi",
4872
+ accept: ["VERDICT: pass"],
4873
+ label: "governance-standard",
4874
+ lineMode: "first-verdict-line",
4875
+ },
4876
+ cwd: ".",
4877
+ timeoutMs: 60000,
4878
+ },
4879
+ };
4880
+ }
4881
+ /**
4882
+ * Insert the governance-standard gate between verify-pi and closeout-pi in a
4883
+ * standard DAG. Idempotent and no-op if verify-pi/closeout-pi are absent.
4884
+ */
4885
+ function insertGovernanceStandardGate(spec, sources) {
4886
+ const closeout = spec.tasks.find((task) => task.id === "closeout-pi");
4887
+ const verify = spec.tasks.find((task) => task.id === "verify-pi");
4888
+ if (!closeout || !verify)
4889
+ return;
4890
+ if (spec.tasks.some((task) => task.id === "governance-standard-gate-shell")) {
4891
+ return;
4892
+ }
4893
+ const gate = buildGovernanceStandardGateNode(sources);
4894
+ const closeoutIndex = spec.tasks.findIndex((task) => task.id === "closeout-pi");
4895
+ spec.tasks.splice(closeoutIndex, 0, gate);
4896
+ closeout.depends_on = ["governance-standard-gate-shell"];
4897
+ }
4794
4898
  function buildReviewGatedHybridDag(standard, sources) {
4795
4899
  const spec = {
4796
4900
  ...standard,
@@ -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) {