@tea-agent/loop-agent 0.43.0-next.18 → 0.43.0-next.19
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/AGENTS.md +1 -1
- package/CHANGELOG.md +60 -2308
- package/README.md +7 -0
- package/dist/application/task-lifecycle/advance.js +38 -7
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init.js +13 -9
- package/dist/commands/task-advance.js +32 -0
- package/dist/executors/dag-pi-executor.js +1615 -188
- package/dist/executors/pi-executor.js +85 -3
- package/dist/executors/pi-sdk-executor.js +18 -0
- package/dist/executors/shell-executor.js +92 -7
- package/dist/shared/backend-dogfood-preflight.js +47 -0
- package/dist/shared/dag-failure-category.js +3 -0
- package/dist/shared/operator/capabilities.js +180 -0
- package/dist/task/config-types.js +29 -0
- package/dist/task/contract/project.js +9 -0
- package/dist/task/contract/schema.js +17 -0
- package/dist/task/source-prepare/build-draft.js +60 -0
- package/dist/worker/console/chat/pi-mode-loop-isolation.js +155 -0
- package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +24 -16
- package/dist/worker/console/chat/pi-runtime/custom-tools/scheduled-goal-tools.js +22 -0
- package/dist/worker/console/chat/pi-runtime/progressive-tools.js +195 -0
- package/dist/worker/console/chat/pi-runtime.js +86 -27
- package/dist/worker/console/chat/routes.js +19 -0
- package/dist/worker/console/chat/scheduled-goal-booking.js +59 -0
- package/dist/worker/console/chat/scheduled-goal-delivery.js +27 -0
- package/dist/worker/console/chat/scheduled-goal-request.js +190 -0
- package/dist/worker/console/chat/session-mode.js +7 -3
- package/dist/worker/console/chat/session-store.js +42 -9
- package/dist/worker/console/chat/tool-preview.js +115 -0
- package/dist/worker/console/chat/turn-order.js +13 -0
- package/dist/worker/console/chat/turn-process.js +32 -30
- package/dist/worker/console/operator-actions.js +50 -0
- package/dist/worker/console/prd-intake-bridge.js +54 -1
- package/dist/worker/console/scheduled-goal-host.js +98 -0
- package/dist/worker/console/scheduled-goal-operation-adapter.js +127 -0
- package/dist/worker/console/server.js +15 -0
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-B_UDknhR.js → abnfDiagram-N423BO3Z-8-j6y-sd.js} +1 -1
- package/dist/worker/console/static/assets/{arc-D6hU0drN.js → arc-eoQiMvuk.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DmVikdRK.js → architectureDiagram-T3A2C74G-C4A3uMcI.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DydABCRg.js → blockDiagram-VBNYF7ZC-D4zD2F-Q.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DrXKC2Jl.js → c4Diagram-5PPSVZJV-j1RkJziL.js} +1 -1
- package/dist/worker/console/static/assets/channel-ChE7y-cx.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D2-qFxr7.js → chunk-2GRJ4B5K-YBHmhik1.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ChfeF41P.js → chunk-2Q5K7J3B-COfWyo9P.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Drwrz55x.js → chunk-5RXB4S5H-I99OUkHY.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CAQGErL7.js → chunk-5VM5RSS4-XKxoJNJ4.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DHO38w5U.js → chunk-6Q2QTUOP-DLT_cYx1.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-EYIN0EOI.js → chunk-GF5L2VYU-CxcKZ9mV.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CrFLKqkT.js → chunk-JWPE2WC7-V1EqXdjY.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DMchffoo.js → chunk-KBJHAD2P-DebTtdFp.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DaSvtKQa.js → chunk-RYQCIY6F-B-ivNRDf.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DcW8W9Qj.js → chunk-XXDRQBXY-DC_Ds11b.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C01TCf2X.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C01TCf2X.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BUirrMEi.js → cose-bilkent-JH36ORCC-sWEqKwIb.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BXzO8iXR.js → cynefin-VYW2F7L2-BXa_dcu4.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DAtgaxIn.js → cynefinDiagram-MW4NZA55-C-Mle84F.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C1re9Noh.js → dagre-VZM6K2ZE-CXPDBITe.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-N8Kj08J9.js → diagram-7IWD3JNH-CC-WJQfa.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-D6BpSLs3.js → diagram-B4RE2ZJO-CDAV5Vs4.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C2Vlgnl4.js → diagram-LBJQPF4R-CmFiAcNz.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-CMbqtVLS.js → diagram-Q27KOJAE-DPBZHuyn.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-A770Eb-0.js → diagram-UB23O5K3-jUlm_Ds3.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-LV2w_2pT.js → ebnfDiagram-BXEA7PRR-DWhQ3mfY.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-bwqf56ah.js → erDiagram-JOGREHBK-Tr2gMqet.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BnVtHhZh.js → flowDiagram-UKHOOZJN-DJjVQHPA.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BGVToEqc.js → ganttDiagram-PKOTCBZU-D74dQ4u0.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DL2l7Vne.js → gitGraphDiagram-DS77QQ5N-DwW0tZ0X.js} +1 -1
- package/dist/worker/console/static/assets/index-BWkIfcrK.css +1 -0
- package/dist/worker/console/static/assets/{index-B_V4wvXs.js → index-Cdkvw_H6.js} +97 -97
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BWIpubOx.js → infoDiagram-6WML65LV-ILCbxJyb.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DjYBD8vv.js → ishikawaDiagram-WSZJBQD7-DeuWBQ89.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-C0_mBaHb.js → journeyDiagram-NVQOT4AX-CF5ih8Fk.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Cy71zbX6.js → kanban-definition-27J2QSJJ-C7yOSuRO.js} +1 -1
- package/dist/worker/console/static/assets/{linear-Dt3_w3Vn.js → linear-BDZ9riWi.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-DnNOlfEP.js → mermaid.core-7pKqYtpZ.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DAbTspwq.js → mindmap-definition-FAOFIHXS-LeJDybSU.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-NNiM141p.js → pegDiagram-VL7TDLO6-BJvT3pMD.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CN80Z1Do.js → pieDiagram-7S7Q4E2Y-_rGqpMan.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bxyfh0JU.js → quadrantDiagram-CIZ2JOQS-DPcSv5aA.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BlWnC79M.js → railroadDiagram-AXF67PYL-Drxx4hkJ.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BrEu4P_e.js → requirementDiagram-LRYGKXZP-BCTUdU4z.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Bt70igBr.js → sankeyDiagram-W5VNT64P-B6wzbZmB.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-C8DQR-r9.js → sequenceDiagram-SI44F4Z6-BGt8d3QQ.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DEMinJAT.js → sizeCapture-X5ZJPWSS-7nlhJKo7.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-DDdDBzQc.js → stateDiagram-OKZ733FA-Cv2stqMA.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DC7V5vcq.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-G2wcBs3T.js → swimlanes-SLNWSIFB-qDAo4Yc1.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bwy4QUTO.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Dzn4g2gJ.js → timeline-definition-Z64GVDOM-CKbDNKTr.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-5v2rP9oO.js → vennDiagram-T6HMQDX7-DI-9EHic.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-B4EOx7ew.js → wardleyDiagram-T6FBY63Y-BrzzRDpX.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CQ1v8eYP.js → xychartDiagram-ELKLHX3M-BclH5hGh.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +41 -11
- package/dist/worker/console/static-src/operator-chat/compaction-message.js +3 -17
- package/dist/worker/console/static-src/operator-chat/timeline-merge.js +29 -0
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +2 -5
- package/dist/worker/console/workspace-context.js +11 -0
- package/dist/worker/observe/static/operator-chrome.js +3 -1
- package/dist/worker/scheduler/scheduled-goal-dispatch.js +117 -0
- package/dist/worker/scheduler/scheduled-goal-evidence.js +167 -0
- package/dist/worker/scheduler/scheduled-goal-recovery.js +62 -0
- package/dist/worker/scheduler/scheduled-goal-store.js +699 -0
- package/dist/worker/scheduler/scheduled-goal-supervisor.js +132 -0
- package/dist/worker/scheduler/scheduled-goal-time.js +102 -0
- package/dist/worker/scheduler/scheduled-goal-types.js +95 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +6 -24
- package/dist/workflows/dag/backend-test-plan-protocol.js +82 -5
- package/dist/workflows/dag/dag-retry-schema.js +11 -0
- package/dist/workflows/dag/frontend-closeout.js +4 -2
- package/dist/workflows/dag/frontend-committed-facts.js +461 -0
- package/dist/workflows/dag/frontend-durable-tools.js +15 -3
- package/dist/workflows/dag/frontend-implementation-contract.js +365 -8
- package/dist/workflows/dag/frontend-plan-canary.js +53 -0
- package/dist/workflows/dag/frontend-plan-decision-contract.js +803 -0
- package/dist/workflows/dag/frontend-plan-render.js +0 -2
- package/dist/workflows/dag/frontend-prewrite-gate.js +3 -3
- package/dist/workflows/dag/frontend-provider-capability-matrix.js +8 -61
- package/dist/workflows/dag/frontend-recovery-run.js +57 -0
- package/dist/workflows/dag/frontend-review-context.js +43 -70
- package/dist/workflows/dag/frontend-risk.js +92 -10
- package/dist/workflows/dag/frontend-session-budget.js +117 -3
- package/dist/workflows/dag/frontend-shape.js +11 -55
- package/dist/workflows/dag/frontend-test-execution-evidence.js +35 -10
- package/dist/workflows/dag/frontend-typed-event-store.js +32 -25
- package/dist/workflows/dag/frontend-verification-trace.js +11 -2
- package/dist/workflows/dag/frontend-writer-admission.js +3 -47
- package/dist/workflows/dag/frontend-writer-status.js +0 -23
- package/dist/workflows/dag/init-hybrid.js +182 -185
- package/dist/workflows/dag/lifecycle.js +7 -2
- package/dist/workflows/dag/node-execution.js +48 -13
- package/dist/workflows/dag/rerun-plan.js +10 -0
- package/dist/workflows/dag/rerun-task.js +77 -1
- package/dist/workflows/dag/retry-policy.js +18 -0
- package/dist/workflows/dag/scheduler.js +2 -4
- package/dist/workflows/dag/types.js +23 -0
- package/docs/README.md +1 -0
- package/docs/init-surface.manifest.json +1 -0
- package/docs/operations/README.md +2 -0
- package/docs/templates/README.md +1 -1
- package/docs/templates/agent-dag.schema.json +2 -2
- package/docs/templates/backend-test-dag.json +14 -10
- package/docs/templates/frontend-implementation-contract.schema.json +0 -7
- package/docs/templates/frontend-implementation-dag.json +8 -9
- package/package.json +6 -3
- package/skills/frontend-bounded-implement/SKILL.md +3 -4
- package/skills/frontend-design-review/SKILL.md +7 -12
- package/skills/frontend-design-review/references/review-checklist.md +7 -7
- package/skills/frontend-plan/SKILL.md +8 -18
- package/skills/frontend-plan/references/decision-contract.md +19 -26
- package/skills/frontend-review/SKILL.md +21 -25
- package/skills/frontend-review/references/review-findings.md +48 -24
- package/dist/worker/console/static/assets/channel-Drecd94a.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-8Udu0t8-.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-8Udu0t8-.js +0 -1
- package/dist/worker/console/static/assets/index-DcudonhZ.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CyCiS0Lt.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-U0lhi6N0.js +0 -8
- package/dist/workflows/dag/frontend-shadow-dual-write.js +0 -975
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
3
|
+
import { deterministicSha256 } from "./frontend-implementation-contract.js";
|
|
4
|
+
import { isConcreteWriteSetPath } from "./frontend-writer-admission.js";
|
|
5
|
+
/** Experimental model input/output. Not a canonical writer authority. The
|
|
6
|
+
* only coverage judgment is behaviorGroupId -> verification focus. Group
|
|
7
|
+
* membership belongs to the source-bound Contract, not another Plan table. */
|
|
8
|
+
export const frontendPlanDecisionContractSchema = z.object({
|
|
9
|
+
schemaId: z.literal("frontend-plan-decision-contract-v1"),
|
|
10
|
+
modulePlacements: z.array(z.object({ id: z.string().min(1), paths: z.array(z.string().min(1)).min(1) }).strict()),
|
|
11
|
+
reuseDecisions: z.array(z.object({
|
|
12
|
+
symbol: z.string().min(1),
|
|
13
|
+
decision: z.enum(["specified", "reuse-existing", "new"]),
|
|
14
|
+
evidence: z.array(z.string()),
|
|
15
|
+
// Required at tool level for specified/new (the spec section or task-source
|
|
16
|
+
// declaration the choice implements); the bridge carries it into the
|
|
17
|
+
// canonical specReference.section, which the contract schema mandates.
|
|
18
|
+
specSection: z.string().min(1).optional(),
|
|
19
|
+
purpose: z.string().min(1), covers: z.array(z.string().min(1)).optional(), rationale: z.string().min(1).optional(),
|
|
20
|
+
// Optional task-wide style contract carried once on any choice row (the
|
|
21
|
+
// relationship component-choice channel); the bridge promotes the first
|
|
22
|
+
// non-empty value to the canonical top-level stylingStrategy.
|
|
23
|
+
stylingStrategy: z.string().min(1).optional(),
|
|
24
|
+
}).strict()),
|
|
25
|
+
stateOwnership: z.array(z.object({
|
|
26
|
+
state: z.string().min(1), owner: z.string().min(1),
|
|
27
|
+
applicable: z.boolean(), expectedBehavior: z.string().min(1).optional(), notApplicableReason: z.string().min(1).optional(),
|
|
28
|
+
}).strict()),
|
|
29
|
+
dataFlows: z.array(z.object({
|
|
30
|
+
interaction: z.string().min(1), source: z.string().min(1),
|
|
31
|
+
trigger: z.string().min(1), expectedBehavior: z.string().min(1),
|
|
32
|
+
}).strict()),
|
|
33
|
+
apiMockBoundaries: z.array(z.object({
|
|
34
|
+
boundary: z.string().min(1),
|
|
35
|
+
mode: z.enum(["real", "mock", "not-needed"]),
|
|
36
|
+
evidence: z.string().min(1),
|
|
37
|
+
// Design policy hard-requires fixture + consumer on every derived endpoint
|
|
38
|
+
// (non-not-needed strategies); the bridge promotes them verbatim instead of
|
|
39
|
+
// fabricating paths. Required at tool level for real/mock (gate).
|
|
40
|
+
fixture: z.string().min(1).optional(),
|
|
41
|
+
consumer: z.string().min(1).optional(),
|
|
42
|
+
}).strict()),
|
|
43
|
+
dependencies: z.array(z.string()),
|
|
44
|
+
verificationFocus: z.array(z.object({
|
|
45
|
+
id: z.string().min(1), behaviorGroupId: z.string().min(1),
|
|
46
|
+
file: z.string().min(1), commandId: z.string().min(1),
|
|
47
|
+
evidenceLevel: z.enum(["unit", "mounted", "real-integration"]),
|
|
48
|
+
boundary: z.string().min(1).optional(),
|
|
49
|
+
}).strict()),
|
|
50
|
+
}).strict();
|
|
51
|
+
const sorted = (values) => [...new Set(values)].sort();
|
|
52
|
+
const concrete = (file) => isConcreteWriteSetPath(file) && !/[\[\]{}():]/.test(file) && !file.split("/").includes(".");
|
|
53
|
+
export function projectFrontendSafetyFacts(input) {
|
|
54
|
+
const decision = frontendPlanDecisionContractSchema.parse(input.decision);
|
|
55
|
+
const { authority } = input;
|
|
56
|
+
const findings = [];
|
|
57
|
+
if (!/^[a-f0-9]{64}$/.test(authority.sourceBinding.requirementSha256) || authority.sourceBinding.requirementSha256 !== authority.observedRequirementSha256)
|
|
58
|
+
findings.push("source-binding-stale-or-invalid");
|
|
59
|
+
const requirementIds = sorted(authority.requirements.map(item => item.id));
|
|
60
|
+
if (requirementIds.length !== authority.requirements.length || authority.requirements.some(item => !item.id.trim() || !item.expectedOutcome.trim()))
|
|
61
|
+
findings.push("invalid-requirements");
|
|
62
|
+
const groups = new Map(authority.behaviorGroups.map(group => [group.id, group]));
|
|
63
|
+
if (groups.size !== authority.behaviorGroups.length)
|
|
64
|
+
findings.push("duplicate-behavior-group");
|
|
65
|
+
const ownedStates = new Set(decision.stateOwnership.map(item => item.state));
|
|
66
|
+
for (const group of authority.behaviorGroups) {
|
|
67
|
+
if (!group.requirementIds.length)
|
|
68
|
+
findings.push(`empty-behavior-group:${group.id}`);
|
|
69
|
+
for (const id of group.requirementIds)
|
|
70
|
+
if (!requirementIds.includes(id))
|
|
71
|
+
findings.push(`unknown-requirement:${id}`);
|
|
72
|
+
for (const state of group.requiredStates ?? []) {
|
|
73
|
+
if (!ownedStates.has(state))
|
|
74
|
+
findings.push(`missing-state:${group.id}:${state}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const mockBoundaries = new Set(decision.apiMockBoundaries.filter(item => item.mode === "mock").map(item => item.boundary));
|
|
78
|
+
const verificationByRequirement = Object.fromEntries(requirementIds.map(id => [id, []]));
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
const levels = { unit: 0, mounted: 1, "real-integration": 2 };
|
|
81
|
+
const verificationTargets = decision.verificationFocus.map(focus => {
|
|
82
|
+
if (seen.has(focus.id))
|
|
83
|
+
findings.push(`duplicate-verification:${focus.id}`);
|
|
84
|
+
seen.add(focus.id);
|
|
85
|
+
const group = groups.get(focus.behaviorGroupId);
|
|
86
|
+
if (!group)
|
|
87
|
+
findings.push(`unknown-behavior-group:${focus.behaviorGroupId}`);
|
|
88
|
+
if (!authority.commands.some(command => command.id === focus.commandId))
|
|
89
|
+
findings.push(`unknown-command:${focus.commandId}`);
|
|
90
|
+
if (group && levels[focus.evidenceLevel] < levels[group.minimumEvidenceLevel])
|
|
91
|
+
findings.push(`insufficient-evidence-level:${focus.id}`);
|
|
92
|
+
if (focus.boundary && focus.evidenceLevel === "real-integration" && mockBoundaries.has(focus.boundary))
|
|
93
|
+
findings.push(`mock-claimed-as-real:${focus.id}:${focus.boundary}`);
|
|
94
|
+
const ids = sorted(group?.requirementIds ?? []);
|
|
95
|
+
for (const id of ids)
|
|
96
|
+
verificationByRequirement[id]?.push(focus.id);
|
|
97
|
+
return { id: focus.id, file: focus.file, commandId: focus.commandId, requirementIds: ids };
|
|
98
|
+
});
|
|
99
|
+
for (const id of requirementIds)
|
|
100
|
+
verificationByRequirement[id] = sorted(verificationByRequirement[id]);
|
|
101
|
+
const deliveryFiles = sorted([...decision.modulePlacements.flatMap(item => item.paths), ...verificationTargets.map(item => item.file)]);
|
|
102
|
+
for (const file of sorted([...deliveryFiles, ...input.concreteWriteSet])) {
|
|
103
|
+
if (!concrete(file))
|
|
104
|
+
findings.push(`write-set-path-not-concrete:${file}`);
|
|
105
|
+
if (!authority.allowedPaths.some(pattern => pathMatchesPattern(file, pattern)) || authority.forbiddenPaths.some(pattern => pathMatchesPattern(file, pattern)))
|
|
106
|
+
findings.push(`write-set-outside-authority:${file}`);
|
|
107
|
+
}
|
|
108
|
+
// References in reuseDecisions never expand delivery files or authorization.
|
|
109
|
+
const writeSetGaps = deliveryFiles.filter(file => !input.concreteWriteSet.includes(file));
|
|
110
|
+
for (const file of input.concreteWriteSet)
|
|
111
|
+
if (!deliveryFiles.includes(file))
|
|
112
|
+
findings.push(`unplanned-write:${file}`);
|
|
113
|
+
const missingVerificationRequirementIds = requirementIds.filter(id => !verificationByRequirement[id].length);
|
|
114
|
+
return { schemaId: "frontend-safety-projection-v1", authorityDigest: deterministicSha256(authority), requirementIds, deliveryFiles, verificationTargets, verificationByRequirement, missingVerificationRequirementIds, writeSetGaps, findings: sorted(findings) };
|
|
115
|
+
}
|
|
116
|
+
/** Compile the experimental Plan output into a canonical-contract-shaped
|
|
117
|
+
* requirements + verificationTargets view. The model supplies semantic decisions
|
|
118
|
+
* only: module placements, reuse, state ownership, data flows, Mock boundaries
|
|
119
|
+
* and one behavior-group -> verification-focus judgment per group. The runtime
|
|
120
|
+
* expands group membership (source-bound) and derives the per-AC
|
|
121
|
+
* implementationTargets and verificationTargetIds that production Policy /
|
|
122
|
+
* Admission consume. This proves the per-AC file table is derivable and does
|
|
123
|
+
* not have to be re-entered by the planner. */
|
|
124
|
+
export function assembleExperimentPlanContract(input) {
|
|
125
|
+
const projection = projectFrontendSafetyFacts(input);
|
|
126
|
+
const groupByRequirement = new Map();
|
|
127
|
+
for (const group of input.authority.behaviorGroups) {
|
|
128
|
+
for (const id of group.requirementIds)
|
|
129
|
+
groupByRequirement.set(id, group.id);
|
|
130
|
+
}
|
|
131
|
+
const placementPathsById = new Map(input.decision.modulePlacements.map(item => [item.id, item.paths]));
|
|
132
|
+
const unplacedRequirementIds = [];
|
|
133
|
+
const requirements = input.authority.requirements.map(req => {
|
|
134
|
+
const groupId = groupByRequirement.get(req.id);
|
|
135
|
+
const implementationTargets = groupId
|
|
136
|
+
? sorted(placementPathsById.get(groupId) ?? [])
|
|
137
|
+
: [];
|
|
138
|
+
if (implementationTargets.length === 0)
|
|
139
|
+
unplacedRequirementIds.push(req.id);
|
|
140
|
+
return {
|
|
141
|
+
id: req.id,
|
|
142
|
+
expectedOutcome: req.expectedOutcome,
|
|
143
|
+
implementationTargets,
|
|
144
|
+
deliverableTargets: [],
|
|
145
|
+
verificationTargetIds: projection.verificationByRequirement[req.id] ?? [],
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
const uiStates = input.decision.stateOwnership.map(item => ({
|
|
149
|
+
name: item.state,
|
|
150
|
+
applicable: item.applicable,
|
|
151
|
+
...(item.expectedBehavior ? { expectedBehavior: item.expectedBehavior } : {}),
|
|
152
|
+
...(item.notApplicableReason ? { notApplicableReason: item.notApplicableReason } : {}),
|
|
153
|
+
}));
|
|
154
|
+
const focusIdsByGroup = new Map();
|
|
155
|
+
for (const focus of input.decision.verificationFocus) {
|
|
156
|
+
const ids = focusIdsByGroup.get(focus.behaviorGroupId) ?? [];
|
|
157
|
+
ids.push(focus.id);
|
|
158
|
+
focusIdsByGroup.set(focus.behaviorGroupId, ids);
|
|
159
|
+
}
|
|
160
|
+
const interactions = input.decision.dataFlows.map(flow => ({
|
|
161
|
+
name: flow.interaction,
|
|
162
|
+
trigger: flow.trigger,
|
|
163
|
+
expectedBehavior: flow.expectedBehavior,
|
|
164
|
+
implementationTargets: sorted(placementPathsById.get(flow.source) ?? []),
|
|
165
|
+
verificationTargetIds: sorted(focusIdsByGroup.get(flow.source) ?? []),
|
|
166
|
+
}));
|
|
167
|
+
const uiComponentChoices = input.decision.reuseDecisions.map(item => {
|
|
168
|
+
const evidencePath = item.evidence[0];
|
|
169
|
+
return {
|
|
170
|
+
purpose: item.purpose,
|
|
171
|
+
component: item.symbol,
|
|
172
|
+
decision: item.decision,
|
|
173
|
+
specReference: evidencePath
|
|
174
|
+
? { path: evidencePath, ...(item.specSection ? { section: item.specSection } : {}) }
|
|
175
|
+
: null,
|
|
176
|
+
rationale: item.rationale ?? "",
|
|
177
|
+
...(item.covers ? { covers: item.covers } : {}),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
const boundaries = input.decision.apiMockBoundaries;
|
|
181
|
+
const mockStrategy = boundaries.some(item => item.mode === "mock")
|
|
182
|
+
? "browser-intercept"
|
|
183
|
+
: boundaries.some(item => item.mode === "real")
|
|
184
|
+
? "native"
|
|
185
|
+
: "not-needed";
|
|
186
|
+
const mockEndpoints = boundaries.map(item => {
|
|
187
|
+
const [method = "", ...pathParts] = item.boundary.split(/\s+/);
|
|
188
|
+
const rawPath = (pathParts.join(" ") || item.boundary).trim();
|
|
189
|
+
return {
|
|
190
|
+
method,
|
|
191
|
+
path: rawPath.startsWith("/") ? rawPath : `/${rawPath}`,
|
|
192
|
+
...(item.fixture ? { fixture: item.fixture } : {}),
|
|
193
|
+
...(item.consumer ? { consumer: item.consumer } : {}),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
const mockApi = {
|
|
197
|
+
strategy: mockStrategy,
|
|
198
|
+
productionDefaultOff: true,
|
|
199
|
+
activation: "explicit activation boundary",
|
|
200
|
+
endpoints: mockEndpoints,
|
|
201
|
+
};
|
|
202
|
+
const targets = { files: projection.deliveryFiles };
|
|
203
|
+
const designEvidence = { source: "experiment-decision", paths: [], conflicts: [] };
|
|
204
|
+
const dependencyPolicy = input.decision.dependencies.length > 0
|
|
205
|
+
? input.decision.dependencies.join(", ")
|
|
206
|
+
: "零新增";
|
|
207
|
+
const verificationTargets = projection.verificationTargets.map(target => ({
|
|
208
|
+
...target,
|
|
209
|
+
mode: "behavior",
|
|
210
|
+
commandLabel: target.commandId,
|
|
211
|
+
uiStates: [],
|
|
212
|
+
}));
|
|
213
|
+
return {
|
|
214
|
+
requirements,
|
|
215
|
+
verificationTargets,
|
|
216
|
+
uiStates,
|
|
217
|
+
interactions,
|
|
218
|
+
uiComponentChoices,
|
|
219
|
+
mockApi,
|
|
220
|
+
targets,
|
|
221
|
+
designEvidence,
|
|
222
|
+
dependencyPolicy,
|
|
223
|
+
deliveryFiles: projection.deliveryFiles,
|
|
224
|
+
missingVerificationRequirementIds: projection.missingVerificationRequirementIds,
|
|
225
|
+
unplacedRequirementIds: sorted(unplacedRequirementIds),
|
|
226
|
+
writeSetGaps: projection.writeSetGaps,
|
|
227
|
+
findings: sorted([
|
|
228
|
+
...projection.findings,
|
|
229
|
+
...unplacedRequirementIds.map(id => `unplaced-requirement:${id}`),
|
|
230
|
+
]),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** Fact kinds emitted by the experimental decision Plan tools. Each tool's
|
|
234
|
+
* entry mirrors the corresponding decision-schema array element exactly. */
|
|
235
|
+
export const FRONTEND_DECISION_FACT_KINDS = [
|
|
236
|
+
"module-placement",
|
|
237
|
+
"reuse-decision",
|
|
238
|
+
"state-ownership",
|
|
239
|
+
"data-flow",
|
|
240
|
+
"api-mock-boundary",
|
|
241
|
+
"verification-focus",
|
|
242
|
+
"dependency",
|
|
243
|
+
];
|
|
244
|
+
function isRecord(value) {
|
|
245
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
246
|
+
}
|
|
247
|
+
/** Case/separator-insensitive identity used by `checkUxNameCollisions`. The
|
|
248
|
+
* decision ledger is append-only across retry attempts (a correction re-records
|
|
249
|
+
* a state/flow with `replace` only for id-keyed kinds), so the same concept
|
|
250
|
+
* legitimately appears under "increment counter", "increment-counter", … .
|
|
251
|
+
* Grouping by this identity and letting the LAST committed row win collapses
|
|
252
|
+
* retry churn onto the model's final correction instead of failing the derived
|
|
253
|
+
* canonical with ux-name-collision. */
|
|
254
|
+
function planNameKey(value) {
|
|
255
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
256
|
+
}
|
|
257
|
+
const HTTP_METHODS = new Set([
|
|
258
|
+
"GET",
|
|
259
|
+
"POST",
|
|
260
|
+
"PUT",
|
|
261
|
+
"PATCH",
|
|
262
|
+
"DELETE",
|
|
263
|
+
"HEAD",
|
|
264
|
+
"OPTIONS",
|
|
265
|
+
]);
|
|
266
|
+
const NO_DEPENDENCY_TOKENS = new Set(["none", "无", "无新增", "不新增"]);
|
|
267
|
+
/**
|
|
268
|
+
* Compile the relationship-shaped plan patch from a *finalized* decision
|
|
269
|
+
* contract (the same one `finalizeDecision` derived the safety projection
|
|
270
|
+
* from). This is the decision → relationship bridge: after a successful
|
|
271
|
+
* decision finalize the executor publishes the patch here as a
|
|
272
|
+
* `target-surface` + `finalize_plan` ledger pair so the plan node's R1
|
|
273
|
+
* self-check and every downstream shell see the canonical compile authority
|
|
274
|
+
* they already consume on the relationship path.
|
|
275
|
+
*
|
|
276
|
+
* The pure function is intentionally free of the UI-name → requirement heuristics
|
|
277
|
+
* that plague relationship-model output: every row is bound from the decision's
|
|
278
|
+
* own group structure, so nothing is invented here.
|
|
279
|
+
*/
|
|
280
|
+
export function buildFrontendPlanRelationshipPatch(input) {
|
|
281
|
+
const { decision, authority } = input;
|
|
282
|
+
const groupByRequirement = new Map();
|
|
283
|
+
for (const group of authority.behaviorGroups) {
|
|
284
|
+
for (const id of group.requirementIds)
|
|
285
|
+
groupByRequirement.set(id, group.id);
|
|
286
|
+
}
|
|
287
|
+
const groupById = new Map(authority.behaviorGroups.map(group => [group.id, group]));
|
|
288
|
+
const placementPathsById = new Map(decision.modulePlacements.map(item => [item.id, item.paths]));
|
|
289
|
+
const vtIdsByGroup = new Map();
|
|
290
|
+
for (const focus of decision.verificationFocus) {
|
|
291
|
+
const ids = vtIdsByGroup.get(focus.behaviorGroupId) ?? [];
|
|
292
|
+
if (!ids.includes(focus.id))
|
|
293
|
+
ids.push(focus.id);
|
|
294
|
+
vtIdsByGroup.set(focus.behaviorGroupId, ids);
|
|
295
|
+
}
|
|
296
|
+
const requiredByRequirement = new Map();
|
|
297
|
+
for (const [requirementId, paths] of input.requiredDeliverables ?? []) {
|
|
298
|
+
const set = requiredByRequirement.get(requirementId) ?? new Set();
|
|
299
|
+
for (const file of paths)
|
|
300
|
+
if (file.trim())
|
|
301
|
+
set.add(file.trim());
|
|
302
|
+
requiredByRequirement.set(requirementId, set);
|
|
303
|
+
}
|
|
304
|
+
// ---- Raw state rows (dedupe by case/separator-insensitive name, last row
|
|
305
|
+
// wins). Canonical applicability/attribution is decided below after the
|
|
306
|
+
// component choices' group coverage is known.
|
|
307
|
+
const stateByKey = new Map();
|
|
308
|
+
const stateKeyOrder = [];
|
|
309
|
+
for (const entry of decision.stateOwnership) {
|
|
310
|
+
const key = planNameKey(entry.state);
|
|
311
|
+
if (!key)
|
|
312
|
+
continue;
|
|
313
|
+
if (!stateByKey.has(key))
|
|
314
|
+
stateKeyOrder.push(key);
|
|
315
|
+
stateByKey.set(key, entry);
|
|
316
|
+
}
|
|
317
|
+
// ---- interactions: derived from data flows; dedupe by name identity, last
|
|
318
|
+
// committed flow wins (the model's final correction vocabulary), and bind
|
|
319
|
+
// implementationTargets / verificationTargetIds from the flow source group.
|
|
320
|
+
const flowByKey = new Map();
|
|
321
|
+
const flowKeyOrder = [];
|
|
322
|
+
for (const flow of decision.dataFlows) {
|
|
323
|
+
const key = planNameKey(flow.interaction);
|
|
324
|
+
if (!key)
|
|
325
|
+
continue;
|
|
326
|
+
if (!flowByKey.has(key))
|
|
327
|
+
flowKeyOrder.push(key);
|
|
328
|
+
flowByKey.set(key, flow);
|
|
329
|
+
}
|
|
330
|
+
const interactions = [];
|
|
331
|
+
const interactionNamesByGroup = new Map();
|
|
332
|
+
for (const key of flowKeyOrder) {
|
|
333
|
+
const flow = flowByKey.get(key);
|
|
334
|
+
const groupId = flow.source;
|
|
335
|
+
interactions.push({
|
|
336
|
+
name: flow.interaction,
|
|
337
|
+
trigger: flow.trigger,
|
|
338
|
+
expectedBehavior: flow.expectedBehavior,
|
|
339
|
+
implementationTargets: sorted(placementPathsById.get(groupId) ?? []),
|
|
340
|
+
verificationTargetIds: sorted(vtIdsByGroup.get(groupId) ?? []),
|
|
341
|
+
});
|
|
342
|
+
const names = interactionNamesByGroup.get(groupId) ?? [];
|
|
343
|
+
names.push(flow.interaction);
|
|
344
|
+
interactionNamesByGroup.set(groupId, names);
|
|
345
|
+
}
|
|
346
|
+
// ---- Which behavior groups do the component decisions actually cover?
|
|
347
|
+
// A reuse row covers a group when one of its `covers` entries names the
|
|
348
|
+
// group directly or one of its requirement ids, or when the row's `purpose`
|
|
349
|
+
// is exactly an applicable state / interaction name owned by the group
|
|
350
|
+
// (single-purpose rows like "loading"). Union across every reuse row: a
|
|
351
|
+
// state row below may only stay applicable when its owner group is designed
|
|
352
|
+
// by at least one choice — a finalized decision set that declares an
|
|
353
|
+
// applicable state with no design choice is internally inconsistent, and the
|
|
354
|
+
// derived canonical cannot invent a component for it.
|
|
355
|
+
const rawStateByGroup = new Map();
|
|
356
|
+
for (const key of stateKeyOrder) {
|
|
357
|
+
const entry = stateByKey.get(key);
|
|
358
|
+
const names = rawStateByGroup.get(entry.owner) ?? [];
|
|
359
|
+
if (!names.includes(entry.state))
|
|
360
|
+
names.push(entry.state);
|
|
361
|
+
rawStateByGroup.set(entry.owner, names);
|
|
362
|
+
}
|
|
363
|
+
const designedGroupIds = new Set();
|
|
364
|
+
for (const entry of decision.reuseDecisions) {
|
|
365
|
+
for (const cover of entry.covers ?? []) {
|
|
366
|
+
const groupId = groupByRequirement.get(cover) ??
|
|
367
|
+
(groupById.has(cover) ? cover : undefined);
|
|
368
|
+
if (groupId)
|
|
369
|
+
designedGroupIds.add(groupId);
|
|
370
|
+
}
|
|
371
|
+
for (const [groupId, stateNames] of rawStateByGroup) {
|
|
372
|
+
if (stateNames.includes(entry.purpose))
|
|
373
|
+
designedGroupIds.add(groupId);
|
|
374
|
+
}
|
|
375
|
+
for (const [groupId, interactionNames] of interactionNamesByGroup) {
|
|
376
|
+
if (interactionNames.includes(entry.purpose))
|
|
377
|
+
designedGroupIds.add(groupId);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// ---- uiStates: canonical rows. Dedupe by case/separator-insensitive name
|
|
381
|
+
// (last row wins), bind applicable states to their owner group's placement +
|
|
382
|
+
// verification focus + covering component choice. Non-applicable states keep
|
|
383
|
+
// applicable:false + notApplicableReason. An applicable state the finalized
|
|
384
|
+
// decisions cannot realize is a contradiction inside the decision set, not a
|
|
385
|
+
// compile-time judgment call: silently demoting it to not-applicable (or
|
|
386
|
+
// dropping the row) would rewrite the model's design semantics behind its
|
|
387
|
+
// back, so the compiler fails with a repairable diagnostic instead — the
|
|
388
|
+
// finalize repair loop feeds it straight back to the model.
|
|
389
|
+
const unbindable = (name, groupId, problem) => new Error(`decision-state-unbindable: applicable state "${name}" (owner behavior group "${groupId}") ${problem} — fix the named record_* facts or resubmit the state row (same state, replace:true) with applicable:false plus notApplicableReason`);
|
|
390
|
+
const applicableStateNamesByGroup = new Map();
|
|
391
|
+
const declaredStateNamesByGroup = new Map();
|
|
392
|
+
const uiStates = [];
|
|
393
|
+
for (const key of stateKeyOrder) {
|
|
394
|
+
const entry = stateByKey.get(key);
|
|
395
|
+
const name = entry.state;
|
|
396
|
+
const groupId = entry.owner;
|
|
397
|
+
const declared = declaredStateNamesByGroup.get(groupId) ?? [];
|
|
398
|
+
if (!declared.includes(name))
|
|
399
|
+
declared.push(name);
|
|
400
|
+
declaredStateNamesByGroup.set(groupId, declared);
|
|
401
|
+
const state = {
|
|
402
|
+
name,
|
|
403
|
+
applicable: entry.applicable,
|
|
404
|
+
};
|
|
405
|
+
if (!entry.applicable) {
|
|
406
|
+
state.notApplicableReason =
|
|
407
|
+
entry.notApplicableReason ?? "not applicable by decision";
|
|
408
|
+
uiStates.push(state);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const implementationTargets = sorted(placementPathsById.get(groupId) ?? []);
|
|
412
|
+
const verificationTargetIds = sorted(vtIdsByGroup.get(groupId) ?? []);
|
|
413
|
+
if (implementationTargets.length === 0) {
|
|
414
|
+
throw unbindable(name, groupId, `has no module placement; record record_module_placement {id: "${groupId}", paths: [...]} or place the state under a placed group`);
|
|
415
|
+
}
|
|
416
|
+
if (verificationTargetIds.length === 0) {
|
|
417
|
+
throw unbindable(name, groupId, `has no verification focus; record record_verification_focus {behaviorGroupId: "${groupId}", ...}`);
|
|
418
|
+
}
|
|
419
|
+
if (!designedGroupIds.has(groupId)) {
|
|
420
|
+
throw unbindable(name, groupId, `is not covered by any component decision; record record_reuse_decision with purpose or covers including "${name}"`);
|
|
421
|
+
}
|
|
422
|
+
if (entry.expectedBehavior)
|
|
423
|
+
state.expectedBehavior = entry.expectedBehavior;
|
|
424
|
+
state.implementationTargets = implementationTargets;
|
|
425
|
+
state.verificationTargetIds = verificationTargetIds;
|
|
426
|
+
const names = applicableStateNamesByGroup.get(groupId) ?? [];
|
|
427
|
+
if (!names.includes(name))
|
|
428
|
+
names.push(name);
|
|
429
|
+
applicableStateNamesByGroup.set(groupId, names);
|
|
430
|
+
uiStates.push(state);
|
|
431
|
+
}
|
|
432
|
+
// ---- verificationTargets: one row per committed focus; requirementIds from
|
|
433
|
+
// the focus group's membership; uiStates from that group's applicable
|
|
434
|
+
// state-ownership rows so every behavior target references a declared state
|
|
435
|
+
// (falling back to the group's declared names when a pure-verification group
|
|
436
|
+
// owns no applicable state).
|
|
437
|
+
const verificationTargets = decision.verificationFocus.map(focus => {
|
|
438
|
+
const group = groupById.get(focus.behaviorGroupId);
|
|
439
|
+
const requirementIds = sorted(group?.requirementIds ?? []);
|
|
440
|
+
const uiStateNames = applicableStateNamesByGroup.get(focus.behaviorGroupId) ?? [];
|
|
441
|
+
const referencedStates = uiStateNames.length > 0
|
|
442
|
+
? uiStateNames
|
|
443
|
+
: declaredStateNamesByGroup.get(focus.behaviorGroupId) ?? [];
|
|
444
|
+
return {
|
|
445
|
+
id: focus.id,
|
|
446
|
+
commandId: focus.commandId,
|
|
447
|
+
file: focus.file,
|
|
448
|
+
requirementIds,
|
|
449
|
+
uiStates: sorted(referencedStates),
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
const verificationTargetIds = new Set(verificationTargets.map(target => target.id));
|
|
453
|
+
// ---- requirements: derived group expansion + contract deliverable
|
|
454
|
+
// obligations. Every requirement must keep a placement and at least one
|
|
455
|
+
// verification target (both are guaranteed post-finalize because the
|
|
456
|
+
// decision safety projection blocks unplaced / unverified requirements).
|
|
457
|
+
const requirements = authority.requirements.map(requirement => {
|
|
458
|
+
const groupId = groupByRequirement.get(requirement.id);
|
|
459
|
+
const placed = sorted(placementPathsById.get(groupId ?? "") ?? []);
|
|
460
|
+
const deliverable = sorted(requiredByRequirement.get(requirement.id) ?? []);
|
|
461
|
+
const implementationTargets = sorted([...placed, ...deliverable]);
|
|
462
|
+
const derivedVerificationTargetIds = sorted([...(vtIdsByGroup.get(groupId ?? "") ?? [])].filter(id => verificationTargetIds.has(id)));
|
|
463
|
+
return {
|
|
464
|
+
id: requirement.id,
|
|
465
|
+
expectedOutcome: requirement.expectedOutcome,
|
|
466
|
+
implementationTargets,
|
|
467
|
+
deliverableTargets: deliverable,
|
|
468
|
+
verificationTargetIds: derivedVerificationTargetIds,
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
// ---- uiComponentChoices: reuse/new decisions keep their component identity;
|
|
472
|
+
// covers are expanded with the applicable state and interaction names of the
|
|
473
|
+
// behavior groups the choice already covers by requirement id (or group id).
|
|
474
|
+
// We never invent a component for an uncovered name.
|
|
475
|
+
const uiComponentChoices = decision.reuseDecisions.map(entry => {
|
|
476
|
+
const coveredGroupIds = new Set();
|
|
477
|
+
for (const cover of entry.covers ?? []) {
|
|
478
|
+
const groupId = groupByRequirement.get(cover) ??
|
|
479
|
+
(groupById.has(cover) ? cover : undefined);
|
|
480
|
+
if (groupId)
|
|
481
|
+
coveredGroupIds.add(groupId);
|
|
482
|
+
}
|
|
483
|
+
const expanded = sorted([...coveredGroupIds].flatMap(groupId => [
|
|
484
|
+
...(applicableStateNamesByGroup.get(groupId) ?? []),
|
|
485
|
+
...(interactionNamesByGroup.get(groupId) ?? []),
|
|
486
|
+
]));
|
|
487
|
+
// Explicit state/interaction names the model listed stay; requirement /
|
|
488
|
+
// group ids in covers were the expansion source and are replaced by the
|
|
489
|
+
// derived names (covers is the choice→UX-name map the policy reads).
|
|
490
|
+
const explicitNames = (entry.covers ?? []).filter(cover => !groupByRequirement.has(cover) && !groupById.has(cover));
|
|
491
|
+
const choice = {
|
|
492
|
+
purpose: entry.purpose,
|
|
493
|
+
component: entry.symbol,
|
|
494
|
+
decision: entry.decision,
|
|
495
|
+
rationale: entry.rationale ?? "",
|
|
496
|
+
};
|
|
497
|
+
if (entry.decision === "reuse-existing") {
|
|
498
|
+
if (entry.evidence.length > 0)
|
|
499
|
+
choice.evidencePath = entry.evidence[0];
|
|
500
|
+
choice.specReference = null;
|
|
501
|
+
}
|
|
502
|
+
else if (entry.evidence.length > 0 && entry.specSection) {
|
|
503
|
+
// The canonical contract schema mandates specReference.section for
|
|
504
|
+
// specified/new choices; the tool gate requires specSection up front,
|
|
505
|
+
// so a row reaching here without one stays specReference-less and the
|
|
506
|
+
// contract superRefine reports it against the decision channel.
|
|
507
|
+
choice.specReference = { path: entry.evidence[0], section: entry.specSection };
|
|
508
|
+
}
|
|
509
|
+
const covers = sorted([...expanded, ...explicitNames]);
|
|
510
|
+
if (covers.length > 0)
|
|
511
|
+
choice.covers = covers;
|
|
512
|
+
return choice;
|
|
513
|
+
});
|
|
514
|
+
// ---- stylingStrategy: task-wide style contract. The relationship channel
|
|
515
|
+
// sets it once on any component choice; the decision channel mirrors that.
|
|
516
|
+
// Rows naming different strategies contradict each other, and silently
|
|
517
|
+
// keeping one would drop the other behind the model's back, so the compiler
|
|
518
|
+
// reports the conflict for repair instead of picking a winner.
|
|
519
|
+
const strategyRows = decision.reuseDecisions
|
|
520
|
+
.filter(entry => typeof entry.stylingStrategy === "string" && entry.stylingStrategy.trim().length > 0)
|
|
521
|
+
.map(entry => ({ purpose: entry.purpose, strategy: entry.stylingStrategy.trim() }));
|
|
522
|
+
const strategiesByValue = new Map();
|
|
523
|
+
for (const row of strategyRows) {
|
|
524
|
+
const purposes = strategiesByValue.get(row.strategy) ?? [];
|
|
525
|
+
if (!purposes.includes(row.purpose))
|
|
526
|
+
purposes.push(row.purpose);
|
|
527
|
+
strategiesByValue.set(row.strategy, purposes);
|
|
528
|
+
}
|
|
529
|
+
if (strategiesByValue.size > 1) {
|
|
530
|
+
const breakdown = [...strategiesByValue.entries()]
|
|
531
|
+
.map(([strategy, purposes]) => `${JSON.stringify(strategy)} (on ${purposes.join(", ")})`)
|
|
532
|
+
.join(" vs ");
|
|
533
|
+
throw new Error(`decision-styling-strategy-conflict: reuse decisions declare different stylingStrategy values: ${breakdown} — the task-wide style contract must be one statement; resubmit the losing rows (same purpose, replace:true) without stylingStrategy`);
|
|
534
|
+
}
|
|
535
|
+
const stylingStrategy = strategyRows[0]?.strategy;
|
|
536
|
+
// ---- mockApi: never fabricate endpoints from prose boundaries. All
|
|
537
|
+
// not-needed boundaries → not-needed. A real/mock boundary becomes an
|
|
538
|
+
// endpoint only when the boundary parses as "<METHOD> /path" with a frozen
|
|
539
|
+
// method; fixture/consumer are bound to derived implementation paths.
|
|
540
|
+
// ---- mock boundaries: dedupe by trimmed boundary identity (last row wins)
|
|
541
|
+
// so a mock→real correction replaces the superseded decision instead of
|
|
542
|
+
// leaving its stale fixture/mode in play alongside the correction.
|
|
543
|
+
const boundaryByKey = new Map();
|
|
544
|
+
const boundaryOrder = [];
|
|
545
|
+
for (const boundary of decision.apiMockBoundaries) {
|
|
546
|
+
const key = boundary.boundary.trim();
|
|
547
|
+
if (!key)
|
|
548
|
+
continue;
|
|
549
|
+
if (!boundaryByKey.has(key))
|
|
550
|
+
boundaryOrder.push(key);
|
|
551
|
+
boundaryByKey.set(key, boundary);
|
|
552
|
+
}
|
|
553
|
+
const boundaries = boundaryOrder.map(key => boundaryByKey.get(key));
|
|
554
|
+
const allNotNeeded = boundaries.length > 0 && boundaries.every(boundary => boundary.mode === "not-needed");
|
|
555
|
+
const mockStrategy = allNotNeeded
|
|
556
|
+
? "not-needed"
|
|
557
|
+
: boundaries.some(boundary => boundary.mode === "mock")
|
|
558
|
+
? "browser-intercept"
|
|
559
|
+
: boundaries.some(boundary => boundary.mode === "real")
|
|
560
|
+
? "native"
|
|
561
|
+
: "not-needed";
|
|
562
|
+
const endpoints = [];
|
|
563
|
+
if (!allNotNeeded && mockStrategy !== "not-needed") {
|
|
564
|
+
for (const boundary of boundaries) {
|
|
565
|
+
if (boundary.mode === "not-needed")
|
|
566
|
+
continue;
|
|
567
|
+
const match = /^\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/\S.*?)\s*$/.exec(boundary.boundary);
|
|
568
|
+
if (!match)
|
|
569
|
+
continue;
|
|
570
|
+
const method = match[1];
|
|
571
|
+
if (!HTTP_METHODS.has(method))
|
|
572
|
+
continue;
|
|
573
|
+
const pathValue = match[2].trim();
|
|
574
|
+
if (!pathValue.startsWith("/"))
|
|
575
|
+
continue;
|
|
576
|
+
// Design policy hard-requires fixture + consumer on every endpoint. The
|
|
577
|
+
// decision channel is their only honest source: fabricating a default
|
|
578
|
+
// fixture path or the first placement as consumer would point the mock
|
|
579
|
+
// evidence chain at files the model never planned, so an endpoint whose
|
|
580
|
+
// decision lacks them fails the compile with a named repair instead.
|
|
581
|
+
const missing = [!boundary.fixture?.trim() && "fixture", !boundary.consumer?.trim() && "consumer"].filter(Boolean).join(" and ");
|
|
582
|
+
if (missing) {
|
|
583
|
+
throw new Error(`decision-mock-endpoint-unbindable: ${boundary.mode} boundary "${boundary.boundary}" has no ${missing}; record record_api_mock_boundary with the same boundary plus fixture (the test fixture path) and consumer (the implementation file that calls it), or mark the boundary not-needed`);
|
|
584
|
+
}
|
|
585
|
+
endpoints.push({
|
|
586
|
+
method,
|
|
587
|
+
path: pathValue,
|
|
588
|
+
fixture: boundary.fixture,
|
|
589
|
+
consumer: boundary.consumer,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const mockApi = {
|
|
594
|
+
strategy: mockStrategy,
|
|
595
|
+
activation: mockStrategy === "not-needed"
|
|
596
|
+
? "n/a; no backend or remote request path"
|
|
597
|
+
: "explicit activation boundary",
|
|
598
|
+
endpoints,
|
|
599
|
+
};
|
|
600
|
+
// ---- dependencyPolicy: explicit declarative no-new-dependencies default
|
|
601
|
+
// when the model recorded no real dependency names ("none"/"无" tokens are
|
|
602
|
+
// not package names).
|
|
603
|
+
const dependencies = decision.dependencies.filter(name => !NO_DEPENDENCY_TOKENS.has(name.trim().toLowerCase()) && name.trim() !== "");
|
|
604
|
+
const dependencyPolicy = dependencies.length > 0 ? dependencies.join(", ") : "零新增,不新增 npm 依赖";
|
|
605
|
+
return {
|
|
606
|
+
requirements,
|
|
607
|
+
uiStates,
|
|
608
|
+
interactions,
|
|
609
|
+
verificationTargets,
|
|
610
|
+
uiComponentChoices,
|
|
611
|
+
mockApi,
|
|
612
|
+
dependencyPolicy,
|
|
613
|
+
...(stylingStrategy ? { stylingStrategy } : {}),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
const repairAssertionSchema = z.discriminatedUnion("check", [
|
|
617
|
+
z.object({ check: z.literal("file-absent"), token: z.string().min(1), source: z.string() }).strict(),
|
|
618
|
+
z.object({ check: z.literal("state-present"), token: z.string().min(1), source: z.string() }).strict(),
|
|
619
|
+
z.object({ check: z.literal("vt-changed"), token: z.string().min(1), source: z.string(),
|
|
620
|
+
expectedRequirementIds: z.array(z.string().min(1)).min(1) }).strict(),
|
|
621
|
+
]);
|
|
622
|
+
/** No heuristic fallback: absent structured expectations mean that semantic
|
|
623
|
+
* closure must be decided by the subsequent design review. Invalid explicit
|
|
624
|
+
* assertions throw rather than silently disabling a requested check. */
|
|
625
|
+
export function extractRepairAssertions(findings, _parentCanonical) {
|
|
626
|
+
return findings.flatMap(finding => z.array(repairAssertionSchema).parse(finding.repairAssertions ?? []));
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Fields that annotate a decision but cannot change what the plan decides:
|
|
630
|
+
* justification prose, the human-readable purpose, the non-applicability
|
|
631
|
+
* explanation, and the source citation. They are excluded from the recovery
|
|
632
|
+
* decision identity.
|
|
633
|
+
*
|
|
634
|
+
* Smoke r34: the identity guard (rationale-only at the time) blocked a
|
|
635
|
+
* no-op repair twice, and the child then cleared it by rewriting only
|
|
636
|
+
* `uiComponentChoices[].specReference` — the citation for a choice whose
|
|
637
|
+
* ownership mapping had not changed at all. A repair must move a decision the
|
|
638
|
+
* machine reads (targets, implementation mappings, states, coverage, plan
|
|
639
|
+
* requirements, verification targets), never just the words around it.
|
|
640
|
+
*/
|
|
641
|
+
const DECISION_FREE_TEXT_KEYS = new Set([
|
|
642
|
+
"rationale",
|
|
643
|
+
"purpose",
|
|
644
|
+
"notApplicableReason",
|
|
645
|
+
"specReference",
|
|
646
|
+
]);
|
|
647
|
+
export function stripDecisionFreeText(value) {
|
|
648
|
+
if (Array.isArray(value))
|
|
649
|
+
return value.map(stripDecisionFreeText);
|
|
650
|
+
if (value && typeof value === "object") {
|
|
651
|
+
const source = value;
|
|
652
|
+
const out = {};
|
|
653
|
+
for (const key of Object.keys(source)) {
|
|
654
|
+
if (DECISION_FREE_TEXT_KEYS.has(key))
|
|
655
|
+
continue;
|
|
656
|
+
out[key] = stripDecisionFreeText(source[key]);
|
|
657
|
+
}
|
|
658
|
+
return out;
|
|
659
|
+
}
|
|
660
|
+
return value;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Identity of a compiled plan's decisions, ignoring justification prose.
|
|
664
|
+
*
|
|
665
|
+
* The recovery identical-plan guard used a raw content hash, so smoke r27
|
|
666
|
+
* showed a repair child clearing `DECISION_REPAIR_NO_CHANGE` by rewriting two
|
|
667
|
+
* `rationale` strings while every structured decision row — targets,
|
|
668
|
+
* implementation mappings, states, verification targets — stayed identical.
|
|
669
|
+
* The guard compares this projection instead, so "no decision changed" is a
|
|
670
|
+
* blocked repair regardless of prose edits. Semantics beyond that remain the
|
|
671
|
+
* design review's judgment.
|
|
672
|
+
*/
|
|
673
|
+
export function decisionIdentitySha256(canonical) {
|
|
674
|
+
return deterministicSha256(stripDecisionFreeText(canonical));
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Evaluate the repair closure: every assertion must hold on the child
|
|
678
|
+
* canonical. `unmet` names exactly what the model still has to change, so a
|
|
679
|
+
* failed closure is a repairable finalize rejection, never a silent pass.
|
|
680
|
+
*/
|
|
681
|
+
export function evaluateRepairClosure(assertions, childCanonical, parentCanonical) {
|
|
682
|
+
const childFiles = (childCanonical.targets?.files ?? []).map((f) => String(f));
|
|
683
|
+
const childStates = new Set((childCanonical.uiStates ?? [])
|
|
684
|
+
.filter((state) => state.applicable !== false)
|
|
685
|
+
.map((state) => String(state.name ?? "")));
|
|
686
|
+
const childVtById = new Map((childCanonical.verificationTargets ?? []).map((target) => [
|
|
687
|
+
String(target.id ?? ""),
|
|
688
|
+
target,
|
|
689
|
+
]));
|
|
690
|
+
const parentVtById = new Map((parentCanonical.verificationTargets ?? []).map((target) => [
|
|
691
|
+
String(target.id ?? ""),
|
|
692
|
+
target,
|
|
693
|
+
]));
|
|
694
|
+
const unmet = [];
|
|
695
|
+
for (const assertion of assertions) {
|
|
696
|
+
if (assertion.check === "file-absent") {
|
|
697
|
+
if (childFiles.some((file) => file === assertion.token)) {
|
|
698
|
+
unmet.push(`file-absent: "${assertion.token}" is still present in the child canonical (targets.files/implementationTargets)`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
else if (assertion.check === "state-present") {
|
|
702
|
+
if (!childStates.has(assertion.token)) {
|
|
703
|
+
unmet.push(`state-present: applicable UI state "${assertion.token}" is still missing from the child canonical`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else if (assertion.check === "vt-changed") {
|
|
707
|
+
const child = childVtById.get(assertion.token);
|
|
708
|
+
const parent = parentVtById.get(assertion.token);
|
|
709
|
+
if (!child || !parent) {
|
|
710
|
+
unmet.push(`vt-changed: VT "${assertion.token}" is missing from the child canonical`);
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
const expected = JSON.stringify([...new Set(assertion.expectedRequirementIds)].sort());
|
|
714
|
+
const actualIds = Array.isArray(child.requirementIds) ? child.requirementIds.map(String) : [];
|
|
715
|
+
const actual = JSON.stringify([...new Set(actualIds)].sort());
|
|
716
|
+
if (actual !== expected)
|
|
717
|
+
unmet.push(`vt-changed: VT "${assertion.token}" does not map to the required requirementIds`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return { closed: unmet.length === 0, unmet };
|
|
722
|
+
}
|
|
723
|
+
/** Last-wins collapse of committed decision entries by their natural
|
|
724
|
+
* identity (id/purpose/state/interaction/boundary). Shared by the ledger
|
|
725
|
+
* compile and the recovery snapshot builder so both see exactly the same
|
|
726
|
+
* surviving rows. Keyless rows are kept: schema validation still rejects
|
|
727
|
+
* malformed entries instead of silently dropping them. */
|
|
728
|
+
export function collapseDecisionEntriesByKind(kind, collected) {
|
|
729
|
+
const keyOf = (entry) => {
|
|
730
|
+
const key = kind === "module-placement" || kind === "verification-focus"
|
|
731
|
+
? entry.id
|
|
732
|
+
: kind === "reuse-decision"
|
|
733
|
+
? entry.purpose
|
|
734
|
+
: kind === "state-ownership"
|
|
735
|
+
? entry.state
|
|
736
|
+
: kind === "data-flow"
|
|
737
|
+
? entry.interaction
|
|
738
|
+
: kind === "api-mock-boundary"
|
|
739
|
+
? typeof entry.boundary === "string" ? entry.boundary.trim() : undefined
|
|
740
|
+
: undefined;
|
|
741
|
+
return typeof key === "string" && key ? key : undefined;
|
|
742
|
+
};
|
|
743
|
+
const byKey = new Map();
|
|
744
|
+
const keyless = [];
|
|
745
|
+
// Retracted tombstones remove their own key and every other record keyed to
|
|
746
|
+
// the same interaction — a wrong-named record cannot survive a rename (r19).
|
|
747
|
+
const retractedKeys = new Set();
|
|
748
|
+
for (const entry of collected) {
|
|
749
|
+
if (entry.retracted === true && typeof entry.interaction === "string") {
|
|
750
|
+
retractedKeys.add(entry.interaction);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
for (const entry of collected) {
|
|
754
|
+
if (entry.retracted === true)
|
|
755
|
+
continue;
|
|
756
|
+
const key = keyOf(entry);
|
|
757
|
+
if (kind === "data-flow" && typeof entry.interaction === "string" && retractedKeys.has(entry.interaction))
|
|
758
|
+
continue;
|
|
759
|
+
if (key)
|
|
760
|
+
byKey.set(key, entry);
|
|
761
|
+
else
|
|
762
|
+
keyless.push(entry);
|
|
763
|
+
}
|
|
764
|
+
return byKey.size > 0 ? [...byKey.values(), ...keyless] : [...collected];
|
|
765
|
+
}
|
|
766
|
+
/** Compile committed decision facts into a FrontendPlanDecisionContract. Each
|
|
767
|
+
* fact's `entry` is already schema-shaped (the tool validates it); this collects
|
|
768
|
+
* entries by kind into the decision arrays and re-validates the whole. This is
|
|
769
|
+
* the compile authority for the experimental decision Plan path and is the
|
|
770
|
+
* runtime counterpart to the model emitting decision facts instead of the
|
|
771
|
+
* per-AC relationship table. */
|
|
772
|
+
export function assembleDecisionContractFromFacts(facts) {
|
|
773
|
+
const committed = facts.filter(fact => fact.origin === undefined || fact.origin === "plan");
|
|
774
|
+
// Durable resubmission with replace:true appends a *new* ledger record with the
|
|
775
|
+
// same natural identity instead of physically deleting the superseded one
|
|
776
|
+
// (mirroring the relationship ledger, where the compiler resolves replaces).
|
|
777
|
+
// Without a last-wins collapse here, a correction that re-records the same VT
|
|
778
|
+
// id compiles into duplicate entries and every subsequent finalize fails with
|
|
779
|
+
// duplicate-verification even though the model did exactly what replace told
|
|
780
|
+
// it to. Identity is per kind: module placement and verification focus are
|
|
781
|
+
// keyed by entry.id; the committed array is already in ledger (revision)
|
|
782
|
+
// order, so the last record with a given key wins.
|
|
783
|
+
const committedEntries = (kind) => {
|
|
784
|
+
const collected = committed
|
|
785
|
+
.filter(fact => fact.kind === kind && isRecord(fact.entry))
|
|
786
|
+
.map(fact => fact.entry);
|
|
787
|
+
return collapseDecisionEntriesByKind(kind, collected);
|
|
788
|
+
};
|
|
789
|
+
const entries = committedEntries;
|
|
790
|
+
return frontendPlanDecisionContractSchema.parse({
|
|
791
|
+
schemaId: "frontend-plan-decision-contract-v1",
|
|
792
|
+
modulePlacements: entries("module-placement"),
|
|
793
|
+
reuseDecisions: entries("reuse-decision"),
|
|
794
|
+
stateOwnership: entries("state-ownership"),
|
|
795
|
+
dataFlows: entries("data-flow"),
|
|
796
|
+
apiMockBoundaries: entries("api-mock-boundary"),
|
|
797
|
+
dependencies: entries("dependency").flatMap(entry => {
|
|
798
|
+
const names = Array.isArray(entry.names) ? entry.names : [entry.name];
|
|
799
|
+
return names.filter((name) => typeof name === "string" && name.length > 0);
|
|
800
|
+
}),
|
|
801
|
+
verificationFocus: entries("verification-focus"),
|
|
802
|
+
});
|
|
803
|
+
}
|