@tea-agent/loop-agent 0.39.0-beta.3 → 0.39.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi-executor.js +47 -1
- package/dist/executors/shell-executor.js +3 -1
- package/dist/task/frontend-project-capability.js +18 -1
- package/dist/workflows/dag/frontend-design-policy.js +20 -1
- package/dist/workflows/dag/frontend-prewrite-gate.js +105 -18
- package/dist/workflows/dag/frontend-typed-event-store.js +1 -0
- package/dist/workflows/dag/init-hybrid.js +3 -3
- package/package.json +1 -1
package/dist/build-stamp.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.39.0-beta.
|
|
4
|
-
"gitSha": "
|
|
5
|
-
"builtAt": "2026-08-
|
|
3
|
+
"version": "0.39.0-beta.4",
|
|
4
|
+
"gitSha": "c2dcaef179cd0526f8f5779e866d0e1103451baa",
|
|
5
|
+
"builtAt": "2026-08-23T07:52:07.528Z"
|
|
6
6
|
}
|
|
@@ -1176,6 +1176,48 @@ export async function createFrontendContractTools(input) {
|
|
|
1176
1176
|
return receipt(result);
|
|
1177
1177
|
},
|
|
1178
1178
|
}));
|
|
1179
|
+
// OpenSpec selection committed as individual typed facts (one path per
|
|
1180
|
+
// call) so a large candidate set never exceeds a single model output
|
|
1181
|
+
// budget: each tool call carries exactly one {path, disposition,
|
|
1182
|
+
// rationale} row and the ledger accumulates them across calls. Only
|
|
1183
|
+
// positive classifications (required | relevant) are legal; unmentioned
|
|
1184
|
+
// candidates default to irrelevant at the prewrite gate.
|
|
1185
|
+
const recordOpenspecSelectionTool = defineTool({
|
|
1186
|
+
name: "record_openspec_selection",
|
|
1187
|
+
label: "record_openspec_selection",
|
|
1188
|
+
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.",
|
|
1189
|
+
promptSnippet: "Commit one OpenSpec candidate classification (required | relevant); one path per call; skip irrelevant candidates.",
|
|
1190
|
+
parameters: Type.Object({
|
|
1191
|
+
path: Type.String({
|
|
1192
|
+
description: "Repo-relative candidate spec path, e.g. openspec/project-specs/ui/ucp-components-md/AdvancedSearch.md",
|
|
1193
|
+
}),
|
|
1194
|
+
disposition: Type.Enum({
|
|
1195
|
+
required: "required",
|
|
1196
|
+
relevant: "relevant",
|
|
1197
|
+
}),
|
|
1198
|
+
rationale: Type.String({}),
|
|
1199
|
+
}, { additionalProperties: false }),
|
|
1200
|
+
async execute(_toolCallId, params) {
|
|
1201
|
+
const path = typeof params?.path === "string" ? params.path : "";
|
|
1202
|
+
const disposition = params?.disposition;
|
|
1203
|
+
const rationale = typeof params?.rationale === "string" ? params.rationale : "";
|
|
1204
|
+
if (!path || !disposition || !rationale.trim()) {
|
|
1205
|
+
return receipt({
|
|
1206
|
+
ok: false,
|
|
1207
|
+
kind: "openspec-selection",
|
|
1208
|
+
error: "record_openspec_selection requires non-empty path, disposition (required|relevant), and rationale",
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
const result = await adoptContractFact("openspec-selection", {
|
|
1212
|
+
kind: "openspec-selection",
|
|
1213
|
+
origin: "contract",
|
|
1214
|
+
path,
|
|
1215
|
+
disposition,
|
|
1216
|
+
rationale,
|
|
1217
|
+
});
|
|
1218
|
+
return receipt(result);
|
|
1219
|
+
},
|
|
1220
|
+
});
|
|
1179
1221
|
const finalizeContractTool = defineTool({
|
|
1180
1222
|
name: "finalize_contract",
|
|
1181
1223
|
label: "finalize_contract",
|
|
@@ -1225,7 +1267,11 @@ export async function createFrontendContractTools(input) {
|
|
|
1225
1267
|
},
|
|
1226
1268
|
});
|
|
1227
1269
|
return {
|
|
1228
|
-
customTools: [
|
|
1270
|
+
customTools: [
|
|
1271
|
+
...recordTools,
|
|
1272
|
+
recordOpenspecSelectionTool,
|
|
1273
|
+
finalizeContractTool,
|
|
1274
|
+
],
|
|
1229
1275
|
flush: async () => {
|
|
1230
1276
|
const committed = readCommittedEvents(store, attemptId);
|
|
1231
1277
|
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.
|
|
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({
|
|
@@ -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 {
|
|
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 {
|
|
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 ||
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 {
|
|
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) {
|
|
@@ -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,
|
|
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
|
|
2942
|
-
"
|
|
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.",
|