@tea-agent/loop-agent 0.39.0-beta.3 → 0.39.0-beta.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.39.0-beta.3",
4
- "gitSha": "d9fdc0eac8d061d1285094f7d9d1531b669e5266",
5
- "builtAt": "2026-08-23T05:55:12.549Z"
3
+ "version": "0.39.0-beta.5",
4
+ "gitSha": "0981b80311d973603a870fd80693331cb5e48335",
5
+ "builtAt": "2026-08-23T11:38:56.113Z"
6
6
  }
@@ -216,6 +216,9 @@ export const FRONTEND_PLAN_RECORD_TOOL_NAMES = [
216
216
  "record_mock_api",
217
217
  "record_design_deviation",
218
218
  "record_dependency",
219
+ "record_plan_requirement",
220
+ "record_plan_verification_target",
221
+ "record_plan_evidence_gap",
219
222
  ];
220
223
  export const FRONTEND_PLAN_TERMINAL_TOOL_NAMES = ["finalize_plan"];
221
224
  export const FRONTEND_PLAN_ADOPT_TOOL_NAMES = ["adopt_staged_fact"];
@@ -923,16 +926,83 @@ export async function createFrontendPlanLedgerTools(input) {
923
926
  return planToolReceipt(result);
924
927
  },
925
928
  });
929
+ // Incremental plan payload records (one entry per call) so requirements,
930
+ // verification targets, evidence gaps, and implementation steps never have
931
+ // to be emitted as one large array inside a single finalize_plan call —
932
+ // they aggregate from the ledger in commit order. Mirrors the contract
933
+ // node's record_requirement pattern to stay within any model output budget.
934
+ const recordPlanRequirementTool = defineTool({
935
+ name: "record_plan_requirement",
936
+ label: "record_plan_requirement",
937
+ description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement; each entry carries id, expectedOutcome, implementationTargets, verificationTargetIds, and optional evidenceGap.",
938
+ promptSnippet: "Commit one plan requirement entry.",
939
+ parameters: Type.Object({
940
+ entry: requirementSchema,
941
+ }, { additionalProperties: false }),
942
+ async execute(_toolCallId, params) {
943
+ const rawEntry = params?.entry;
944
+ if (!isRecordObject(rawEntry)) {
945
+ return planToolReceipt({
946
+ ok: false,
947
+ kind: "plan-requirement",
948
+ error: "record_plan_requirement requires a non-empty entry object",
949
+ });
950
+ }
951
+ const entry = rawEntry;
952
+ const result = await adoptPlanFact("plan-requirement", `${attemptId}:record_plan_requirement:${randomUUID()}`, { kind: "plan-requirement", origin: "plan", entry });
953
+ return planToolReceipt(result);
954
+ },
955
+ });
956
+ const recordPlanVerificationTargetTool = defineTool({
957
+ name: "record_plan_verification_target",
958
+ label: "record_plan_verification_target",
959
+ description: "Commit one plan verification target entry (origin=plan plan-verification-target fact). Call once per target; entry carries id, type, commandLabel, file, symbol, requirementIds, uiStates.",
960
+ promptSnippet: "Commit one plan verification target entry.",
961
+ parameters: Type.Object({
962
+ entry: verificationTargetSchema,
963
+ }, { additionalProperties: false }),
964
+ async execute(_toolCallId, params) {
965
+ const rawEntry = params?.entry;
966
+ if (!isRecordObject(rawEntry)) {
967
+ return planToolReceipt({
968
+ ok: false,
969
+ kind: "plan-verification-target",
970
+ error: "record_plan_verification_target requires a non-empty entry object",
971
+ });
972
+ }
973
+ const entry = rawEntry;
974
+ const result = await adoptPlanFact("plan-verification-target", `${attemptId}:record_plan_verification_target:${randomUUID()}`, { kind: "plan-verification-target", origin: "plan", entry });
975
+ return planToolReceipt(result);
976
+ },
977
+ });
978
+ const recordPlanEvidenceGapTool = defineTool({
979
+ name: "record_plan_evidence_gap",
980
+ label: "record_plan_evidence_gap",
981
+ description: "Commit one plan evidence gap entry (origin=plan plan-evidence-gap fact). Call once per gap; entry carries requirementId, description, blocking.",
982
+ promptSnippet: "Commit one plan evidence gap entry.",
983
+ parameters: Type.Object({
984
+ entry: evidenceGapSchema,
985
+ }, { additionalProperties: false }),
986
+ async execute(_toolCallId, params) {
987
+ const rawEntry = params?.entry;
988
+ if (!isRecordObject(rawEntry)) {
989
+ return planToolReceipt({
990
+ ok: false,
991
+ kind: "plan-evidence-gap",
992
+ error: "record_plan_evidence_gap requires a non-empty entry object",
993
+ });
994
+ }
995
+ const entry = rawEntry;
996
+ const result = await adoptPlanFact("plan-evidence-gap", `${attemptId}:record_plan_evidence_gap:${randomUUID()}`, { kind: "plan-evidence-gap", origin: "plan", entry });
997
+ return planToolReceipt(result);
998
+ },
999
+ });
926
1000
  const finalizePlanTool = defineTool({
927
1001
  name: "finalize_plan",
928
1002
  label: "finalize_plan",
929
- description: "Assemble the canonical editable plan patch from committed record facts plus these remaining fields, publish it on a target-surface fact, and commit the finalize_plan terminal. Call exactly once.",
930
- promptSnippet: "Assemble and commit the finalize_plan terminal (requirements + verificationTargets + evidenceGaps + optional fields).",
1003
+ description: "Commit the finalize_plan terminal. Requirements, verification targets, and evidence gaps (optional) were already committed incrementally through record_plan_requirement / record_plan_verification_target / record_plan_evidence_gap; finalize_plan assembles them from the ledger together with these optional remaining fields, publishes the canonical editable patch on a target-surface fact, and commits the terminal. Call exactly once.",
1004
+ promptSnippet: "Commit the finalize_plan terminal (ledger fields + optional residualRisks / realIntegrationGap).",
931
1005
  parameters: Type.Object({
932
- requirements: Type.Array(requirementSchema),
933
- verificationTargets: Type.Array(verificationTargetSchema),
934
- evidenceGaps: Type.Array(evidenceGapSchema),
935
- implementationSteps: optionalStringArray,
936
1006
  residualRisks: optionalStringArray,
937
1007
  realIntegrationGap: optionalString,
938
1008
  }, { additionalProperties: false }),
@@ -942,12 +1012,6 @@ export async function createFrontendPlanLedgerTools(input) {
942
1012
  const fragment = assemblePlanPatchFromCommittedFacts(committed) ?? {};
943
1013
  const patch = {
944
1014
  ...fragment,
945
- requirements: params?.requirements ?? [],
946
- verificationTargets: params?.verificationTargets ?? [],
947
- evidenceGaps: params?.evidenceGaps ?? [],
948
- ...(params?.implementationSteps
949
- ? { implementationSteps: params.implementationSteps }
950
- : {}),
951
1015
  ...(params?.residualRisks
952
1016
  ? { residualRisks: params.residualRisks }
953
1017
  : {}),
@@ -1024,6 +1088,9 @@ export async function createFrontendPlanLedgerTools(input) {
1024
1088
  recordMockApiTool,
1025
1089
  recordDesignDeviationTool,
1026
1090
  recordDependencyTool,
1091
+ recordPlanRequirementTool,
1092
+ recordPlanVerificationTargetTool,
1093
+ recordPlanEvidenceGapTool,
1027
1094
  adoptStagedFactTool,
1028
1095
  finalizePlanTool,
1029
1096
  ],
@@ -1176,6 +1243,48 @@ export async function createFrontendContractTools(input) {
1176
1243
  return receipt(result);
1177
1244
  },
1178
1245
  }));
1246
+ // OpenSpec selection committed as individual typed facts (one path per
1247
+ // call) so a large candidate set never exceeds a single model output
1248
+ // budget: each tool call carries exactly one {path, disposition,
1249
+ // rationale} row and the ledger accumulates them across calls. Only
1250
+ // positive classifications (required | relevant) are legal; unmentioned
1251
+ // candidates default to irrelevant at the prewrite gate.
1252
+ const recordOpenspecSelectionTool = defineTool({
1253
+ name: "record_openspec_selection",
1254
+ label: "record_openspec_selection",
1255
+ description: "Commit one OpenSpec candidate classification (origin=contract openspec-selection fact). Call once per path you actually use or consult: required (must be read and cited) or relevant (informs planning). Never call it for irrelevant candidates — unmentioned candidates default to irrelevant. You may call it many times; one row per call.",
1256
+ promptSnippet: "Commit one OpenSpec candidate classification (required | relevant); one path per call; skip irrelevant candidates.",
1257
+ parameters: Type.Object({
1258
+ path: Type.String({
1259
+ description: "Repo-relative candidate spec path, e.g. openspec/project-specs/ui/ucp-components-md/AdvancedSearch.md",
1260
+ }),
1261
+ disposition: Type.Enum({
1262
+ required: "required",
1263
+ relevant: "relevant",
1264
+ }),
1265
+ rationale: Type.String({}),
1266
+ }, { additionalProperties: false }),
1267
+ async execute(_toolCallId, params) {
1268
+ const path = typeof params?.path === "string" ? params.path : "";
1269
+ const disposition = params?.disposition;
1270
+ const rationale = typeof params?.rationale === "string" ? params.rationale : "";
1271
+ if (!path || !disposition || !rationale.trim()) {
1272
+ return receipt({
1273
+ ok: false,
1274
+ kind: "openspec-selection",
1275
+ error: "record_openspec_selection requires non-empty path, disposition (required|relevant), and rationale",
1276
+ });
1277
+ }
1278
+ const result = await adoptContractFact("openspec-selection", {
1279
+ kind: "openspec-selection",
1280
+ origin: "contract",
1281
+ path,
1282
+ disposition,
1283
+ rationale,
1284
+ });
1285
+ return receipt(result);
1286
+ },
1287
+ });
1179
1288
  const finalizeContractTool = defineTool({
1180
1289
  name: "finalize_contract",
1181
1290
  label: "finalize_contract",
@@ -1225,7 +1334,11 @@ export async function createFrontendContractTools(input) {
1225
1334
  },
1226
1335
  });
1227
1336
  return {
1228
- customTools: [...recordTools, finalizeContractTool],
1337
+ customTools: [
1338
+ ...recordTools,
1339
+ recordOpenspecSelectionTool,
1340
+ finalizeContractTool,
1341
+ ],
1229
1342
  flush: async () => {
1230
1343
  const committed = readCommittedEvents(store, attemptId);
1231
1344
  await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), committed);
@@ -2429,7 +2429,9 @@ async function executeFrontendDesignPolicy(input, meta) {
2429
2429
  contract,
2430
2430
  allowedMockStrategies: config.allowedMockStrategies,
2431
2431
  sourceFreshness,
2432
- componentSpecCandidatePaths: config.componentSpecCandidatePaths ?? [],
2432
+ componentSpecCandidatePaths: config.openspecCandidatePaths ??
2433
+ config.componentSpecCandidatePaths ??
2434
+ [],
2433
2435
  allowedDependencies: config.allowedDependencies,
2434
2436
  writeSetPatterns: config.implementationWriteSet ?? [],
2435
2437
  });
@@ -267,6 +267,14 @@ function emptyClassified() {
267
267
  * project-specific file names. Basename stems use word-boundary matching so
268
268
  * e.g. `*api*` (case-insensitive) maps to `rule.api` without hardcoding names.
269
269
  */
270
+ /** True when any path segment (other than the file itself) carries a
271
+ * component/theme kind marker. Directory-organized libraries such as
272
+ * `ui/ucp-components-md/AdvancedSearch.md` rely on this; templates/ and
273
+ * rules/ are handled by their own branches before this fallback runs. */
274
+ function segmentMatchesComponentKind(segments) {
275
+ const dirSegments = segments.slice(0, -1);
276
+ return dirSegments.some((segment) => /\bcomponents?\b/.test(segment.toLowerCase()));
277
+ }
270
278
  function classifyOpenspecPaths(paths) {
271
279
  const classified = emptyClassified();
272
280
  const ruleStem = (basename) => {
@@ -305,8 +313,17 @@ function classifyOpenspecPaths(paths) {
305
313
  classified.theme.push(candidate);
306
314
  else if (/\bcomponents?\b/.test(lower))
307
315
  classified.component.push(candidate);
308
- else
316
+ else if (segmentMatchesComponentKind(segments)) {
317
+ // Directory-organized component libraries (e.g.
318
+ // ui/ucp-components-md/AdvancedSearch.md) carry the kind in the
319
+ // parent directory name, not the file name. The parent-dir rule
320
+ // is a fallback so such layouts still land in the component
321
+ // bucket instead of being dropped into uiOther.
322
+ classified.component.push(candidate);
323
+ }
324
+ else {
309
325
  classified.uiOther.push(candidate);
326
+ }
310
327
  }
311
328
  else {
312
329
  classified.advisoryOther.push(candidate);
@@ -141,10 +141,22 @@ export function checkMockStrategy(contract, allowedMockStrategies) {
141
141
  }
142
142
  /** 4. Component choice must be precisely spec-referenced: `specified` requires
143
143
  * a specReference whose normalized path is a candidate and a non-empty section;
144
- * `new` must not carry a specReference; `reuse-existing` may omit it. */
144
+ * `new` must not carry a specReference; `reuse-existing` may omit it. The
145
+ * candidate set is the full generation-frozen openspec candidate list (the
146
+ * shell passes openspecCandidatePaths with componentSpecCandidatePaths as the
147
+ * fallback), so directory-organized component libraries are never rejected.
148
+ * A component specReference must still not point into clearly non-component
149
+ * spec areas — templates and the ai_workspace governance subtree cannot be the
150
+ * authoritative component definition. */
145
151
  export function checkComponentChoice(contract, componentSpecCandidatePaths) {
146
152
  const findings = [];
147
153
  const candidates = new Set(componentSpecCandidatePaths.map(normalizeRelativePath));
154
+ const isNonComponentSpecArea = (path) => {
155
+ const normalized = normalizeRelativePath(path);
156
+ return (normalized.startsWith("openspec/project-specs/templates/") ||
157
+ normalized.startsWith("ai_workspace/") ||
158
+ normalized === "openspec/project-specs/templates");
159
+ };
148
160
  (contract.uiComponentChoices ?? []).forEach((choice, index) => {
149
161
  if (choice.decision === "specified") {
150
162
  if (!choice.specReference) {
@@ -170,6 +182,13 @@ export function checkComponentChoice(contract, componentSpecCandidatePaths) {
170
182
  path: choice.specReference.path,
171
183
  });
172
184
  }
185
+ else if (isNonComponentSpecArea(normalized)) {
186
+ findings.push({
187
+ code: "component-spec-reference-path-outside-candidates",
188
+ message: `specified component choice #${index} specReference.path "${choice.specReference.path}" is a candidate but lives in a non-component spec area (templates or ai_workspace governance cannot be the authoritative component definition)`,
189
+ path: choice.specReference.path,
190
+ });
191
+ }
173
192
  }
174
193
  else if (choice.decision === "new" && choice.specReference) {
175
194
  findings.push({
@@ -209,7 +209,6 @@ export function loadFrontendImplementationContractJsonSchema(startDir = path.dir
209
209
  "mockApi",
210
210
  "designEvidence",
211
211
  "verificationTargets",
212
- "evidenceGaps",
213
212
  ];
214
213
  const actualRequired = Array.isArray(schema.required) ? schema.required : [];
215
214
  const missing = expectedRequired.filter((key) => !actualRequired.includes(key));
@@ -407,7 +406,7 @@ export const frontendImplementationContractSchema = z
407
406
  uiStates: z.array(z.string().min(1)),
408
407
  })
409
408
  .strict()).min(1),
410
- evidenceGaps: z.array(gap),
409
+ evidenceGaps: z.array(gap).optional(),
411
410
  implementationSteps: z.array(z.string().min(1)).optional(),
412
411
  stylingStrategy: z.string().min(1).optional(),
413
412
  uiComponentChoices: uiComponentChoicesSchema.optional(),
@@ -1687,7 +1686,7 @@ export async function analyzeFrontendImplementationContract(input) {
1687
1686
  if (!result.success)
1688
1687
  fail("retryable-invalid", `invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, candidateJsonSha256);
1689
1688
  const blockingGaps = [
1690
- ...result.data.evidenceGaps,
1689
+ ...(result.data.evidenceGaps ?? []),
1691
1690
  ...result.data.requirements.flatMap((item) => item.evidenceGap ? [item.evidenceGap] : []),
1692
1691
  ].filter((item) => item.blocking);
1693
1692
  if (blockingGaps.length > 0)
@@ -1696,7 +1695,7 @@ export async function analyzeFrontendImplementationContract(input) {
1696
1695
  .join(", ")}`, candidateJsonSha256);
1697
1696
  for (const requirementId of canonicalBinding.requirementIds)
1698
1697
  if (!result.data.requirements.some((item) => item.id === requirementId) &&
1699
- !result.data.evidenceGaps.some((item) => item.requirementId === requirementId))
1698
+ !(result.data.evidenceGaps ?? []).some((item) => item.requirementId === requirementId))
1700
1699
  fail("blocked", `frontend contract does not cover ${requirementId}`, candidateJsonSha256);
1701
1700
  return {
1702
1701
  canonical: result.data,
@@ -1728,6 +1727,9 @@ const PLAN_LEDGER_FACT_KINDS_FOR_COMPILE = [
1728
1727
  "mock-api",
1729
1728
  "design-deviation",
1730
1729
  "dependency",
1730
+ "plan-requirement",
1731
+ "plan-verification-target",
1732
+ "plan-evidence-gap",
1731
1733
  ];
1732
1734
  /**
1733
1735
  * Restore the editable RFC 7386 plan patch from a committed plan ledger.
@@ -54,9 +54,6 @@ export function renderFrontendPlanMarkdown(contract) {
54
54
  "## Requirement Coverage",
55
55
  requirementsCoverage(contract),
56
56
  "",
57
- "## Implementation Steps",
58
- bulletList(contract.implementationSteps) || "_(not specified)_",
59
- "",
60
57
  "## Target Files",
61
58
  bulletList(contract.targets.files) || "_(none)_",
62
59
  "",
@@ -334,37 +334,124 @@ async function resolveRequiredOpenspecPaths(input) {
334
334
  // semantics instead of silently weakening an existing run.
335
335
  if (!input.selectionNodeId)
336
336
  return { ok: true, paths: input.candidatePaths };
337
+ // OpenSpec classifications arrive as individual typed `openspec-selection`
338
+ // facts (one path per record_openspec_selection call) so a large candidate
339
+ // set never exceeds a single model output budget. Only positive
340
+ // classifications (required | relevant) are legal facts; candidates that
341
+ // were never mentioned default to irrelevant. Explicit task declarations /
342
+ // source citations (`mandatoryPaths`) are never downgraded: they are
343
+ // always required and always must-read, whether or not the model declared
344
+ // them. Fall back to the legacy single fenced JSON for older runs (which
345
+ // may still carry explicit irrelevant rows).
346
+ let selections;
347
+ let allowIrrelevant = false;
348
+ const selectionNodeId = input.selectionNodeId; // guarded above: no selector -> full candidate semantics
349
+ try {
350
+ const facts = await readCommittedContractFacts(input.runDir, selectionNodeId);
351
+ selections = facts
352
+ .filter((fact) => fact.kind === "openspec-selection")
353
+ .map((fact) => ({
354
+ path: String(fact.path ?? ""),
355
+ disposition: String(fact.disposition ?? ""),
356
+ rationale: String(fact.rationale ?? ""),
357
+ }));
358
+ }
359
+ catch {
360
+ selections = [];
361
+ }
362
+ if (selections.length === 0) {
363
+ const legacy = await readLegacyOpenspecSelection({
364
+ runDir: input.runDir,
365
+ selectionNodeId,
366
+ candidatePaths: input.candidatePaths,
367
+ mandatoryPaths: input.mandatoryPaths,
368
+ });
369
+ if (!legacy.ok)
370
+ return legacy;
371
+ selections = legacy.selections;
372
+ allowIrrelevant = true;
373
+ }
374
+ try {
375
+ const byPath = new Map();
376
+ for (const row of selections) {
377
+ const legalDispositions = allowIrrelevant
378
+ ? ["required", "relevant", "irrelevant"]
379
+ : ["required", "relevant"];
380
+ if (typeof row.path !== "string" ||
381
+ !legalDispositions.includes(row.disposition) ||
382
+ typeof row.rationale !== "string" ||
383
+ !row.rationale.trim()) {
384
+ throw new Error(`invalid selection row (path+${legalDispositions.join("|")}+non-empty rationale expected): ${JSON.stringify(row)}`);
385
+ }
386
+ if (!input.candidatePaths.includes(row.path)) {
387
+ throw new Error(`invalid selection ${row.path}: not a candidate path`);
388
+ }
389
+ if (byPath.has(row.path))
390
+ throw new Error(`duplicate selection ${row.path}`);
391
+ byPath.set(row.path, row.disposition);
392
+ }
393
+ // Only explicitly required/relevant candidates must be read; everything
394
+ // unmentioned defaults to irrelevant. mandatoryPaths are intrinsic:
395
+ // they must be read even if the model omitted them, and they can never
396
+ // be downgraded by a declaration.
397
+ const mustRead = [
398
+ ...input.mandatoryPaths,
399
+ ...input.candidatePaths.filter((candidate) => byPath.get(candidate) === "required"),
400
+ ];
401
+ return { ok: true, paths: [...new Set(mustRead)].sort() };
402
+ }
403
+ catch (error) {
404
+ return {
405
+ ok: false,
406
+ reason: `openspec selector invalid: ${error instanceof Error ? error.message : String(error)}`,
407
+ };
408
+ }
409
+ }
410
+ async function readCommittedContractFacts(runDir, nodeId) {
411
+ const { readTypedEventStoreFromJsonl } = await import("./frontend-typed-event-store.js");
412
+ const records = await readTypedEventStoreFromJsonl(path.join(runDir, nodeId, "contract-typed-facts.jsonl"));
413
+ return records
414
+ .filter((record) => record.phase === "committed")
415
+ .map((record) => record.fact)
416
+ .filter((fact) => Boolean(fact));
417
+ }
418
+ async function readLegacyOpenspecSelection(input) {
337
419
  let text;
338
420
  try {
339
421
  text = await readNodeText(input.runDir, input.selectionNodeId);
340
422
  }
341
423
  catch (error) {
342
- return { ok: false, reason: `openspec selector output unreadable: ${error instanceof Error ? error.message : String(error)}` };
424
+ return {
425
+ ok: false,
426
+ reason: `openspec selector output unreadable: ${error instanceof Error ? error.message : String(error)}`,
427
+ };
343
428
  }
344
429
  const fenced = /```json\s*\n([\s\S]*?)\n```/.exec(text);
345
- if (!fenced)
346
- return { ok: false, reason: "openspec selector must emit exactly one fenced JSON selection object" };
430
+ if (!fenced) {
431
+ return {
432
+ ok: false,
433
+ reason: "openspec selector emitted neither typed openspec-selection facts nor a legacy fenced JSON selection object",
434
+ };
435
+ }
347
436
  try {
348
437
  const parsed = JSON.parse(fenced[1] ?? "");
349
- if (parsed.schemaVersion !== 1 || parsed.schemaId !== "frontend-openspec-selection-v1" || !Array.isArray(parsed.selections))
438
+ if (parsed.schemaVersion !== 1 ||
439
+ parsed.schemaId !== "frontend-openspec-selection-v1" ||
440
+ !Array.isArray(parsed.selections)) {
350
441
  throw new Error("schemaVersion/schemaId/selections invalid");
351
- const byPath = new Map();
352
- for (const row of parsed.selections) {
353
- if (typeof row.path !== "string" || typeof row.disposition !== "string" || typeof row.rationale !== "string")
354
- throw new Error("selection row invalid");
355
- if (!input.candidatePaths.includes(row.path) || !["required", "relevant", "irrelevant"].includes(row.disposition))
356
- throw new Error(`invalid selection ${row.path}`);
357
- if (byPath.has(row.path))
358
- throw new Error(`duplicate selection ${row.path}`);
359
- byPath.set(row.path, row.disposition);
360
442
  }
361
- const missing = input.candidatePaths.filter((candidate) => !byPath.has(candidate));
362
- if (missing.length)
363
- throw new Error(`missing classifications: ${missing.join(", ")}`);
364
- return { ok: true, paths: [...new Set([...input.mandatoryPaths, ...input.candidatePaths.filter((candidate) => byPath.get(candidate) === "required")])].sort() };
443
+ const rows = parsed.selections.map((row) => ({
444
+ path: String(row.path ?? ""),
445
+ disposition: String(row.disposition ?? ""),
446
+ rationale: String(row.rationale ?? ""),
447
+ }));
448
+ return { ok: true, selections: rows };
365
449
  }
366
450
  catch (error) {
367
- return { ok: false, reason: `openspec selector invalid: ${error instanceof Error ? error.message : String(error)}` };
451
+ return {
452
+ ok: false,
453
+ reason: `openspec selector invalid: ${error instanceof Error ? error.message : String(error)}`,
454
+ };
368
455
  }
369
456
  }
370
457
  async function finalizePrewrite(input, pending) {
@@ -232,6 +232,9 @@ export const PLAN_LEDGER_FACT_KINDS = [
232
232
  "mock-api",
233
233
  "design-deviation",
234
234
  "dependency",
235
+ "plan-requirement",
236
+ "plan-verification-target",
237
+ "plan-evidence-gap",
235
238
  ];
236
239
  function skeletonTargetFiles(skeleton) {
237
240
  if (!isRecord(skeleton))
@@ -320,6 +323,29 @@ export function assemblePlanPatchFromCommittedFacts(records) {
320
323
  const dependencyPolicy = asString(dependency?.policy);
321
324
  if (dependencyPolicy)
322
325
  patch.dependencyPolicy = dependencyPolicy;
326
+ // Requirements / verification targets / evidence gaps / implementation
327
+ // steps arrive as individual facts (one per record_plan_* call) so a large
328
+ // plan never exceeds a single model output budget. Aggregate them in
329
+ // ledger order.
330
+ const requirements = facts
331
+ .filter((fact) => fact.kind === "plan-requirement")
332
+ .map((fact) => fact.entry)
333
+ .filter(isRecord);
334
+ if (requirements.length > 0)
335
+ patch.requirements = requirements;
336
+ const verificationTargets = facts
337
+ .filter((fact) => fact.kind === "plan-verification-target")
338
+ .map((fact) => fact.entry)
339
+ .filter(isRecord);
340
+ if (verificationTargets.length > 0) {
341
+ patch.verificationTargets = verificationTargets;
342
+ }
343
+ const evidenceGaps = facts
344
+ .filter((fact) => fact.kind === "plan-evidence-gap")
345
+ .map((fact) => fact.entry)
346
+ .filter(isRecord);
347
+ if (evidenceGaps.length > 0)
348
+ patch.evidenceGaps = evidenceGaps;
323
349
  return Object.keys(patch).length > 0 ? patch : undefined;
324
350
  }
325
351
  /** Derive the 7 origin=plan ledger facts from an editable patch. Skeleton
@@ -460,7 +486,8 @@ function planLedgerFactsFromCommittedRecords(records) {
460
486
  .filter((fact) => isRecord(fact) &&
461
487
  fact.origin === "plan" &&
462
488
  typeof fact.kind === "string" &&
463
- PLAN_LEDGER_FACT_KINDS.includes(fact.kind));
489
+ PLAN_LEDGER_FACT_KINDS.includes(fact.kind))
490
+ .map((fact) => fact);
464
491
  }
465
492
  export async function loadCommittedPlanLedgerFacts(runDir, nodeId = "frontend-plan-pi") {
466
493
  const { readTypedEventStoreFromJsonl } = await import("./frontend-typed-event-store.js");
@@ -94,6 +94,7 @@ export const CONTRACT_FACT_KINDS = [
94
94
  "handoff-intent",
95
95
  "open-question",
96
96
  "split-proposal",
97
+ "openspec-selection",
97
98
  "contract-finalized",
98
99
  ];
99
100
  export const CONTRACT_TERMINAL_FACT_KINDS = ["contract-finalized"];
@@ -112,6 +113,18 @@ export const contractBlockingOwnerSchema = z.enum([
112
113
  * `finalize_plan` terminal fact.
113
114
  */
114
115
  export const PLAN_TERMINAL_FACT_KINDS = ["finalize_plan"];
116
+ /**
117
+ * A+B: incremental payload records for `frontend-plan-pi` (requirements,
118
+ * verification targets, evidence gaps). Each is committed by one
119
+ * `record_plan_*` call so a large plan never exceeds a single model output
120
+ * budget; `assemblePlanPatchFromCommittedFacts` aggregates them from the
121
+ * ledger in commit order.
122
+ */
123
+ export const PLAN_RECORD_FACT_KINDS = [
124
+ "plan-requirement",
125
+ "plan-verification-target",
126
+ "plan-evidence-gap",
127
+ ];
115
128
  /**
116
129
  * A+B (AC-009): typed issue category shared by review and design change
117
130
  * requests. The five-value enum replaces free-form issueCategory strings.
@@ -142,6 +155,7 @@ const ALL_TYPED_EVENT_FACT_KIND_VALUES = [
142
155
  ...FRONTEND_SHAPE_FACT_KINDS,
143
156
  ...CONTRACT_FACT_KINDS,
144
157
  ...PLAN_TERMINAL_FACT_KINDS,
158
+ ...PLAN_RECORD_FACT_KINDS,
145
159
  ]),
146
160
  ];
147
161
  export const typedEventFactKindSchema = z.enum(ALL_TYPED_EVENT_FACT_KIND_VALUES);
@@ -11,7 +11,7 @@ import { pathMatchesPattern } from "../../shared/git-progress.js";
11
11
  import { DEFAULT_OPENSPEC_GOVERNANCE_ROOT, DEFAULT_FRONTEND_SPEC_ROOTS, extractTaskSourceFrontendSpecPaths, } from "../../shared/openspec-spec.js";
12
12
  import { BASELINE_FORBIDDEN_PATHS } from "./governance-constants.js";
13
13
  import { buildDecisionEnvelopePromptContract } from "./decision-envelope.js";
14
- import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PLANNER_OUTPUT_LIMIT_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, BACKEND_TEST_WRITER_COMPLETENESS_RETRY_POLICY, WRITER_TRANSPORT_RETRY_POLICY, FRONTEND_PLAN_LADDER_RETRY_POLICY, TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE, TARGET_TEMPLATE_WRITER_TRANSPORT_RETRY_POLICY, isCanonicalFinalVerifyShellRetryCandidate, isSafeReadOnlyPiRetryCandidate, isTargetTemplateImplementPi, isWriterTransportRetryCandidate, } from "./retry-policy.js";
14
+ import { DEFAULT_READ_ONLY_PI_RETRY_POLICY, PLANNER_OUTPUT_LIMIT_RETRY_POLICY, PROTOCOL_AWARE_PI_RETRY_POLICY, BACKEND_TEST_WRITER_COMPLETENESS_RETRY_POLICY, WRITER_TRANSPORT_RETRY_POLICY, FRONTEND_PLAN_LADDER_RETRY_POLICY, FRONTEND_REVIEW_TERMINAL_RETRY_POLICY, TARGET_TEMPLATE_TRANSIENT_RETRY_PROFILE, TARGET_TEMPLATE_WRITER_TRANSPORT_RETRY_POLICY, isCanonicalFinalVerifyShellRetryCandidate, isSafeReadOnlyPiRetryCandidate, isTargetTemplateImplementPi, isWriterTransportRetryCandidate, } from "./retry-policy.js";
15
15
  import { REVIEW_JSON_VERDICT_OUTPUT_PROTOCOL, REVIEW_VERDICT_OUTPUT_PROTOCOL, } from "./output-protocol.js";
16
16
  import { resolveAdapter } from "../../adapters/index.js";
17
17
  import { loadHarnessManifest } from "../../governance/harness.js";
@@ -2651,7 +2651,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2651
2651
  '{"strategy":"native","productionDefaultOff":true,"activation":"VITE_ENABLE_MOCK=true","endpoints":[{"method":"GET","path":"/api/users","fixture":"mocks/fixtures/users.json","consumer":"src/api/users.ts"}]}',
2652
2652
  "",
2653
2653
  "### optional plan fields - GOOD (all optional; omit when absent):",
2654
- '{"implementationSteps":["confirm contract","sync tests"],"stylingStrategy":"reuse existing design tokens","dependencyPolicy":"no new runtime deps","residualRisks":["browser a11y not-run"],"realIntegrationGap":"FE-TEST owns live HTTP"}',
2654
+ '{"stylingStrategy":"reuse existing design tokens","dependencyPolicy":"no new runtime deps","residualRisks":["browser a11y not-run"],"realIntegrationGap":"FE-TEST owns live HTTP"}',
2655
2655
  "",
2656
2656
  "### optional plan fields - BAD (present-but-empty strings are rejected):",
2657
2657
  '{"stylingStrategy":"","dependencyPolicy":""} <-- REJECTED: optional string fields must be non-empty when present; omit them instead',
@@ -2676,8 +2676,8 @@ async function buildFrontendHybridDagFromTask(sources) {
2676
2676
  "- targets: files, routes, publicApiChanges",
2677
2677
  "- mockApi: strategy, productionDefaultOff, activation, endpoints[]",
2678
2678
  "- verificationTargets[]: id, type, commandLabel, file, symbol, requirementIds, uiStates",
2679
- "- designEvidence: source, paths, conflicts; evidenceGaps[]",
2680
- "- optional: implementationSteps[], stylingStrategy, uiComponentChoices[], dependencyPolicy, residualRisks[], realIntegrationGap",
2679
+ "- designEvidence: source, paths, conflicts; evidenceGaps[] (optional)",
2680
+ "- optional: stylingStrategy, uiComponentChoices[], dependencyPolicy, residualRisks[], realIntegrationGap",
2681
2681
  "- uiComponentChoices[]: purpose, component, decision (specified|reuse-existing|new), specReference { path, section, line } | null, rationale",
2682
2682
  "Do not require or read a separate plan prose section; the contract JSON is the only plan surface.",
2683
2683
  ].join("\n");
@@ -2929,7 +2929,7 @@ async function buildFrontendHybridDagFromTask(sources) {
2929
2929
  allowedPaths: readOnlyPaths,
2930
2930
  forbiddenPaths,
2931
2931
  skills: FRONTEND_IMPLEMENTATION_SKILLS,
2932
- outputContract: "Typed requirement facts plus a concise Markdown contract. Submit through the incremental typed tools record_requirement / record_constraint / record_evidence_expectation / record_handoff_intent / record_open_question / record_split_proposal, then call finalize_contract exactly once. Requirements use stable REQ/BR/AC identifiers with source spans and a disposition (explicit | repository-resolvable | assumption | blocking); each requirement registers evidence expectations across static/behavior/Mock/real-integration (required | optional | not-applicable), and UI-visible or interactive requirements register a non-blocking frontend-test handoff intent. End finalize_contract with a single contract disposition of ready | ready-with-assumptions | blocked. Cover scope, non-goals, acceptance criteria, UI states, target runtime environment, risks, and verification expectations; do not fix target files, components, or implementation methods as requirements. When OpenSpec candidates exist, also append the openspec classification and one final fenced frontend-openspec-selection-v1 JSON object. No file writes.",
2932
+ outputContract: "Typed requirement facts plus a concise Markdown contract. Submit through the incremental typed tools record_requirement / record_constraint / record_evidence_expectation / record_handoff_intent / record_open_question / record_split_proposal / record_openspec_selection, then call finalize_contract exactly once. Requirements use stable REQ/BR/AC identifiers with source spans and a disposition (explicit | repository-resolvable | assumption | blocking); each requirement registers evidence expectations across static/behavior/Mock/real-integration (required | optional | not-applicable), and UI-visible or interactive requirements register a non-blocking frontend-test handoff intent. End finalize_contract with a single contract disposition of ready | ready-with-assumptions | blocked. Cover scope, non-goals, acceptance criteria, UI states, target runtime environment, risks, and verification expectations; do not fix target files, components, or implementation methods as requirements. When OpenSpec candidates exist, classify only the ones you actually use: call record_openspec_selection once per required/relevant path; never enumerate irrelevant candidates (unmentioned defaults to irrelevant) and never emit a fenced selection JSON. No file writes.",
2933
2933
  subtask_prompt: [
2934
2934
  "Read task source and produce a concise frontend implementation contract as typed requirement facts plus narrative Markdown.",
2935
2935
  "Assign each requirement a stable REQ/BR/AC identifier and a source span (task-source section or repository file:line). Label each requirement's disposition as explicit | repository-resolvable | assumption | blocking; a blocking requirement must name its owner (human-decision or external-state) and evidence refs.",
@@ -2938,8 +2938,8 @@ async function buildFrontendHybridDagFromTask(sources) {
2938
2938
  "If the task is too large for one bounded writer, record a task split proposal instead of silently widening scope.",
2939
2939
  "End the contract with a single disposition: ready, ready-with-assumptions (bounded assumptions that do not change product behavior), or blocked.",
2940
2940
  ...(requiresOpenspecClassification ? [
2941
- "Classify every generation-frozen OpenSpec candidate while contracting: required must be read and cited by the plan; relevant may inform planning but is not forced; irrelevant is out of scope. mandatoryPaths MUST be required and cannot be downgraded.",
2942
- "After the Markdown contract, append exactly one fenced json object with schemaVersion 1, schemaId frontend-openspec-selection-v1, and selections [{path,disposition,rationale}]. No other JSON fences.",
2941
+ "Classify OpenSpec candidates incrementally while contracting — only the ones you actually use. Call record_openspec_selection once per path with disposition required (must be read and cited by the plan) or relevant (may inform planning). Never call it for irrelevant candidates and never list them: candidates you do not mention are treated as irrelevant by the runtime. Explicit task declarations / source citations are already required and must-read regardless; you never need to re-declare them.",
2942
+ "Mandatory paths are enforced by the runtime from the frozen task configuration do not enumerate them, do not downgrade them.",
2943
2943
  openspecSelectionContext,
2944
2944
  ] : []),
2945
2945
  "Read-only: do not modify code, docs, artifacts, or repository files.",
@@ -2982,13 +2982,14 @@ async function buildFrontendHybridDagFromTask(sources) {
2982
2982
  retryOnInvalid: true,
2983
2983
  skeleton: frontendContractSkeleton,
2984
2984
  },
2985
- outputContract: "Short Markdown narrative plus incremental typed plan record tools (record_target_surface / record_component_choice / record_state_flow / record_data_flow / record_mock_api / record_design_deviation / record_dependency) and exactly one finalize_plan terminal call. finalize_plan assembles the canonical editable patch from committed facts (requirements / verificationTargets / evidenceGaps / implementationSteps / residualRisks / realIntegrationGap). Omit protected fields: schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. The runtime compiles facts ⊕ skeleton into canonical full-contract JSON for downstream review. No file writes.",
2985
+ outputContract: "Short Markdown narrative plus incremental typed plan record tools (record_target_surface / record_component_choice / record_state_flow / record_data_flow / record_mock_api / record_design_deviation / record_dependency / record_plan_requirement / record_plan_verification_target / record_plan_evidence_gap) and exactly one finalize_plan terminal call. finalize_plan assembles the canonical editable patch from the committed ledger facts (requirements / verificationTargets / evidenceGaps / residualRisks / realIntegrationGap). Commit requirements and verification targets incrementally — one entry per record call — so the plan never needs a single large output; evidence gaps are optional and only needed for genuine gaps. Implementation steps are NOT part of the plan — the implementer designs its own ordering. Omit protected fields: schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff. The runtime compiles facts ⊕ skeleton into canonical full-contract JSON for downstream review. No file writes.",
2986
2986
  subtask_prompt: [
2987
2987
  "Use frontend-contract-pi typed requirement facts (stable REQ/BR/AC identifiers, dispositions, evidence expectations, and frontend-test handoff intents), frontend-scout-pi, task sources, and the generation-time Mock capability evidence to fill the runtime-owned frontend contract skeleton. Record the plan decision ledger through the incremental record_* tools, then call finalize_plan exactly once. The runtime already owns schemaVersion, sourceBinding, riskLevel, targets.files, and mockApi.productionDefaultOff; omit those protected paths even when their values look obvious.",
2988
+ "Commit requirements with record_plan_requirement (one entry per call) and verification targets with record_plan_verification_target (one entry per call). Evidence gaps are optional — call record_plan_evidence_gap only for genuine gaps. Never bundle these into finalize_plan arguments — finalize_plan only closes the plan. Splitting one entry per call keeps every single output small. Do NOT plan implementation steps: the implementer decides ordering itself.",
2988
2989
  ...(requiresOpenspecClassification ? ["Additionally consume the frontend-contract-pi openspec classification. Successfully read every required selection (including mandatory paths) and declare any used component specReference in the typed decision ledger; relevant/irrelevant selections are not forced into the ledger unless the plan actually uses them."] : []),
2989
2990
  "The incremental record_* facts become the complete implementation plan after deterministic merge with the protected skeleton. Do not treat leftover JSON in the narrative as the compile authority.",
2990
2991
  "Select the Mock / API strategy only through record_mock_api. Encode endpoint/fixture mapping, explicit activation, verification commands, and Real Integration Gap in schema-defined fields; productionDefaultOff comes from the protected skeleton and there is no second plan output.",
2991
- "Encode ordered steps (implementationSteps), target files, UI state handling, styling/component strategy (stylingStrategy), interaction notes, Mock/API strategy, dependency policy (dependencyPolicy), deterministic verification entrypoints, Real Integration Gap (realIntegrationGap), and residual risks (residualRisks) into the ledger payload. Use only the fixed entrypoints below; implementation may add tests behind them but cannot replace them.",
2992
+ "Encode target files, UI state handling, styling/component strategy (stylingStrategy), interaction notes, Mock/API strategy, dependency policy (dependencyPolicy), deterministic verification entrypoints, Real Integration Gap (realIntegrationGap), and residual risks (residualRisks) into the ledger payload. Use only the fixed entrypoints below; implementation may add tests behind them but cannot replace them.",
2992
2993
  "Every target file and verification target must be selected from the current target workspace and task scope. Do not reuse paths or symbols from examples, prior tasks, or loop-agent itself; if the project uses app/, packages/, spec/, __tests__, or another layout, preserve that layout.",
2993
2994
  "Consume the Scout target surface and design evidence before selecting files. Preserve the discovered existing entrypoint and data source. If implementationPaths or testPaths are outside task allowedPaths, record a blocking scope conflict; do not substitute a new page or silently broaden the writeSet.",
2994
2995
  "Output a short Markdown narrative, record the incremental facts, then call finalize_plan exactly once. Do NOT emit protected skeleton fields or a full contract as the authority.",
@@ -3234,6 +3235,7 @@ async function buildFrontendHybridDagFromTask(sources) {
3234
3235
  executor: "pi",
3235
3236
  complexity: "HIGH",
3236
3237
  writePolicy: "read-only",
3238
+ retryPolicy: FRONTEND_REVIEW_TERMINAL_RETRY_POLICY,
3237
3239
  allowedPaths: readOnlyPaths,
3238
3240
  forbiddenPaths,
3239
3241
  skills: FRONTEND_REVIEW_SKILLS,
@@ -228,6 +228,16 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
228
228
  buildProtocolRetryInstruction(task.outputProtocol, previousProtocolReason),
229
229
  ].join("\n");
230
230
  }
231
+ if (previousFailureCategory === "review-terminal-missing") {
232
+ return [
233
+ basePrompt,
234
+ "",
235
+ "<retry_instruction>",
236
+ "The review emitted a verdict in response text but never committed the authoritative typed terminal tool call (approve_review / request_review_changes). The response text is NOT the authority: no branch or gate reads it.",
237
+ "Call exactly one typed terminal tool to finish: approve_review (implementation passes, no Critical/Important findings) or request_review_changes (with typed issueCategory, at least one evidenceRef, and non-empty findings). Do not repeat the review analysis; commit the terminal tool once and stop.",
238
+ "</retry_instruction>",
239
+ ].join("\n");
240
+ }
231
241
  if (previousFailureCategory === "invalid-output" &&
232
242
  task.structuredContractOutput &&
233
243
  previousProtocolReason) {
@@ -25,6 +25,13 @@ export const PROTOCOL_INVALID_RETRY_CATEGORY = "protocol-invalid";
25
25
  export const STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY = "invalid-output";
26
26
  /** Provider stopReason=length truncated the response before the JSON contract completed. */
27
27
  export const STRUCTURED_OUTPUT_TRUNCATED_RETRY_CATEGORY = "structured-output-truncated";
28
+ /**
29
+ * The review node emitted a verdict in response text (e.g. "VERDICT: pass")
30
+ * but never committed the authoritative typed terminal tool call
31
+ * (approve_review / request_review_changes). Read-only and safe to retry with
32
+ * a corrected instruction; the typed terminal is the only authority.
33
+ */
34
+ export const REVIEW_TERMINAL_MISSING_RETRY_CATEGORY = "review-terminal-missing";
28
35
  /** Retry only a proven no-op from an explicitly opt-in bounded Pi writer. */
29
36
  export const WRITER_EMPTY_DIFF_RETRY_CATEGORY = "writer-empty-diff";
30
37
  /** Retry when a backend-test writer finished but Completeness Gate found missing/broken targets. */
@@ -57,6 +64,7 @@ export const ALL_DAG_RETRY_CATEGORIES = [
57
64
  WRITER_EMPTY_DIFF_RETRY_CATEGORY,
58
65
  INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
59
66
  WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY,
67
+ REVIEW_TERMINAL_MISSING_RETRY_CATEGORY,
60
68
  ];
61
69
  const RETRY_SAFE_PI_ROLES = new Set([
62
70
  "planner",
@@ -143,6 +151,20 @@ export const PROTOCOL_AWARE_PI_RETRY_POLICY = {
143
151
  ...DEFAULT_READ_ONLY_PI_RETRY_POLICY,
144
152
  retryCategories: [...PROTOCOL_AWARE_DAG_RETRY_CATEGORIES],
145
153
  };
154
+ /**
155
+ * frontend-review-pi retry policy. The review verdict must be committed
156
+ * through the typed terminal tools (approve_review / request_review_changes);
157
+ * a model that only echoes "VERDICT: pass" text fails as
158
+ * review-terminal-missing. That is read-only and safe to retry with a
159
+ * corrected instruction so a single model slip does not burn the whole run.
160
+ */
161
+ export const FRONTEND_REVIEW_TERMINAL_RETRY_POLICY = {
162
+ ...DEFAULT_READ_ONLY_PI_RETRY_POLICY,
163
+ retryCategories: [
164
+ ...DEFAULT_DAG_RETRY_CATEGORIES,
165
+ REVIEW_TERMINAL_MISSING_RETRY_CATEGORY,
166
+ ],
167
+ };
146
168
  /**
147
169
  * The sole writer retry policy for requireChangedFiles writers. It is
148
170
  * intentionally not included in any read-only default: a writer may retry only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.39.0-beta.3",
3
+ "version": "0.39.0-beta.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",