@tea-agent/loop-agent 0.43.0-next.1 → 0.43.0-next.2

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 (78) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/application/evaluation/budget.js +19 -1
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/executors/dag-pi-executor.js +762 -252
  5. package/dist/executors/pi-executor.js +20 -1
  6. package/dist/executors/pi-sdk-executor.js +64 -1
  7. package/dist/executors/shell-executor.js +5 -2
  8. package/dist/shared/frontend-execution-policy.js +22 -0
  9. package/dist/task/config-types.js +4 -0
  10. package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
  11. package/dist/task/source-prepare/ledger-review.js +6 -9
  12. package/dist/task/source-prepare/semantic-intake.js +16 -26
  13. package/dist/task/source-prepare/source-fidelity-pi.js +26 -7
  14. package/dist/worker/observe/node-transparency.js +81 -72
  15. package/dist/worker/observe/routes.js +20 -1
  16. package/dist/worker/observe/static/dag-inspector-humanize.js +3 -0
  17. package/dist/worker/observe/static/dom.js +20 -1
  18. package/dist/worker/observe/static/format-pool.d.ts +2 -0
  19. package/dist/worker/observe/static/format-pool.js +6 -0
  20. package/dist/worker/observe/static/format.js +7 -0
  21. package/dist/worker/observe/static/inspect-workspace.js +34 -7
  22. package/dist/worker/observe/static/inspector-submission.js +32 -0
  23. package/dist/worker/observe/static/kpi.js +1 -0
  24. package/dist/worker/observe/static/relations.js +2 -0
  25. package/dist/worker/observe/static/router.js +13 -0
  26. package/dist/worker/observe/static/run-processing.js +2 -0
  27. package/dist/worker/observe/static/shell-chrome.js +36 -3
  28. package/dist/worker/observe/static/state.js +35 -2
  29. package/dist/worker/observe/static/styles.css +260 -39
  30. package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
  31. package/dist/worker/observe/static/task-failure-labels.js +67 -0
  32. package/dist/worker/observe/static/task-history.js +12 -0
  33. package/dist/worker/observe/static/views/batch.js +6 -13
  34. package/dist/worker/observe/static/views/dag-graph.js +50 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +746 -265
  36. package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
  37. package/dist/worker/observe/static/views/dag.d.ts +6 -0
  38. package/dist/worker/observe/static/views/dag.js +48 -10
  39. package/dist/worker/observe/static/views/dags.js +2 -0
  40. package/dist/worker/observe/static/views/dashboard.js +21 -12
  41. package/dist/worker/observe/static/views/failures.js +21 -11
  42. package/dist/worker/observe/static/views/feature.js +11 -29
  43. package/dist/worker/observe/static/views/pool.js +37 -28
  44. package/dist/worker/observe/static/views/run.js +48 -5
  45. package/dist/worker/observe/static/views/session-timeline.js +189 -240
  46. package/dist/worker/observe/static/views/task.js +81 -62
  47. package/dist/workflows/dag/budget-enforcement.js +53 -3
  48. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  49. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  50. package/dist/workflows/dag/frontend-implementation-contract.js +9 -0
  51. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  52. package/dist/workflows/dag/frontend-plan-render.js +10 -3
  53. package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
  54. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  55. package/dist/workflows/dag/frontend-recovery-run.js +4 -0
  56. package/dist/workflows/dag/frontend-review-scopes.js +117 -0
  57. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  58. package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
  59. package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
  60. package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
  61. package/dist/workflows/dag/init-hybrid.js +16 -10
  62. package/dist/workflows/dag/node-execution.js +32 -155
  63. package/dist/workflows/dag/prompt.js +4 -0
  64. package/dist/workflows/dag/rerun-plan.js +7 -1
  65. package/dist/workflows/dag/runner.js +26 -1
  66. package/dist/workflows/dag/types.js +6 -0
  67. package/docs/operations/README.md +1 -0
  68. package/docs/templates/frontend-design-contract.md +4 -4
  69. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  70. package/docs/templates/frontend-implementation-dag.json +5 -5
  71. package/package.json +1 -1
  72. package/skills/frontend-contract/SKILL.md +2 -1
  73. package/skills/frontend-contract/references/contract-protocol.md +19 -3
  74. package/skills/frontend-design-review/SKILL.md +12 -11
  75. package/skills/frontend-plan/SKILL.md +2 -2
  76. package/skills/frontend-plan/references/decision-contract.md +18 -5
  77. package/skills/frontend-review/SKILL.md +10 -11
  78. package/skills/frontend-scout/references/scout-evidence.md +4 -0
@@ -1,3 +1,4 @@
1
+ import { collectFrontendExecutionGroups } from "./frontend-execution-groups.js";
1
2
  /**
2
3
  * Deterministic Markdown renderer for a canonical frontend implementation
3
4
  * contract. The prewrite gate materializes this as `contracts/frontend-plan.md`
@@ -17,13 +18,19 @@ function bulletList(items) {
17
18
  * prose — the renderer must never present it as requirement content. */
18
19
  const IMPLIED_OUTCOME_PREFIX = "implied by contract:";
19
20
  function requirementsCoverage(contract) {
20
- const lines = contract.requirements.map((requirement) => {
21
+ const renderRequirement = (requirement, includeOutcome = true) => {
21
22
  const targets = requirement.implementationTargets.join(", ");
22
23
  const verificationTargets = requirement.verificationTargetIds.join(", ");
23
- const authoredOutcome = requirement.expectedOutcome.startsWith(IMPLIED_OUTCOME_PREFIX)
24
+ const authoredOutcome = !includeOutcome || requirement.expectedOutcome.startsWith(IMPLIED_OUTCOME_PREFIX)
24
25
  ? ""
25
26
  : ` — ${requirement.expectedOutcome}`;
26
27
  return `- ${requirement.id} → ${targets || "(no targets)"} [${verificationTargets || "no VT"}]${authoredOutcome}`;
28
+ };
29
+ const lines = collectFrontendExecutionGroups(contract.requirements).flatMap(group => {
30
+ const members = contract.requirements.filter(r => group.requirementIds.includes(r.id));
31
+ if (group.kind === "unclassified")
32
+ return members.map(r => renderRequirement(r));
33
+ return [`### ${group.id} (${group.kind})`, group.summary, ...members.map(r => renderRequirement(r, false)), "Full independent acceptance conditions remain in contracts/frontend-implementation-contract.json."];
27
34
  });
28
35
  return lines.length > 0 ? lines.join("\n") : "_(no requirements)_";
29
36
  }
@@ -72,7 +79,7 @@ function sourceFidelityLedger(contract) {
72
79
  `- ledger: ${binding.ledgerPath}`,
73
80
  `- ledgerSha256: ${binding.ledgerSha256 ?? "(missing)"}`,
74
81
  `- inputDigest: ${binding.inputDigest ?? "(missing)"}`,
75
- "- 语义权威:原始 requirement/acceptance 文档与 requirement-ledger.json;本计划只消费派生摘要与 bound ledger 指针,长原文不内联。",
82
+ "- 语义权威:原始 requirement/acceptance 文档与 requirement-ledger.json;本计划按执行组展示共享方案;独立验收全文保留在 canonical contract,执行输入按完整义务分批。",
76
83
  ];
77
84
  const boundCount = Object.keys(binding.requirementToFragments ?? {}).length;
78
85
  if (boundCount > 0) {
@@ -96,13 +96,13 @@ export async function runRecoveryOperationController(input) {
96
96
  };
97
97
  }
98
98
  // Total budget fold + hard stop (resource protection, fail-closed).
99
- const ledgers = await readLedgers();
100
- if (deps.budget) {
101
- budgetAggregate = foldOperationBudgets({
102
- ledgers,
103
- budget: deps.budget,
104
- now,
105
- });
99
+ try {
100
+ const ledgers = await readLedgers();
101
+ if (deps.budget)
102
+ budgetAggregate = foldOperationBudgets({ ledgers, budget: deps.budget, now });
103
+ }
104
+ catch (error) {
105
+ return { terminalReason: "needs-attention", attempts: attempt, budgetAggregate, fingerprints, detail: `Recovery budget evidence unavailable: ${error instanceof Error ? error.message : String(error)}` };
106
106
  }
107
107
  if (budgetAggregate?.status === "breached") {
108
108
  return {
@@ -87,6 +87,7 @@ export function foldOperationBudgets(input) {
87
87
  limits: { ...input.budget.limits },
88
88
  consumed: {
89
89
  executorCalls: 0,
90
+ ...(input.budget.limits.maxProviderRequests !== undefined ? { providerRequests: 0 } : {}),
90
91
  wallTimeMs: 0,
91
92
  tokens: null,
92
93
  repairPasses: 0,
@@ -100,6 +101,15 @@ export function foldOperationBudgets(input) {
100
101
  let knownTokenSum = 0;
101
102
  const missing = new Set();
102
103
  for (const ledger of input.ledgers) {
104
+ if (input.budget.limits.maxProviderRequests !== undefined) {
105
+ if (ledger.consumed.providerRequests === undefined) {
106
+ if (input.budget.mode === "hard")
107
+ throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: missing operation request consumption");
108
+ delete aggregate.consumed.providerRequests;
109
+ }
110
+ else if (aggregate.consumed.providerRequests !== undefined)
111
+ aggregate.consumed.providerRequests += ledger.consumed.providerRequests;
112
+ }
103
113
  aggregate.consumed.executorCalls += ledger.consumed.executorCalls;
104
114
  aggregate.consumed.wallTimeMs += ledger.consumed.wallTimeMs;
105
115
  aggregate.consumed.repairPasses += ledger.consumed.repairPasses;
@@ -119,6 +129,9 @@ export function foldOperationBudgets(input) {
119
129
  aggregate.consumed.missingTokenNodeIds = [...missing].sort((a, b) => a.localeCompare(b));
120
130
  if (aggregate.mode === "hard") {
121
131
  const limits = aggregate.limits;
132
+ pushAggregateBreach(aggregate, "providerRequests", aggregate.consumed.providerRequests ?? 0, limits.maxProviderRequests, now);
133
+ if (aggregate.breaches.length)
134
+ aggregate.status = "breached";
122
135
  pushAggregateBreach(aggregate, "executorCalls", aggregate.consumed.executorCalls, limits.maxExecutorCalls, now);
123
136
  pushAggregateBreach(aggregate, "wallTimeMs", aggregate.consumed.wallTimeMs, limits.maxWallTimeMs, now);
124
137
  pushAggregateBreach(aggregate, "repairPasses", aggregate.consumed.repairPasses, limits.maxRepairPasses, now);
@@ -1,5 +1,6 @@
1
1
  import { copyFile, mkdir, readdir, readFile, rm, access } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
3
4
  import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
4
5
  import { sha256Hex } from "./frontend-implementation-contract.js";
5
6
  import { FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH, FRONTEND_RECOVERY_INTENT_REL_DIR, FRONTEND_RECOVERY_STAGING_PREFIX, computeFrontendRecoveryPlan, computeFrontendRecoveryPlanForSource, } from "./frontend-recovery-plan.js";
@@ -413,6 +414,8 @@ async function materializeChildStaging(input) {
413
414
  }
414
415
  }
415
416
  const childState = {
417
+ ...(parentState.budget ? { budget: structuredClone(parentState.budget) } : {}),
418
+ ...(parentState.budgetLedger ? { budgetLedger: structuredClone(parentState.budgetLedger) } : {}),
416
419
  version: 1,
417
420
  title: parentSpec.title,
418
421
  runId: childRunId,
@@ -434,6 +437,7 @@ async function materializeChildStaging(input) {
434
437
  }
435
438
  : {}),
436
439
  frontendRecoveryState: {
440
+ ...(parentState.budgetLedger ? { budgetLineage: { parentLedgerSha256: sha256OfCanonicalJson(parentState.budgetLedger), inheritedAt: createdAt, inheritedConsumed: structuredClone(parentState.budgetLedger.consumed) } } : {}),
437
441
  schemaVersion: 1,
438
442
  phase: "child-running",
439
443
  requestId,
@@ -0,0 +1,117 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFile, realpath, lstat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
5
+ import { readCommittedEvents } from "./frontend-typed-event-store.js";
6
+ import { stageTypedEventFact, adoptTypedEventFact } from "./frontend-typed-event-transaction.js";
7
+ const hash = (text) => createHash("sha256").update(text).digest("hex");
8
+ /** The section index is navigation; reconstruct coverage against the canonical source before using it. */
9
+ export async function loadFrontendReviewScopes(runDir, phase, targetBytes) {
10
+ const root = await realpath(runDir);
11
+ const files = new Map();
12
+ async function read(relative, expected) {
13
+ const absolute = path.resolve(root, relative);
14
+ if (!absolute.startsWith(root + path.sep) || (await lstat(absolute)).isSymbolicLink() || !(await realpath(absolute)).startsWith(root + path.sep))
15
+ throw Error("FRONTEND_REVIEW_SCOPE_INVALID: artifact escaped run");
16
+ const text = await readFile(absolute, "utf8");
17
+ const sha256 = hash(text);
18
+ if (expected && sha256 !== expected)
19
+ throw Error(`FRONTEND_REVIEW_SCOPE_DRIFT: ${relative}`);
20
+ files.set(relative, sha256);
21
+ return { id: relative, text, sha256 };
22
+ }
23
+ let indexArtifact;
24
+ try {
25
+ indexArtifact = await read("contracts/frontend-implementation-contract.index.json");
26
+ }
27
+ catch (error) {
28
+ if (error.code === "ENOENT")
29
+ return undefined;
30
+ throw error;
31
+ }
32
+ const index = JSON.parse(indexArtifact.text);
33
+ const canonicalArtifact = await read(index.contract.path, index.contract.sha256);
34
+ const canonical = JSON.parse(canonicalArtifact.text);
35
+ const covered = new Map();
36
+ const sections = [];
37
+ const ids = new Set();
38
+ for (const entry of index.sections) {
39
+ if (ids.has(entry.path))
40
+ throw Error("FRONTEND_REVIEW_SCOPE_INVALID: duplicate section identity");
41
+ ids.add(entry.path);
42
+ const section = await read(entry.path, entry.sha256);
43
+ const payload = JSON.parse(section.text);
44
+ if (payload.contractSha256 !== canonicalArtifact.sha256 || sha256OfCanonicalJson(payload.slices) !== sha256OfCanonicalJson(entry.slices))
45
+ throw Error("FRONTEND_REVIEW_SCOPE_INVALID: unbound section");
46
+ for (const slice of payload.slices) {
47
+ const value = canonical[slice.field];
48
+ const range = Array.isArray(value) ? value.slice(slice.start, slice.end) : value;
49
+ if (sha256OfCanonicalJson(range) !== sha256OfCanonicalJson(payload.data[slice.field]))
50
+ throw Error("FRONTEND_REVIEW_SCOPE_INVALID: section differs from canonical obligation");
51
+ const positions = covered.get(slice.field) ?? new Set();
52
+ if (Array.isArray(value)) {
53
+ if (!Number.isInteger(slice.start) || !Number.isInteger(slice.end) || slice.start < 0 || slice.end < slice.start || slice.end > value.length || slice.total !== value.length)
54
+ throw Error("FRONTEND_REVIEW_SCOPE_INVALID: invalid array coverage");
55
+ for (let i = slice.start; i < slice.end; i++) {
56
+ if (positions.has(i))
57
+ throw Error("FRONTEND_REVIEW_SCOPE_INVALID: overlapping coverage");
58
+ positions.add(i);
59
+ }
60
+ }
61
+ else {
62
+ if (positions.size)
63
+ throw Error("FRONTEND_REVIEW_SCOPE_INVALID: duplicate field");
64
+ positions.add(0);
65
+ }
66
+ covered.set(slice.field, positions);
67
+ }
68
+ sections.push(section);
69
+ }
70
+ if (Object.keys(canonical).some(field => !covered.has(field) || covered.get(field).size !== (Array.isArray(canonical[field]) ? canonical[field].length : 1)))
71
+ throw Error("FRONTEND_REVIEW_SCOPE_INVALID: canonical field coverage incomplete");
72
+ const scopes = Buffer.byteLength(canonicalArtifact.text) <= targetBytes ? [canonicalArtifact] : sections;
73
+ if (phase === "review") {
74
+ const context = await read("contracts/frontend-review-context.json");
75
+ const payload = JSON.parse(context.text);
76
+ if (payload.contractRef.sha256 !== canonicalArtifact.sha256 || payload.contractRef.indexSha256 !== indexArtifact.sha256)
77
+ throw Error("FRONTEND_REVIEW_SCOPE_DRIFT: review context contract binding");
78
+ const diff = await read(payload.diff.reviewIndexPath);
79
+ const diffIndex = JSON.parse(diff.text);
80
+ await read(payload.diff.patchPath, payload.diff.patchSha256);
81
+ if (diffIndex.patchSha256 !== payload.diff.patchSha256)
82
+ throw Error("FRONTEND_REVIEW_SCOPE_DRIFT: review diff binding");
83
+ scopes.push(context);
84
+ for (const hunk of diffIndex.hunks)
85
+ scopes.push(await read(hunk.patchPath, hunk.sha256));
86
+ }
87
+ return { scopes, digest: sha256OfCanonicalJson([...files]), validate: async () => { for (const [relative, expected] of files)
88
+ await read(relative, expected); } };
89
+ }
90
+ /** Checkpoints attest explicit model completion of already supplied, hash-bound scopes. */
91
+ export function createFrontendReviewScopeProtocol(input) {
92
+ const kind = input.phase === "design" ? "design-scope-completed" : "review-scope-completed";
93
+ let active = new Set();
94
+ const completedScopeIds = () => new Set(readCommittedEvents(input.getStore(), input.attemptId).filter(r => r.fact.kind === kind).map(r => String(r.fact.id)));
95
+ const receipt = (details) => ({ content: [{ type: "text", text: JSON.stringify(details) }], details });
96
+ return {
97
+ setActiveScope(ids) { active = new Set(ids); },
98
+ completedScopeIds,
99
+ committedFacts: () => readCommittedEvents(input.getStore(), input.attemptId),
100
+ assertComplete() { const missing = input.inventory?.scopes.filter(s => !completedScopeIds().has(s.id)) ?? []; if (missing.length)
101
+ throw Object.assign(Error(`FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${missing.map(s => s.id).join(", ")}`), { code: "FRONTEND_REVIEW_SCOPE_INCOMPLETE" }); },
102
+ customTools: !input.inventory ? [] : [{ name: "complete_review_scope", label: "complete_review_scope", description: "After reviewing the supplied complete scope and saving its findings, checkpoint its exact runtime id. This does not approve the implementation or design.", parameters: { type: "object", properties: { id: { type: "string", minLength: 1 } }, required: ["id"], additionalProperties: false },
103
+ async execute(callId, params) {
104
+ const scope = input.inventory.scopes.find(s => s.id === params.id);
105
+ if (!scope || !active.has(params.id))
106
+ return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "Only the supplied active review scope may be completed" });
107
+ await input.inventory.validate();
108
+ const store = input.getStore();
109
+ const requestId = `${input.attemptId}:${callId}:${randomUUID()}`;
110
+ const fact = { kind, id: scope.id, sha256: scope.sha256 };
111
+ const staged = stageTypedEventFact({ store, requestId, attemptId: input.attemptId, fact });
112
+ const committed = await adoptTypedEventFact({ store, requestId, attemptId: input.attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
113
+ return receipt({ ok: true, kind, revision: committed.revision });
114
+ },
115
+ }],
116
+ };
117
+ }
@@ -0,0 +1,249 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { readFrontendToolProgress } from "./frontend-durable-tools.js";
5
+ import { frontendModelCapabilities } from "../../shared/frontend-execution-policy.js";
6
+ /** Count returned text only after a successful native read. No filesystem reads or raw content retention. */
7
+ export function createFrontendReadObservation() {
8
+ const pending = new Map();
9
+ const finished = new Set();
10
+ const seen = new Set();
11
+ let bytes = 0, duplicates = 0, complete = 0, rejected = 0, unknown = false;
12
+ return {
13
+ observe(raw) {
14
+ const event = record(raw);
15
+ const id = event.toolCallId;
16
+ if (typeof id !== "string")
17
+ return;
18
+ if (event.type === "tool_execution_start" && event.toolName === "read")
19
+ pending.set(id, record(event.args ?? event.input));
20
+ if (event.type !== "tool_execution_end" || !pending.has(id) || finished.has(id))
21
+ return;
22
+ const args = pending.get(id);
23
+ pending.delete(id);
24
+ finished.add(id);
25
+ const result = record(event.result);
26
+ if (event.isError === true || result.isError === true || result.ok === false || result.error || record(result.details).ok === false) {
27
+ rejected++;
28
+ return;
29
+ }
30
+ const content = result.content;
31
+ if (!Array.isArray(content) || content.some(item => record(item).type !== "text" || typeof record(item).text !== "string") || typeof args.path !== "string") {
32
+ unknown = true;
33
+ return;
34
+ }
35
+ const text = content.map(item => record(item).text).join("\n");
36
+ const size = Buffer.byteLength(text);
37
+ const key = createHash("sha256").update(JSON.stringify([args.path.replaceAll("\\", "/").replace(/^\.\//, ""), args.offset ?? null, args.limit ?? null, text])).digest("hex");
38
+ bytes += size;
39
+ complete++;
40
+ if (seen.has(key))
41
+ duplicates += size;
42
+ seen.add(key);
43
+ },
44
+ snapshot() { return { successfulReadBytes: unknown ? null : bytes, duplicateReadBytes: unknown ? null : duplicates, completedReads: complete, rejectedReads: rejected, incompleteReads: pending.size }; },
45
+ };
46
+ }
47
+ export function classifyFrontendSessionFailure(result) {
48
+ const errorCode = /\b(?:FRONTEND_[A-Z_]+|TOOL_SCHEMA_INVALID|FACT_IDENTITY_CONFLICT|REQUEST_ID_REUSE_CONFLICT|TERMINAL_CONFLICT|[A-Z_]+_INCOMPLETE|[A-Z_]+_EXHAUSTED)\b/.exec(result.stderr)?.[0] ?? null;
49
+ const category = result.failureCategory;
50
+ const failureLayer = result.stopReason === "length" || /context|budget-exhausted/.test(category) ? "capacity"
51
+ : result.ok ? "none"
52
+ : errorCode?.includes("LEDGER") || errorCode === "FRONTEND_COMMIT_FAILED" ? "persistence"
53
+ : errorCode === "TOOL_SCHEMA_INVALID" ? "schema"
54
+ : /binding|source|permission|write-guard|tool-policy/.test(category) ? "governance"
55
+ : /timeout|rate-limit|provider|transport|network|auth/.test(category) ? "provider"
56
+ : /invalid-output|structured-output/.test(category) ? "protocol-or-coverage"
57
+ : "unknown";
58
+ return { failureLayer, errorCode };
59
+ }
60
+ const fields = ["inputTokens", "outputTokens", "reasoningTokens", "cacheReadTokens", "cacheWriteTokens", "totalTokens", "peakRequestContextTokens"];
61
+ const record = (v) => v !== null && typeof v === "object" ? v : {};
62
+ const number = (r, ...keys) => {
63
+ for (const key of keys)
64
+ if (typeof r[key] === "number" && Number.isFinite(r[key]) && r[key] >= 0)
65
+ return r[key];
66
+ return null;
67
+ };
68
+ const sumKnown = (values) => values.length && values.every(v => v !== null) ? values.reduce((s, v) => s + v, 0) : null;
69
+ /** Observation only. Never feed this projection into the SDK execution guard. */
70
+ export function collectFrontendUsage(events) {
71
+ const samples = new Map();
72
+ const aliases = new Map();
73
+ let anonymous = false;
74
+ let observed = false;
75
+ for (const raw of events) {
76
+ const event = record(raw);
77
+ const message = record(event.message);
78
+ if (typeof message.role === "string" && message.role !== "assistant")
79
+ continue;
80
+ const usage = record(event.usage ?? message.usage ?? event.tokenUsage);
81
+ if (!Object.keys(usage).length)
82
+ continue;
83
+ observed = true;
84
+ const ids = [event.responseId, event.messageId, message.responseId, message.id].filter((v) => typeof v === "string" && v.length > 0);
85
+ const roots = [...new Set(ids.map(id => aliases.get(id) ?? id))];
86
+ const key = roots[0];
87
+ if (!key) {
88
+ anonymous = true;
89
+ continue;
90
+ }
91
+ const input = number(usage, "input", "input_tokens", "inputTokens", "prompt_tokens", "promptTokens");
92
+ const output = number(usage, "output", "output_tokens", "outputTokens", "completion_tokens", "completionTokens");
93
+ const cacheRead = number(usage, "cacheRead", "cache_read_input_tokens");
94
+ const cacheWrite = number(usage, "cacheWrite", "cache_creation_input_tokens");
95
+ // Pi's native input excludes cache tokens; OpenAI-compatible input includes them.
96
+ const context = input === null ? null : "input" in usage ? sumKnown([input, cacheRead, cacheWrite]) : input;
97
+ const sample = {
98
+ inputTokens: input, outputTokens: output,
99
+ reasoningTokens: number(usage, "reasoning", "reasoning_tokens", "reasoningTokens") ?? number(record(usage.completion_tokens_details ?? usage.output_tokens_details), "reasoning_tokens"),
100
+ cacheReadTokens: cacheRead, cacheWriteTokens: cacheWrite,
101
+ totalTokens: number(usage, "total_tokens", "totalTokens") ?? sumKnown([context, output]),
102
+ peakRequestContextTokens: context,
103
+ };
104
+ for (const root of roots) {
105
+ const previous = samples.get(root);
106
+ if (previous)
107
+ for (const field of fields)
108
+ sample[field] = previous[field] === null ? sample[field] : sample[field] === null ? previous[field] : Math.max(previous[field], sample[field]);
109
+ samples.delete(root);
110
+ }
111
+ for (const [alias, root] of aliases)
112
+ if (roots.includes(root))
113
+ aliases.set(alias, key);
114
+ for (const id of ids)
115
+ aliases.set(id, key);
116
+ samples.set(key, sample);
117
+ }
118
+ const result = Object.fromEntries(fields.map(field => [field, anonymous ? null : field === "peakRequestContextTokens" ? (samples.size && [...samples.values()].every(s => s[field] !== null) ? Math.max(...[...samples.values()].map(s => s[field])) : null) : sumKnown([...samples.values()].map(s => s[field]))]));
119
+ return { ...result, requestCount: anonymous || !observed ? null : samples.size,
120
+ completeness: !observed ? "unknown" : !anonymous && result.inputTokens !== null && result.outputTokens !== null && result.totalTokens !== null ? "complete" : "partial" };
121
+ }
122
+ function aggregateAttempts(attempts) {
123
+ const usages = attempts.map(a => a.actual ?? collectFrontendUsage([]));
124
+ const totals = Object.fromEntries(fields.map(field => [field, field === "peakRequestContextTokens"
125
+ ? usages.every(u => u[field] !== null) ? Math.max(...usages.map(u => u[field])) : null
126
+ : sumKnown(usages.map(u => u[field]))]));
127
+ return { ...totals, requestCount: sumKnown(usages.map(u => u.requestCount)), completeness: usages.every(u => u.completeness === "complete") ? "complete" : usages.every(u => u.completeness === "unknown") ? "unknown" : "partial" };
128
+ }
129
+ /** Captures every transport attempt, including an SDK failure before CLI fallback. */
130
+ export async function observeFrontendAttempt(input, execute) {
131
+ const event = { id: randomUUID(), backend: input.backend, model: input.model, startedAt: new Date().toISOString(), completion: "incomplete" };
132
+ const notify = () => { try {
133
+ input.observer?.(structuredClone(event));
134
+ }
135
+ catch { /* observation cannot change execution */ } };
136
+ notify();
137
+ const result = await execute();
138
+ event.completion = "complete";
139
+ event.actual = result.usageObservation ?? collectFrontendUsage([]);
140
+ event.reads = result.readObservation;
141
+ event.capabilities = result.capabilitiesObservation;
142
+ event.requestEnvelope = result.requestEnvelopeObservation;
143
+ event.terminal = { ok: result.ok, failureCategory: result.failureCategory, stopReason: result.stopReason ?? null, durationMs: result.durationMs };
144
+ notify();
145
+ return result;
146
+ }
147
+ const measure = (text) => ({ sha256: createHash("sha256").update(text).digest("hex"), chars: text.length, bytes: Buffer.byteLength(text), estimatedTokens: Math.ceil(Buffer.byteLength(text) / 3) });
148
+ function safeObservation(read, fallback) { try {
149
+ return read();
150
+ }
151
+ catch {
152
+ return fallback;
153
+ } }
154
+ /** One logical dispatch and its real transport attempts; no raw prompt or tool output is stored. */
155
+ export async function observeFrontendSession(input, execute) {
156
+ const durableBefore = safeObservation(() => input.durableCommittedCount?.(), undefined);
157
+ const before = safeObservation(() => input.committedCount?.(), undefined);
158
+ const toolsBefore = readFrontendToolProgress(input.customTools);
159
+ const receipt = {
160
+ schemaVersion: 1, policyVersion: "frontend-session-observation-v1",
161
+ identity: { sessionId: randomUUID(), phase: input.phase, scopeIds: input.scopeIds ?? [], taskId: input.taskId ?? null, runId: input.runId ?? null, nodeId: input.nodeId ?? null, attempt: input.attempt ?? null, sourceDigest: input.sourceDigest ?? null, contractDigest: input.contractDigest ?? null },
162
+ startedAt: new Date().toISOString(), finishedAt: null, completion: "incomplete",
163
+ planned: { prompt: measure(input.prompt), userMessage: measure(input.userMessage ?? ""), toolSchemas: input.customTools ? safeObservation(() => measure(JSON.stringify(input.customTools.map(t => { const tool = record(t); return { name: tool.name, description: tool.description, parameters: tool.parameters }; }))), null) : null,
164
+ estimatorVersion: "utf8-bytes-div3-v1-estimate", capabilities: { ...frontendModelCapabilities(undefined), model: input.model ?? null }, unmeasuredComponents: ["sdk-system-overhead", "built-in-tools", "history", "tool-results", "attachments"] },
165
+ actual: collectFrontendUsage([]), attempts: [],
166
+ progress: { memoryCommittedDelta: null, durableCommittedDelta: null, failedRecords: null, duplicateRecords: null, durableSubmissions: null, durableReplacements: null, successfulReadBytes: null, duplicateReadBytes: null }, terminal: null,
167
+ };
168
+ let writes = Promise.resolve();
169
+ let abandoned = false;
170
+ const writeObservation = async (file, snapshot) => {
171
+ // Never recreate a run that the runner has already archived.
172
+ await mkdir(path.dirname(file)).catch(error => { if (error.code !== "EEXIST")
173
+ throw error; });
174
+ const temporary = `${file}.${randomUUID()}.tmp`;
175
+ try {
176
+ await writeFile(temporary, JSON.stringify(snapshot), { flag: "wx" });
177
+ await rename(temporary, file);
178
+ }
179
+ finally {
180
+ await rm(temporary, { force: true }).catch(() => { });
181
+ }
182
+ };
183
+ const save = () => {
184
+ const snapshot = structuredClone(receipt);
185
+ writes = writes.then(async () => {
186
+ if (abandoned)
187
+ return;
188
+ if (input.sink)
189
+ await input.sink(snapshot);
190
+ else if (input.artifactPath)
191
+ await writeObservation(input.artifactPath, snapshot);
192
+ }).catch(() => { });
193
+ return writes;
194
+ };
195
+ const waitForWrite = async (pending) => {
196
+ let timer;
197
+ try {
198
+ await Promise.race([pending, new Promise(resolve => { timer = setTimeout(() => { abandoned = true; resolve(); }, 100); })]);
199
+ }
200
+ finally {
201
+ if (timer)
202
+ clearTimeout(timer);
203
+ }
204
+ };
205
+ await waitForWrite(save());
206
+ const result = await execute(event => {
207
+ const index = receipt.attempts.findIndex(a => a.id === event.id);
208
+ if (index < 0)
209
+ receipt.attempts.push(event);
210
+ else
211
+ receipt.attempts[index] = event;
212
+ void save();
213
+ });
214
+ receipt.finishedAt = new Date().toISOString();
215
+ receipt.completion = "complete";
216
+ receipt.actual = receipt.attempts.length ? aggregateAttempts(receipt.attempts) : result.usageObservation ?? collectFrontendUsage([]);
217
+ receipt.planned.capabilities = receipt.attempts[0]?.capabilities ?? result.capabilitiesObservation ?? receipt.planned.capabilities;
218
+ receipt.progress.memoryCommittedDelta = before !== undefined && input.committedCount ? safeObservation(() => Math.max(0, input.committedCount() - before), null) : null;
219
+ const durableAfter = safeObservation(() => input.durableCommittedCount?.(), undefined);
220
+ receipt.progress.durableCommittedDelta = durableBefore !== undefined && durableAfter !== undefined ? Math.max(0, durableAfter - durableBefore) : null;
221
+ const toolsAfter = readFrontendToolProgress(input.customTools);
222
+ if (toolsBefore && toolsAfter)
223
+ for (const key of ["failedRecords", "duplicateRecords", "durableSubmissions", "durableReplacements"])
224
+ receipt.progress[key] = Math.max(0, toolsAfter[key] - toolsBefore[key]);
225
+ const reads = receipt.attempts.length ? receipt.attempts.map(a => a.reads) : [result.readObservation];
226
+ for (const key of ["successfulReadBytes", "duplicateReadBytes"])
227
+ receipt.progress[key] = sumKnown(reads.map(r => r?.[key] ?? null));
228
+ receipt.terminal = { ok: result.ok, failureCategory: result.failureCategory, stopReason: result.stopReason ?? null, durationMs: Date.parse(receipt.finishedAt) - Date.parse(receipt.startedAt), ...classifyFrontendSessionFailure(result) };
229
+ await waitForWrite(save());
230
+ return result;
231
+ }
232
+ export function summarizeFrontendSessions(receipts) {
233
+ const byId = new Map();
234
+ for (const receipt of receipts) {
235
+ const previous = byId.get(receipt.identity.sessionId);
236
+ const rank = (r) => [r.completion === "complete" ? 1 : 0, Date.parse(r.finishedAt ?? r.startedAt), r.attempts.filter(a => a.completion === "complete").length, r.attempts.length];
237
+ const candidateRank = rank(receipt);
238
+ const previousRank = previous ? rank(previous) : [];
239
+ const difference = candidateRank.map((v, i) => v - (previousRank[i] ?? 0)).find(v => v !== 0) ?? 0;
240
+ if (!previous || difference > 0)
241
+ byId.set(receipt.identity.sessionId, receipt);
242
+ }
243
+ const unique = [...byId.values()];
244
+ return { sessions: unique.length, failedSessions: unique.filter(r => r.terminal?.ok === false).length, incompleteSessions: unique.filter(r => r.completion === "incomplete").length,
245
+ failureLayers: Object.fromEntries([...new Set(unique.flatMap(r => r.terminal && r.terminal.failureLayer !== "none" ? [r.terminal.failureLayer] : []))].sort().map(layer => [layer, unique.filter(r => r.terminal?.failureLayer === layer).length])),
246
+ progress: Object.fromEntries(["durableCommittedDelta", "durableSubmissions", "durableReplacements", "failedRecords", "duplicateRecords", "successfulReadBytes", "duplicateReadBytes"].map(key => [key, sumKnown(unique.map(r => r.progress[key]))])),
247
+ totalTokens: sumKnown(unique.flatMap(r => r.attempts.length ? r.attempts.map(a => a.actual?.totalTokens ?? null) : [r.actual.totalTokens])),
248
+ sessionDurationSumMs: sumKnown(unique.map(r => r.terminal?.durationMs ?? null)) };
249
+ }
@@ -236,6 +236,7 @@ export const PLAN_LEDGER_FACT_KINDS = [
236
236
  "plan-requirement",
237
237
  "plan-verification-target",
238
238
  "plan-evidence-gap",
239
+ "mock-endpoint",
239
240
  ];
240
241
  function skeletonTargetFiles(skeleton) {
241
242
  if (!isRecord(skeleton))
@@ -386,8 +387,25 @@ export function assemblePlanPatchFromCommittedFacts(records, contractRequirement
386
387
  patch.interactions = [...interactionByName.values(), ...unnamedInteractions];
387
388
  }
388
389
  const mockApi = lastByKind("mock-api");
390
+ if (!mockApi && facts.some(f => f.kind === "mock-endpoint" && f.removed !== true))
391
+ throw Error("MOCK_POLICY_MISSING: endpoint has no policy");
389
392
  if (mockApi && isRecord(mockApi.mockApi)) {
390
- patch.mockApi = mockApi.mockApi;
393
+ const endpoints = new Map();
394
+ for (const endpoint of Array.isArray(mockApi.mockApi.endpoints) ? mockApi.mockApi.endpoints : []) {
395
+ if (isRecord(endpoint))
396
+ endpoints.set(`${endpoint.method}:${endpoint.path}`, endpoint);
397
+ }
398
+ for (const fact of facts)
399
+ if (fact.kind === "mock-endpoint" && isRecord(fact.endpoint)) {
400
+ const key = `${fact.endpoint.method}:${fact.endpoint.path}`;
401
+ if (fact.removed === true)
402
+ endpoints.delete(key);
403
+ else
404
+ endpoints.set(key, fact.endpoint);
405
+ }
406
+ if (mockApi.mockApi.strategy === "not-needed" && endpoints.size)
407
+ throw Error("MOCK_POLICY_CONFLICT: withdraw endpoints before declaring Mock/API not needed");
408
+ patch.mockApi = { ...mockApi.mockApi, endpoints: [...endpoints.values()] };
391
409
  }
392
410
  const deviation = lastByKind("design-deviation");
393
411
  if (deviation) {
@@ -433,7 +451,7 @@ export function assemblePlanPatchFromCommittedFacts(records, contractRequirement
433
451
  const inherited = contractRequirements.get(id);
434
452
  if (!inherited)
435
453
  return entry;
436
- const merged = { ...entry };
454
+ const merged = { ...entry, ...(inherited.text ? { expectedOutcome: inherited.text } : {}), ...(inherited.execution ? { execution: inherited.execution } : {}) };
437
455
  // Inherit the contract-declared provenance only when the plan did
438
456
  // not already declare it (model override wins; missing inherits).
439
457
  if (!Array.isArray(entry.sourceFragmentIds)) {
@@ -39,9 +39,10 @@ const resultSchema = z.object({
39
39
  })),
40
40
  })),
41
41
  });
42
- export function parseFrontendTestExecutionReport(raw, cwd) {
42
+ /** Resolve runner paths against its cwd, then use the contract workspace coordinates. */
43
+ export function parseFrontendTestExecutionReport(raw, cwd, workspaceRoot = cwd) {
43
44
  return resultSchema.parse(raw).testResults.flatMap((suite) => suite.assertionResults.map((test) => ({
44
- file: path.relative(cwd, path.resolve(cwd, suite.name)).replace(/\\/g, "/"),
45
+ file: path.relative(workspaceRoot, path.resolve(cwd, suite.name)).replace(/\\/g, "/"),
45
46
  title: test.fullName ?? [...(test.ancestorTitles ?? []), test.title].join(" "),
46
47
  status: test.status,
47
48
  })));
@@ -128,6 +128,7 @@ export const PLAN_RECORD_FACT_KINDS = [
128
128
  "plan-evidence-gap",
129
129
  "state-registry",
130
130
  ];
131
+ export const INCREMENTAL_DETAIL_FACT_KINDS = ["mock-endpoint", "review-finding", "design-finding", "contract-scope-completed", "plan-group-coverage", "scout-scope", "review-scope-completed", "design-scope-completed"];
131
132
  /**
132
133
  * A+B (AC-009): typed issue category shared by review and design change
133
134
  * requests. The five-value enum replaces free-form issueCategory strings.
@@ -159,6 +160,7 @@ const ALL_TYPED_EVENT_FACT_KIND_VALUES = [
159
160
  ...CONTRACT_FACT_KINDS,
160
161
  ...PLAN_TERMINAL_FACT_KINDS,
161
162
  ...PLAN_RECORD_FACT_KINDS,
163
+ ...INCREMENTAL_DETAIL_FACT_KINDS,
162
164
  ]),
163
165
  ];
164
166
  export const typedEventFactKindSchema = z.enum(ALL_TYPED_EVENT_FACT_KIND_VALUES);
@@ -261,6 +263,15 @@ export const typedEventRecordSchema = z
261
263
  revision: z.number().int().nonnegative(),
262
264
  recordedAt: z.string().min(1),
263
265
  quarantineReason: z.string().min(1).optional(),
266
+ submission: z.object({
267
+ version: z.literal(1),
268
+ bindingSha256: z.string().regex(/^[a-f0-9]{64}$/),
269
+ tool: z.string(),
270
+ callId: z.string(),
271
+ identity: z.string(),
272
+ paramsSha256: z.string().regex(/^[a-f0-9]{64}$/),
273
+ receipt: z.record(z.unknown()).optional(),
274
+ }).strict().optional(),
264
275
  })
265
276
  .strict()
266
277
  .superRefine((value, ctx) => {