@tea-agent/loop-agent 0.31.1 → 0.32.1
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 +46 -0
- package/README.md +1 -1
- package/dist/shared/operator/capabilities.js +22 -1
- package/dist/shared/operator/command-lifecycle.js +94 -0
- package/dist/shared/operator/index.js +1 -0
- package/dist/worker/cli.js +10 -24
- package/dist/worker/console/doctor.js +11 -4
- package/dist/worker/console/observe-health-match.js +18 -15
- package/dist/worker/console/operator-actions.js +2 -1
- package/dist/worker/console/static/assets/index-Bpa2qrc-.js +29 -0
- package/dist/worker/console/static/assets/index-BqfFDdnG.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +140 -0
- package/dist/worker/console/static-src/app/useConsoleShell.js +101 -0
- package/dist/worker/console/static-src/app/useOperatorActions.js +304 -0
- package/dist/worker/console/static-src/app/usePrdImport.js +171 -0
- package/dist/worker/console/static-src/app/useRecoveryActions.js +257 -0
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +334 -0
- package/dist/worker/console/static-src/app/useTaskWizard.js +229 -0
- package/dist/worker/console/static-src/chat-view-types.js +2 -0
- package/dist/worker/console/static-src/night/night-types.js +24 -0
- package/dist/worker/console/static-src/night/useNightBoard.js +125 -0
- package/dist/worker/console/static-src/night/useNightWizard.js +171 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +253 -0
- package/dist/worker/console/static-src/operator-chat/format.js +71 -0
- package/dist/worker/console/static-src/operator-chat/refs.js +47 -0
- package/dist/worker/console/static-src/operator-chat/tools-catalog.js +77 -0
- package/dist/worker/console/static-src/operator-chat/types.js +1 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +320 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +209 -0
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +218 -0
- package/dist/worker/console/static-src/operator-chat/useComposer.js +125 -0
- package/dist/worker/console/static-src/operator-chat/useInterview.js +108 -0
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +123 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +207 -0
- package/dist/worker/feature/acceptance-policy.js +227 -0
- package/dist/worker/feature/decision-loader.js +1 -1
- package/dist/worker/feature/next-action.js +56 -6
- package/dist/worker/feature/profile-schema.js +3 -0
- package/dist/worker/feature/reducer.js +1 -0
- package/dist/worker/feature/review.js +72 -8
- package/dist/worker/feature/scaffold.js +14 -0
- package/dist/worker/loop-agent/controller-protocol.js +143 -0
- package/dist/worker/materialize/harness-task-lifecycle-probe.js +126 -0
- package/dist/worker/materialize/harness-task-lineage.js +220 -0
- package/dist/worker/materialize/harness-task-materializer.js +350 -80
- package/dist/worker/observability/progress-composite.js +1 -0
- package/dist/worker/observability/read-model.js +66 -19
- package/dist/worker/pool/attempt-identity.js +41 -0
- package/dist/worker/pool/attempt-lease.js +184 -0
- package/dist/worker/pool/attempt-transition.js +210 -0
- package/dist/worker/pool/begin-attempt-with-lease.js +26 -0
- package/dist/worker/pool/begin-attempt.js +35 -0
- package/dist/worker/pool/failure-routing.js +49 -0
- package/dist/worker/pool/recovery-decision.js +163 -0
- package/dist/worker/pool/run-owner-store.js +126 -0
- package/dist/worker/pool/run-store.js +32 -46
- package/dist/worker/pool/runtime-reconcile-inventory.js +127 -0
- package/dist/worker/pool/state-projection.js +57 -0
- package/dist/worker/run-task/run-task.js +31 -4
- package/dist/worker/runner/run-ready.js +64 -14
- package/dist/worker/runner/single-task-attempt.js +42 -13
- package/dist/worker/task-graph/acceptance-schema.js +3 -0
- package/docs/README.md +3 -1
- package/docs/architecture/evolution.md +1 -1
- package/docs/operations/README.md +1 -0
- package/docs/templates/README.md +1 -0
- package/docs/templates/agent-worker-production-readiness-checklist.md +47 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
- package/docs/templates/evaluation/agents-map-verbose-v0.md +1 -1
- package/docs/templates/init-managed-agents.md +1 -1
- package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +11 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +11 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +11 -0
- package/package.json +5 -4
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +1 -1
- package/skills/loop-agent/references/command-reference.md +2 -2
- package/skills/loop-agent/references/harness-policy.md +1 -1
- package/dist/worker/console/static/assets/index-D9gnJn_l.js +0 -29
- package/dist/worker/console/static/assets/index-rajoXwkM.css +0 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { formatModelRef, mergeSessionRuntime, runtimeSelectionFromRecord, sessionRuntimeMatches, } from "../../chat/runtime-selection.js";
|
|
3
|
+
import { confirmationToken } from "./format.js";
|
|
4
|
+
import { buildToolCatalog } from "./tools-catalog.js";
|
|
5
|
+
/** Capabilities catalog, model / thinking-level switcher, runtime-context
|
|
6
|
+
* popover and the outside-click dismissal for both transient menus. */
|
|
7
|
+
export function useRuntimeControls(params) {
|
|
8
|
+
const { origin, session, setSession, refs, setError } = params;
|
|
9
|
+
const [capabilities, setCapabilities] = useState(null);
|
|
10
|
+
const [models, setModels] = useState([]);
|
|
11
|
+
const [thinkingLevels, setThinkingLevels] = useState([]);
|
|
12
|
+
const [runtimeContext, setRuntimeContext] = useState(null);
|
|
13
|
+
const [toolsOpen, setToolsOpen] = useState(false);
|
|
14
|
+
const [runtimeContextOpen, setRuntimeContextOpen] = useState(false);
|
|
15
|
+
const toolsMenuRef = useRef(null);
|
|
16
|
+
const runtimeContextMenuRef = useRef(null);
|
|
17
|
+
const loadCapabilities = useCallback(async () => {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/capabilities`);
|
|
20
|
+
if (!res.ok)
|
|
21
|
+
return;
|
|
22
|
+
const body = (await res.json());
|
|
23
|
+
setCapabilities(body);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// capabilities optional; Chat still works without them
|
|
27
|
+
}
|
|
28
|
+
}, [origin]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
void loadCapabilities();
|
|
31
|
+
}, [loadCapabilities]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!session) {
|
|
34
|
+
setModels([]);
|
|
35
|
+
setRuntimeContext(null);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const sessionId = session.sessionId;
|
|
39
|
+
const base = `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`;
|
|
40
|
+
void fetch(`${base}/models`, { credentials: "include" })
|
|
41
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
42
|
+
.then((body) => {
|
|
43
|
+
// Guard against a stale response landing after the user switched/
|
|
44
|
+
// created/forked a session: only adopt models for the session we
|
|
45
|
+
// actually requested them for.
|
|
46
|
+
if (!body || refs.sessionRef.current?.sessionId !== sessionId)
|
|
47
|
+
return;
|
|
48
|
+
setModels(body.models ?? []);
|
|
49
|
+
setThinkingLevels(body.thinkingLevels ?? []);
|
|
50
|
+
});
|
|
51
|
+
void fetch(`${base}/runtime-context`, { credentials: "include" })
|
|
52
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
53
|
+
.then((body) => {
|
|
54
|
+
// Stale-response guard: a slow runtime-context reply for session A
|
|
55
|
+
// must not overwrite the context/session state of the now-current
|
|
56
|
+
// session B.
|
|
57
|
+
if (!body?.context || refs.sessionRef.current?.sessionId !== sessionId)
|
|
58
|
+
return;
|
|
59
|
+
setRuntimeContext(body.context);
|
|
60
|
+
const ctx = body.context;
|
|
61
|
+
const patch = {
|
|
62
|
+
model: ctx.model,
|
|
63
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
64
|
+
};
|
|
65
|
+
setSession((current) => {
|
|
66
|
+
if (!current || current.sessionId !== sessionId)
|
|
67
|
+
return current;
|
|
68
|
+
if (sessionRuntimeMatches(current, patch))
|
|
69
|
+
return current;
|
|
70
|
+
const merged = mergeSessionRuntime(current, patch);
|
|
71
|
+
refs.sessionRef.current = merged;
|
|
72
|
+
return merged;
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}, [origin, session?.sessionId, refs, setSession]);
|
|
76
|
+
const switchRuntime = useCallback(async (body) => {
|
|
77
|
+
if (!session)
|
|
78
|
+
return;
|
|
79
|
+
// Capture the target session so a slow model/thinking response cannot
|
|
80
|
+
// write its result into a different (newly created/forked/switched)
|
|
81
|
+
// session the user has since moved to.
|
|
82
|
+
const targetSessionId = session.sessionId;
|
|
83
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(session.sessionId)}/model`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
credentials: "include",
|
|
86
|
+
headers: {
|
|
87
|
+
"content-type": "application/json",
|
|
88
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify(body),
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
// Only surface the failure for the session that still owns it.
|
|
94
|
+
if (refs.sessionRef.current?.sessionId === targetSessionId)
|
|
95
|
+
setError("模型或思考等级切换失败");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const payload = (await res.json().catch(() => ({})));
|
|
99
|
+
if (!payload.session)
|
|
100
|
+
return;
|
|
101
|
+
// Drop the result entirely if the active session changed mid-flight.
|
|
102
|
+
if (refs.sessionRef.current?.sessionId !== targetSessionId)
|
|
103
|
+
return;
|
|
104
|
+
setSession((current) => {
|
|
105
|
+
if (!current || current.sessionId !== targetSessionId)
|
|
106
|
+
return current;
|
|
107
|
+
const patch = runtimeSelectionFromRecord(payload.session);
|
|
108
|
+
if (sessionRuntimeMatches(current, patch))
|
|
109
|
+
return current;
|
|
110
|
+
const merged = mergeSessionRuntime(current, patch);
|
|
111
|
+
refs.sessionRef.current = merged;
|
|
112
|
+
return merged;
|
|
113
|
+
});
|
|
114
|
+
setRuntimeContext((current) => current
|
|
115
|
+
? {
|
|
116
|
+
...current,
|
|
117
|
+
...(payload.session.modelProvider && payload.session.modelId
|
|
118
|
+
? {
|
|
119
|
+
model: {
|
|
120
|
+
provider: payload.session.modelProvider,
|
|
121
|
+
modelId: payload.session.modelId,
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
: {}),
|
|
125
|
+
...(payload.session.thinkingLevel
|
|
126
|
+
? { thinkingLevel: payload.session.thinkingLevel }
|
|
127
|
+
: body.thinkingLevel
|
|
128
|
+
? { thinkingLevel: body.thinkingLevel }
|
|
129
|
+
: {}),
|
|
130
|
+
}
|
|
131
|
+
: current);
|
|
132
|
+
}, [origin, session, refs, setSession, setError]);
|
|
133
|
+
// Close tools catalog on outside click / Escape.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!toolsOpen)
|
|
136
|
+
return;
|
|
137
|
+
const onPointer = (e) => {
|
|
138
|
+
const root = toolsMenuRef.current;
|
|
139
|
+
if (!root)
|
|
140
|
+
return;
|
|
141
|
+
if (e.target instanceof Node && !root.contains(e.target)) {
|
|
142
|
+
setToolsOpen(false);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const onKey = (e) => {
|
|
146
|
+
if (e.key === "Escape")
|
|
147
|
+
setToolsOpen(false);
|
|
148
|
+
};
|
|
149
|
+
document.addEventListener("mousedown", onPointer);
|
|
150
|
+
document.addEventListener("keydown", onKey);
|
|
151
|
+
return () => {
|
|
152
|
+
document.removeEventListener("mousedown", onPointer);
|
|
153
|
+
document.removeEventListener("keydown", onKey);
|
|
154
|
+
};
|
|
155
|
+
}, [toolsOpen]);
|
|
156
|
+
// Close runtime-context popover on outside click / Escape.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!runtimeContextOpen)
|
|
159
|
+
return;
|
|
160
|
+
const onPointer = (e) => {
|
|
161
|
+
const root = runtimeContextMenuRef.current;
|
|
162
|
+
if (!root)
|
|
163
|
+
return;
|
|
164
|
+
if (e.target instanceof Node && !root.contains(e.target)) {
|
|
165
|
+
setRuntimeContextOpen(false);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const onKey = (e) => {
|
|
169
|
+
if (e.key === "Escape")
|
|
170
|
+
setRuntimeContextOpen(false);
|
|
171
|
+
};
|
|
172
|
+
document.addEventListener("mousedown", onPointer);
|
|
173
|
+
document.addEventListener("keydown", onKey);
|
|
174
|
+
return () => {
|
|
175
|
+
document.removeEventListener("mousedown", onPointer);
|
|
176
|
+
document.removeEventListener("keydown", onKey);
|
|
177
|
+
};
|
|
178
|
+
}, [runtimeContextOpen]);
|
|
179
|
+
const toolCatalog = useMemo(() => buildToolCatalog(capabilities), [
|
|
180
|
+
capabilities,
|
|
181
|
+
]);
|
|
182
|
+
const activeModel = session?.model ?? runtimeContext?.model;
|
|
183
|
+
const activeModelRef = activeModel
|
|
184
|
+
? formatModelRef(activeModel.provider, activeModel.modelId)
|
|
185
|
+
: "";
|
|
186
|
+
const activeThinkingLevel = session?.thinkingLevel ?? runtimeContext?.thinkingLevel ?? "";
|
|
187
|
+
const activeModelListed = !activeModelRef ||
|
|
188
|
+
models.some((model) => formatModelRef(model.provider, model.id) === activeModelRef);
|
|
189
|
+
return {
|
|
190
|
+
capabilities,
|
|
191
|
+
models,
|
|
192
|
+
thinkingLevels,
|
|
193
|
+
runtimeContext,
|
|
194
|
+
toolsOpen,
|
|
195
|
+
setToolsOpen,
|
|
196
|
+
runtimeContextOpen,
|
|
197
|
+
setRuntimeContextOpen,
|
|
198
|
+
toolsMenuRef,
|
|
199
|
+
runtimeContextMenuRef,
|
|
200
|
+
switchRuntime,
|
|
201
|
+
toolCatalog,
|
|
202
|
+
activeModel,
|
|
203
|
+
activeModelRef,
|
|
204
|
+
activeThinkingLevel,
|
|
205
|
+
activeModelListed,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* ADR 0007: only `agent-worker feature verify-final` is the canonical writer for
|
|
4
|
+
* Feature Verification Bundle, QA pass evidence, and Final Verification Record.
|
|
5
|
+
* Task Pool Done, DAG verify-shell receipts, and packet FINAL-VERIFY-* smoke
|
|
6
|
+
* nodes are inputs only — they must never be treated as coverage writers.
|
|
7
|
+
*/
|
|
8
|
+
export const CANONICAL_FINAL_VERIFICATION_AUTHORITY = "feature-verify-final";
|
|
9
|
+
export const DEFAULT_VERIFY_FINAL_COMMAND = "agent-worker feature verify-final";
|
|
10
|
+
const deliverySequenceSchema = z.array(z.enum(["verify-final", "delivery", "closeout"]));
|
|
11
|
+
export const acceptancePolicySchema = z
|
|
12
|
+
.object({
|
|
13
|
+
schema_version: z.literal(1),
|
|
14
|
+
authority: z.string().min(1).optional(),
|
|
15
|
+
independent_qa_required: z.boolean().optional(),
|
|
16
|
+
coverage_writer: z.string().min(1).optional(),
|
|
17
|
+
final_verification: z
|
|
18
|
+
.object({
|
|
19
|
+
command: z.string().min(1),
|
|
20
|
+
})
|
|
21
|
+
.strict()
|
|
22
|
+
.optional(),
|
|
23
|
+
delivery_sequence: deliverySequenceSchema.optional(),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
const DEFAULT_POLICY = {
|
|
27
|
+
schema_version: 1,
|
|
28
|
+
authority: CANONICAL_FINAL_VERIFICATION_AUTHORITY,
|
|
29
|
+
independent_qa_required: true,
|
|
30
|
+
coverage_writer: CANONICAL_FINAL_VERIFICATION_AUTHORITY,
|
|
31
|
+
final_verification: {
|
|
32
|
+
command: DEFAULT_VERIFY_FINAL_COMMAND,
|
|
33
|
+
},
|
|
34
|
+
delivery_sequence: ["verify-final", "delivery", "closeout"],
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Parse optional `acceptance_policy` from a Feature Packet object (typically
|
|
38
|
+
* `feature.yaml`). Unknown fields are rejected; missing fields receive ADR 0007
|
|
39
|
+
* defaults without inventing covered evidence.
|
|
40
|
+
*/
|
|
41
|
+
export function parseAcceptancePolicy(packet) {
|
|
42
|
+
if (!packet || typeof packet !== "object")
|
|
43
|
+
return { ...DEFAULT_POLICY };
|
|
44
|
+
const raw = packet.acceptance_policy;
|
|
45
|
+
if (raw === undefined || raw === null)
|
|
46
|
+
return { ...DEFAULT_POLICY };
|
|
47
|
+
const parsed = acceptancePolicySchema.safeParse(raw);
|
|
48
|
+
if (!parsed.success)
|
|
49
|
+
return { ...DEFAULT_POLICY };
|
|
50
|
+
return normalizeAcceptancePolicy(parsed.data);
|
|
51
|
+
}
|
|
52
|
+
/** Apply canonical defaults while preserving explicit packet overrides. */
|
|
53
|
+
export function normalizeAcceptancePolicy(policy) {
|
|
54
|
+
return {
|
|
55
|
+
schema_version: 1,
|
|
56
|
+
authority: policy.authority?.trim() || DEFAULT_POLICY.authority,
|
|
57
|
+
independent_qa_required: policy.independent_qa_required ?? DEFAULT_POLICY.independent_qa_required,
|
|
58
|
+
coverage_writer: policy.coverage_writer?.trim() || DEFAULT_POLICY.coverage_writer,
|
|
59
|
+
final_verification: {
|
|
60
|
+
command: policy.final_verification?.command?.trim() ||
|
|
61
|
+
DEFAULT_POLICY.final_verification.command,
|
|
62
|
+
},
|
|
63
|
+
delivery_sequence: policy.delivery_sequence && policy.delivery_sequence.length > 0
|
|
64
|
+
? [...policy.delivery_sequence]
|
|
65
|
+
: [...DEFAULT_POLICY.delivery_sequence],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Extract additive policy fields that may exist on raw acceptance YAML items. */
|
|
69
|
+
export function extractAcceptanceItemPolicyFields(rawItem) {
|
|
70
|
+
if (!rawItem || typeof rawItem !== "object") {
|
|
71
|
+
return { requiredEvidenceKinds: [], expectedTaskRefs: [] };
|
|
72
|
+
}
|
|
73
|
+
const verification = rawItem.verification;
|
|
74
|
+
if (!verification || typeof verification !== "object") {
|
|
75
|
+
return { requiredEvidenceKinds: [], expectedTaskRefs: [] };
|
|
76
|
+
}
|
|
77
|
+
const record = verification;
|
|
78
|
+
const authority = typeof record.authority === "string" && record.authority.trim().length > 0
|
|
79
|
+
? record.authority.trim()
|
|
80
|
+
: undefined;
|
|
81
|
+
const requiredEvidenceKinds = readStringArray(record.required_evidence_kinds ?? record.required_evidence);
|
|
82
|
+
const expectedTaskRefs = readStringArray(record.expected_task_refs);
|
|
83
|
+
return { authority, requiredEvidenceKinds, expectedTaskRefs };
|
|
84
|
+
}
|
|
85
|
+
export function indexRawAcceptanceItems(rawAcceptance) {
|
|
86
|
+
const result = new Map();
|
|
87
|
+
if (!rawAcceptance || typeof rawAcceptance !== "object")
|
|
88
|
+
return result;
|
|
89
|
+
const items = rawAcceptance.acceptance;
|
|
90
|
+
if (!Array.isArray(items))
|
|
91
|
+
return result;
|
|
92
|
+
for (const item of items) {
|
|
93
|
+
if (!item || typeof item !== "object")
|
|
94
|
+
continue;
|
|
95
|
+
const id = item.id;
|
|
96
|
+
if (typeof id === "string" && id.length > 0)
|
|
97
|
+
result.set(id, item);
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
export function buildAcceptanceGaps(input) {
|
|
102
|
+
const gaps = [];
|
|
103
|
+
const verifyFinalCommand = buildVerifyFinalCommand(input.policy, input.featureDir, input.repoRoot);
|
|
104
|
+
const allTasksDone = input.tasks.length > 0 &&
|
|
105
|
+
input.tasks.every((task) => task.status === "Done");
|
|
106
|
+
for (const item of input.acceptanceItems) {
|
|
107
|
+
if (item.priority !== "must")
|
|
108
|
+
continue;
|
|
109
|
+
const fact = input.coverage.find((entry) => entry.acId === item.id);
|
|
110
|
+
if (!fact || fact.status === "covered" || fact.status === "waived")
|
|
111
|
+
continue;
|
|
112
|
+
const rawItem = input.rawByAcId?.get(item.id);
|
|
113
|
+
const policyFields = extractAcceptanceItemPolicyFields(rawItem);
|
|
114
|
+
const authority = resolveItemAuthority(input.policy, policyFields);
|
|
115
|
+
const blockers = collectBlockers({
|
|
116
|
+
policy: input.policy,
|
|
117
|
+
policyFields,
|
|
118
|
+
item,
|
|
119
|
+
authority,
|
|
120
|
+
});
|
|
121
|
+
const status = normalizeGapStatus(fact.status);
|
|
122
|
+
const missingEvidenceKinds = resolveMissingEvidenceKinds({
|
|
123
|
+
policyFields,
|
|
124
|
+
item,
|
|
125
|
+
fact,
|
|
126
|
+
status,
|
|
127
|
+
});
|
|
128
|
+
gaps.push({
|
|
129
|
+
acId: item.id,
|
|
130
|
+
status,
|
|
131
|
+
missingEvidenceKinds,
|
|
132
|
+
expectedTaskRefs: item.verification.expected_task_refs,
|
|
133
|
+
authority,
|
|
134
|
+
nextCommand: projectGapNextCommand({
|
|
135
|
+
status,
|
|
136
|
+
blockers,
|
|
137
|
+
allTasksDone,
|
|
138
|
+
verifyFinalCommand,
|
|
139
|
+
featureDir: input.featureDir,
|
|
140
|
+
repoRoot: input.repoRoot,
|
|
141
|
+
blockedBy: fact.blockedBy,
|
|
142
|
+
}),
|
|
143
|
+
blockers,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return gaps;
|
|
147
|
+
}
|
|
148
|
+
function resolveItemAuthority(policy, policyFields) {
|
|
149
|
+
return policyFields.authority ?? policy.authority ?? CANONICAL_FINAL_VERIFICATION_AUTHORITY;
|
|
150
|
+
}
|
|
151
|
+
function collectBlockers(input) {
|
|
152
|
+
const blockers = [];
|
|
153
|
+
if (!input.policyFields.authority && input.policy.independent_qa_required) {
|
|
154
|
+
blockers.push("missing authority: required AC has no verification.authority");
|
|
155
|
+
}
|
|
156
|
+
if (input.policyFields.expectedTaskRefs.length === 0 &&
|
|
157
|
+
(input.policyFields.requiredEvidenceKinds.length === 0 &&
|
|
158
|
+
(input.item.verification.implementation_task_refs?.length ?? 0) === 0 &&
|
|
159
|
+
(input.item.verification.verification_task_refs?.length ?? 0) === 0)) {
|
|
160
|
+
blockers.push("missing producer: no expected_task_refs or dual-coverage refs");
|
|
161
|
+
}
|
|
162
|
+
if (input.policy.independent_qa_required &&
|
|
163
|
+
input.policyFields.requiredEvidenceKinds.length === 0 &&
|
|
164
|
+
(input.item.verification.required_evidence?.length ?? 0) === 0 &&
|
|
165
|
+
input.authority === CANONICAL_FINAL_VERIFICATION_AUTHORITY) {
|
|
166
|
+
blockers.push("missing evidence kinds: declare verification.required_evidence_kinds or required_evidence");
|
|
167
|
+
}
|
|
168
|
+
if (input.authority !== CANONICAL_FINAL_VERIFICATION_AUTHORITY &&
|
|
169
|
+
input.policy.coverage_writer === CANONICAL_FINAL_VERIFICATION_AUTHORITY) {
|
|
170
|
+
blockers.push(`non-canonical authority ${input.authority}; ADR 0007 requires ${CANONICAL_FINAL_VERIFICATION_AUTHORITY}`);
|
|
171
|
+
}
|
|
172
|
+
return blockers;
|
|
173
|
+
}
|
|
174
|
+
function resolveMissingEvidenceKinds(input) {
|
|
175
|
+
if (input.status === "blocked")
|
|
176
|
+
return [];
|
|
177
|
+
const declared = [
|
|
178
|
+
...input.policyFields.requiredEvidenceKinds,
|
|
179
|
+
...(input.item.verification.required_evidence ?? []),
|
|
180
|
+
];
|
|
181
|
+
const unique = [...new Set(declared)];
|
|
182
|
+
if (unique.length > 0 && input.fact.evidence.length === 0)
|
|
183
|
+
return unique;
|
|
184
|
+
if (unique.length === 0 && input.status !== "missing") {
|
|
185
|
+
return ["canonical-qa-evidence"];
|
|
186
|
+
}
|
|
187
|
+
return unique;
|
|
188
|
+
}
|
|
189
|
+
function normalizeGapStatus(status) {
|
|
190
|
+
if (status === "awaiting-verification")
|
|
191
|
+
return "awaiting-verification";
|
|
192
|
+
if (status === "blocked")
|
|
193
|
+
return "blocked";
|
|
194
|
+
if (status === "partial")
|
|
195
|
+
return "partial";
|
|
196
|
+
return "missing";
|
|
197
|
+
}
|
|
198
|
+
function projectGapNextCommand(input) {
|
|
199
|
+
if (input.status === "blocked") {
|
|
200
|
+
if (input.blockedBy.length > 0) {
|
|
201
|
+
const taskId = input.blockedBy[0];
|
|
202
|
+
return `agent-worker feature review --feature-dir ${quote(input.featureDir)} --repo ${quote(input.repoRoot)}`;
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
if (!input.allTasksDone) {
|
|
207
|
+
return `agent-worker batch run-ready --feature-dir ${quote(input.featureDir)} --repo ${quote(input.repoRoot)}`;
|
|
208
|
+
}
|
|
209
|
+
if (input.status === "partial" ||
|
|
210
|
+
input.status === "awaiting-verification" ||
|
|
211
|
+
input.status === "missing") {
|
|
212
|
+
return input.verifyFinalCommand;
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
function buildVerifyFinalCommand(policy, featureDir, repoRoot) {
|
|
217
|
+
const base = policy.final_verification?.command?.trim() || DEFAULT_VERIFY_FINAL_COMMAND;
|
|
218
|
+
return `${base} --feature-dir ${quote(featureDir)} --repo ${quote(repoRoot)}`;
|
|
219
|
+
}
|
|
220
|
+
function readStringArray(value) {
|
|
221
|
+
if (!Array.isArray(value))
|
|
222
|
+
return [];
|
|
223
|
+
return value.filter((entry) => typeof entry === "string" && entry.length > 0);
|
|
224
|
+
}
|
|
225
|
+
function quote(value) {
|
|
226
|
+
return JSON.stringify(value);
|
|
227
|
+
}
|
|
@@ -27,7 +27,7 @@ export async function loadFeatureDecisionModels(repoRoot) {
|
|
|
27
27
|
function degradeExisting(feature, warnings) {
|
|
28
28
|
return { ...feature, status: "needs_action", statusLabel: "需处理", blockingItems: [...warnings.map((warning) => ({ type: "projection_warning", message: warning, evidence: [] })), ...feature.blockingItems], nextAction: { kind: "repair_projection", label: "修复损坏的 Feature / Task Pool facts 后重新审阅" }, projectionWarnings: [...new Set([...feature.projectionWarnings, ...warnings])] };
|
|
29
29
|
}
|
|
30
|
-
function degradedFeature(featureId, warning) { return { schemaVersion: 1, featureId, status: "needs_action", statusLabel: "需处理", summary: { tasksTotal: 0, tasksSucceeded: 0, tasksFailed: 0, tasksReady: 0, tasksBlocked: 0, requiredAcTotal: 0, requiredAcCovered: 0 }, riskSummary: { high: 1, medium: 0, low: 0 }, blockingItems: [{ type: "projection_warning", message: warning, evidence: [] }], followUps: { pending: [], actionCards: [], resolvedFailureTaskIds: [] }, tasks: [], acceptanceCoverage: [], nextAction: { kind: "repair_projection", label: "修复损坏事实后重新审阅" }, alternativeActions: [], evidence: { morningReport: null, observeSnapshot: null, delivery: null, closeout: null }, projectionWarnings: [warning] }; }
|
|
30
|
+
function degradedFeature(featureId, warning) { return { schemaVersion: 1, featureId, status: "needs_action", statusLabel: "需处理", summary: { tasksTotal: 0, tasksSucceeded: 0, tasksFailed: 0, tasksReady: 0, tasksBlocked: 0, requiredAcTotal: 0, requiredAcCovered: 0 }, riskSummary: { high: 1, medium: 0, low: 0 }, blockingItems: [{ type: "projection_warning", message: warning, evidence: [] }], followUps: { pending: [], actionCards: [], resolvedFailureTaskIds: [] }, tasks: [], acceptanceCoverage: [], acceptanceGaps: [], nextAction: { kind: "repair_projection", label: "修复损坏事实后重新审阅" }, alternativeActions: [], evidence: { morningReport: null, observeSnapshot: null, delivery: null, closeout: null }, projectionWarnings: [warning] }; }
|
|
31
31
|
async function auditTaskPoolFacts(repoRoot) {
|
|
32
32
|
const warnings = [];
|
|
33
33
|
try {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveWorkerRecoveryDecision } from "../pool/recovery-decision.js";
|
|
1
2
|
export function projectNextAction(input, status) {
|
|
2
3
|
const featureArgs = `--feature-dir ${quote(input.featureDir)} --repo ${quote(input.repoRoot)}`;
|
|
3
4
|
const resolvedFailures = new Set(input.resolvedFailureTaskIds ?? []);
|
|
@@ -31,20 +32,59 @@ export function projectNextAction(input, status) {
|
|
|
31
32
|
}
|
|
32
33
|
const failed = input.tasks.find((task) => task.status === "Failed" && !resolvedFailures.has(task.taskId));
|
|
33
34
|
if (failed) {
|
|
34
|
-
|
|
35
|
+
const decision = resolveWorkerRecoveryDecision({
|
|
36
|
+
state: {
|
|
37
|
+
status: failed.status ?? "Failed",
|
|
38
|
+
...(failed.workerRunId ? { workerRunId: failed.workerRunId } : {}),
|
|
39
|
+
...(failed.failureCategory
|
|
40
|
+
? {
|
|
41
|
+
failure: {
|
|
42
|
+
category: failed.failureCategory,
|
|
43
|
+
recommendedFollowUpKind: "feature-review",
|
|
44
|
+
derivedFollowUpTaskId: `${failed.taskId}-review`,
|
|
45
|
+
source: "fallback",
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
: {}),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (decision.recommendedAction === "retry") {
|
|
35
52
|
return {
|
|
36
53
|
kind: "retry_task",
|
|
37
|
-
label:
|
|
54
|
+
label: `重试失败任务 ${failed.taskId}`,
|
|
38
55
|
command: `agent-worker task retry ${quote(failed.taskId)} --feature-id ${quote(input.featureId)} --repo ${quote(input.repoRoot)}`,
|
|
39
56
|
};
|
|
40
57
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
if (decision.recommendedAction === "decide") {
|
|
59
|
+
return {
|
|
60
|
+
kind: "handle_failure",
|
|
61
|
+
label: `根据失败证据处理 ${failed.taskId};ProductBug 可生成并人工批准 Follow-up`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
45
64
|
}
|
|
46
65
|
const blocked = input.tasks.find((task) => ["Blocked", "HumanReview", "Abandoned"].includes(task.status ?? ""));
|
|
47
66
|
if (blocked) {
|
|
67
|
+
const decision = resolveWorkerRecoveryDecision({
|
|
68
|
+
state: {
|
|
69
|
+
status: blocked.status ?? "Blocked",
|
|
70
|
+
...(blocked.failureCategory
|
|
71
|
+
? {
|
|
72
|
+
failure: {
|
|
73
|
+
category: blocked.failureCategory,
|
|
74
|
+
recommendedFollowUpKind: "feature-review",
|
|
75
|
+
derivedFollowUpTaskId: `${blocked.taskId}-review`,
|
|
76
|
+
source: "fallback",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
if (decision.recommendedAction === "revise-contract") {
|
|
83
|
+
return {
|
|
84
|
+
kind: "revise_contract",
|
|
85
|
+
label: `修订 ${blocked.taskId} 的 contract/spec 后再 materialize`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
48
88
|
return {
|
|
49
89
|
kind: "resolve_human_gate",
|
|
50
90
|
label: `处理 ${blocked.taskId} 的人工 Gate 或阻塞原因`,
|
|
@@ -65,6 +105,16 @@ export function projectNextAction(input, status) {
|
|
|
65
105
|
command: `agent-worker batch run-ready ${featureArgs}`,
|
|
66
106
|
};
|
|
67
107
|
}
|
|
108
|
+
if (status === "awaiting_qa" || status === "deliverable") {
|
|
109
|
+
const gap = input.acceptanceGaps?.find((entry) => entry.nextCommand);
|
|
110
|
+
if (gap?.nextCommand) {
|
|
111
|
+
return {
|
|
112
|
+
kind: status === "deliverable" ? "advance_delivery" : "complete_qa",
|
|
113
|
+
label: `补齐 ${gap.acId} 验收缺口(${gap.status})`,
|
|
114
|
+
command: gap.nextCommand,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
68
118
|
if (status === "awaiting_qa") {
|
|
69
119
|
const finalish = input.tasks.find((task) => /(final[-_]?(verify|verification))|closeout/i.test(task.taskId));
|
|
70
120
|
if (finalish && (finalish.status === "Done" || finalish.status === "Ready")) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { acceptancePolicySchema } from "./acceptance-policy.js";
|
|
2
3
|
/**
|
|
3
4
|
* Feature-level profile for a Feature Packet (M4 `fullstack-v1`).
|
|
4
5
|
*
|
|
@@ -36,6 +37,8 @@ export const featureProfileSchema = z
|
|
|
36
37
|
})
|
|
37
38
|
.strict()
|
|
38
39
|
.optional(),
|
|
40
|
+
/** Phase 6 ADR 0007 acceptance / verify-final / delivery policy. */
|
|
41
|
+
acceptance_policy: acceptancePolicySchema.optional(),
|
|
39
42
|
})
|
|
40
43
|
.strict();
|
|
41
44
|
/** Whether a parsed profile activates the fullstack-v1 gate set. */
|
|
@@ -81,6 +81,7 @@ export function reduceFeature(input) {
|
|
|
81
81
|
status: task.status ?? (isReady(task.taskId, input) ? "Ready" : "Draft"),
|
|
82
82
|
})),
|
|
83
83
|
acceptanceCoverage: input.acceptance,
|
|
84
|
+
acceptanceGaps: input.acceptanceGaps ?? [],
|
|
84
85
|
nextAction: projectNextAction(input, status),
|
|
85
86
|
alternativeActions: [],
|
|
86
87
|
evidence: {
|