@defend-tech/opencode-optima 0.1.47 → 0.1.49
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_Common.md +1 -1
- package/Agents_Common.prompt.md +1 -1
- package/assets/agents/workflow_product_manager.md +1 -1
- package/dist/index.js +139 -25
- package/dist/sanitize_cli.js +139 -25
- package/docs/core/agent_orchestration.md +1 -1
- package/docs/core/agent_orchestration.prompt.md +1 -1
- package/docs/core/role_contracts.md +1 -1
- package/docs/core/role_contracts.prompt.md +1 -1
- package/docs/core/task_model.md +1 -1
- package/docs/core/task_model.prompt.md +1 -1
- package/docs/core/testing_strategy.md +1 -1
- package/package.json +1 -1
package/Agents_Common.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
- `product_manager` may investigate, answer, pre-estimate "a qué huele" small/medium/large plus rough story points, and operate ClickUp dashboards; development requests must be converted into properly routed ClickUp tasks.
|
|
19
19
|
- ClickUp delivery types are `Tarea`, `Bug`, `Doc`, `PoC`; ignore `Idea`, legacy `Backlog` alias, `Hito`, `Nota de reunión`, and `Respuesta del formulario` unless converted or linked.
|
|
20
20
|
- WPM estimates `Story Points` during `plan` and re-estimates on material plan changes.
|
|
21
|
-
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md
|
|
21
|
+
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md` when present; if a task worktree lacks that file, use Optima-provided fallback role context and configured ClickUp IDs instead of blocking solely on the missing repo-local file. Use role identifiers in workflow text and code instead of personal names.
|
|
22
22
|
- ClickUp status actions: `backlog` ignore, `plan` plan plus remove the PM assignee before assigning `CTO`/`PO` at plan end, `in progress` execute, `validation` Tech Lead + Validator/QA gates. After validation, Validator/QA may merge validated subtasks into the parent branch without `CTO`/`PO` approval; validated parent tasks stay in `validation` for `CTO`/`PO` approval, they approve by moving the parent to `merge`, and Validator/QA then attempts the parent merge into `dev`. `completed`/`Closed` ignore unless reopened.
|
|
23
23
|
- One shared-worktree `implementation` task may be active; ClickUp-first delivery should use task-specific worktrees/branches.
|
|
24
24
|
- Agent messages must start with `[Agent Message] From: <agent_name> To: <agent_name>`.
|
package/Agents_Common.prompt.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
- `product_manager` may investigate, answer, pre-estimate "a qué huele" small/medium/large plus rough story points, and operate ClickUp dashboards; development requests must become routed ClickUp tasks.
|
|
21
21
|
- ClickUp-first delivery types: `Tarea`, `Bug`, `Doc`, `PoC`; ignore `Idea`, legacy `Backlog` alias, `Hito`, `Nota de reunión`, `Respuesta del formulario` unless converted or linked.
|
|
22
22
|
- WPM estimates `Story Points` during `plan` and re-estimates on material plan changes.
|
|
23
|
-
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md
|
|
23
|
+
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md` when present; if missing in a task worktree, use Optima fallback role context/configured ClickUp IDs instead of blocking solely on the missing file.
|
|
24
24
|
- ClickUp-first statuses: `backlog` ignore, `plan` plan plus remove the PM assignee before assigning `CTO`/`PO` at plan end, `in progress` execute, `validation` Tech Lead + Validator/QA gates. Validator/QA may merge validated subtasks into the parent branch without `CTO`/`PO` approval; validated parent tasks stay in `validation` for `CTO`/`PO` approval, they approve by moving the parent to `merge`, and Validator/QA then attempts the parent merge into `dev`. `completed`/`Closed` ignore unless reopened.
|
|
25
25
|
- Signed agent-to-agent messages must start exactly: `[Agent Message] From: <agent_name> To: <agent_name>`.
|
|
26
26
|
- Direct all clarifications, blockers, and specialist questions through PMA unless explicitly in a direct discussion-capable role.
|
|
@@ -41,7 +41,7 @@ You are Workflow_Product_Manager, Optima's ClickUp-first delivery orchestrator.
|
|
|
41
41
|
## Status Actions
|
|
42
42
|
|
|
43
43
|
- `backlog`: ignore until prioritized.
|
|
44
|
-
- Human registry: resolve `CTO` and `PO` from `docs/core/humans.md
|
|
44
|
+
- Human registry: resolve `CTO` and `PO` from `docs/core/humans.md` when present; if the task worktree lacks that file, use the Optima-provided Human Role Fallback Registry and configured ClickUp IDs instead of blocking solely on the missing repo-local file. Use role IDs in workflow text, ClickUp comments, and automation config.
|
|
45
45
|
- `plan`: clarify AC/SCR/test strategy with Validator/QA, decompose, create/update Definition, estimate Story Points, hand off implementation, remove PM assignee first, then assign `CTO` + `PO` or next owner; target zero PM-assigned tasks.
|
|
46
46
|
- `in progress`: execute through assigned delivery agent or workflow runner.
|
|
47
47
|
- `validation`: route Tech Lead for architecture/code/PR/standards/repo-skill review and Validator/QA for tests, Playwright/regression/coverage/evidence/final-doc checks. Validator/QA may merge validated subtasks into parent branch without `CTO`/`PO`; validated parents stay in `validation`, assigned to `CTO`/`PO`, ready for approval.
|
package/dist/index.js
CHANGED
|
@@ -8130,6 +8130,42 @@ function resolveHumanRoles(roles = CLICKUP_FINAL_APPROVER_ROLES, registry = load
|
|
|
8130
8130
|
function finalApprovalAssignees(roles = CLICKUP_FINAL_APPROVER_ROLES, registry = loadHumansRegistry()) {
|
|
8131
8131
|
return resolveHumanRoles(roles, registry);
|
|
8132
8132
|
}
|
|
8133
|
+
function normalizedHumanRoleRegistry(registry = loadHumansRegistry()) {
|
|
8134
|
+
return Object.fromEntries(
|
|
8135
|
+
Object.entries(registry || {}).map(([role, human]) => [String(role || "").trim(), String(human || "").trim()]).filter(([role, human]) => role && human)
|
|
8136
|
+
);
|
|
8137
|
+
}
|
|
8138
|
+
function normalizeClickUpRoleIds(raw = {}) {
|
|
8139
|
+
const source = isPlainObject(raw) ? raw : {};
|
|
8140
|
+
const entries = Object.entries(source).map(([role, id]) => [String(role || "").trim(), String(id || "").trim()]);
|
|
8141
|
+
return Object.fromEntries(entries.filter(([role, id]) => role && id));
|
|
8142
|
+
}
|
|
8143
|
+
function buildHumanRoleContext({ roles = CLICKUP_FINAL_APPROVER_ROLES, registry = loadHumansRegistry(), roleIds = {} } = {}) {
|
|
8144
|
+
const normalizedRegistry = normalizedHumanRoleRegistry(registry);
|
|
8145
|
+
const normalizedIds = normalizeClickUpRoleIds(roleIds);
|
|
8146
|
+
return [...new Set(roles.map((role) => String(role || "").trim()).filter(Boolean))].map((role) => ({
|
|
8147
|
+
role,
|
|
8148
|
+
human: normalizedRegistry[role] || "",
|
|
8149
|
+
clickupUserId: normalizedIds[role] || ""
|
|
8150
|
+
}));
|
|
8151
|
+
}
|
|
8152
|
+
function formatHumanRoleContext(context = []) {
|
|
8153
|
+
const entries = Array.isArray(context) ? context : [];
|
|
8154
|
+
if (!entries.length) return "";
|
|
8155
|
+
return entries.map((entry) => {
|
|
8156
|
+
const human = entry.human ? ` = ${entry.human}` : "";
|
|
8157
|
+
const id = entry.clickupUserId ? ` (ClickUp ID: ${entry.clickupUserId})` : "";
|
|
8158
|
+
return `- ${entry.role}${human}${id}`;
|
|
8159
|
+
}).join("\n");
|
|
8160
|
+
}
|
|
8161
|
+
function hasActionableClickUpRoute(result = {}) {
|
|
8162
|
+
if (!result?.ok || result.action === "ignored") return false;
|
|
8163
|
+
if (!result.sessionId) return false;
|
|
8164
|
+
if (result.action === "message_delivery_failed" || result.action === "error") return false;
|
|
8165
|
+
if (result.deliveryVerification?.ok === false) return false;
|
|
8166
|
+
if (result.deliveryVerification?.method === "prompt_admission") return false;
|
|
8167
|
+
return true;
|
|
8168
|
+
}
|
|
8133
8169
|
function determineClickUpMergeAuthority({ isSubtask = false, clickupStatus = "", validationPassed = false, mergeFailed = false, finalApprovalRoles = CLICKUP_FINAL_APPROVER_ROLES, humansRegistry } = {}) {
|
|
8134
8170
|
if (mergeFailed) {
|
|
8135
8171
|
return {
|
|
@@ -9015,7 +9051,8 @@ function normalizeClickUpWebhookConfig(rawClickUp = null, worktree = process.cwd
|
|
|
9015
9051
|
ignoredStatuses,
|
|
9016
9052
|
metadataFieldId: String(routing.metadata_field_id || routing.metadataFieldId || "").trim(),
|
|
9017
9053
|
metadataKey: String(routing.metadata_key || routing.metadataKey || CLICKUP_PM_METADATA_KEY).trim(),
|
|
9018
|
-
ignoredCommentAuthorId: String(routing.ignored_comment_author_id || routing.ignoredCommentAuthorId || routing.product_manager_mention_user_id || routing.productManagerMentionUserId || "").trim()
|
|
9054
|
+
ignoredCommentAuthorId: String(routing.ignored_comment_author_id || routing.ignoredCommentAuthorId || routing.product_manager_mention_user_id || routing.productManagerMentionUserId || "").trim(),
|
|
9055
|
+
humanRoleClickUpIds: normalizeClickUpRoleIds(routing.human_role_clickup_ids || routing.humanRoleClickUpIds || routing.role_ids || routing.roleIds)
|
|
9019
9056
|
}
|
|
9020
9057
|
};
|
|
9021
9058
|
const errors = [];
|
|
@@ -9619,6 +9656,22 @@ async function createOpenCodeSession(client, { title, directory, agent } = {}) {
|
|
|
9619
9656
|
}
|
|
9620
9657
|
throw firstError || new Error("OpenCode session create failed.");
|
|
9621
9658
|
}
|
|
9659
|
+
async function waitForOpenCodeReadiness(client, { worktree = process.cwd(), attempts = 10, delayMs = 500, now = () => /* @__PURE__ */ new Date() } = {}) {
|
|
9660
|
+
if (typeof client?.session?.create !== "function") return { ok: true, skipped: true, reason: "session_create_probe_unavailable" };
|
|
9661
|
+
let lastError = "opencode_not_ready";
|
|
9662
|
+
const maxAttempts = Math.max(1, Number(attempts) || 1);
|
|
9663
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
9664
|
+
try {
|
|
9665
|
+
const sessionId = await createOpenCodeSession(client, { title: `Optima startup readiness probe ${now().toISOString()}`, directory: worktree });
|
|
9666
|
+
if (await openCodeSessionExists(client, sessionId)) return { ok: true, method: "session_create_probe", sessionId, attempts: attempt };
|
|
9667
|
+
lastError = "readiness_probe_session_not_visible";
|
|
9668
|
+
} catch (error) {
|
|
9669
|
+
lastError = error.message || "opencode_not_ready";
|
|
9670
|
+
}
|
|
9671
|
+
if (attempt < maxAttempts && delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
9672
|
+
}
|
|
9673
|
+
return { ok: false, reason: lastError, attempts: maxAttempts };
|
|
9674
|
+
}
|
|
9622
9675
|
function assertOpenCodePromptAccepted(result) {
|
|
9623
9676
|
const status = Number(result?.status || result?.response?.status || result?.error?.status || 0);
|
|
9624
9677
|
if (status >= 400 || result?.ok === false || result?.error) {
|
|
@@ -9656,13 +9709,13 @@ async function readOpenCodeJsonResponse(response, endpointName) {
|
|
|
9656
9709
|
return { raw };
|
|
9657
9710
|
}
|
|
9658
9711
|
}
|
|
9659
|
-
async function sendOpenCodeSessionEventDirect({ baseUrl, sessionId, text, agent, fetchImpl = globalThis.fetch } = {}) {
|
|
9712
|
+
async function sendOpenCodeSessionEventDirect({ baseUrl, sessionId, text, agent, fetchImpl = globalThis.fetch, legacyOnly = false } = {}) {
|
|
9660
9713
|
if (typeof fetchImpl !== "function") throw new Error("OpenCode direct prompt delivery requires fetch.");
|
|
9661
9714
|
const root = normalizeOpenCodeBaseUrl(baseUrl, "");
|
|
9662
9715
|
if (!root) throw new Error("OpenCode direct prompt delivery requires a base URL.");
|
|
9663
9716
|
const encodedSession = encodeURIComponent(sessionId);
|
|
9664
9717
|
const attempts = [
|
|
9665
|
-
{
|
|
9718
|
+
legacyOnly ? null : {
|
|
9666
9719
|
name: "v2 prompt",
|
|
9667
9720
|
url: `${root}/api/session/${encodedSession}/prompt`,
|
|
9668
9721
|
body: { prompt: { text }, delivery: "queue", resume: true },
|
|
@@ -9681,7 +9734,7 @@ async function sendOpenCodeSessionEventDirect({ baseUrl, sessionId, text, agent,
|
|
|
9681
9734
|
return { ok: true, method: "http", endpoint: "/session/{sessionID}/prompt_async", status: response.status, data: data?.data || null, response: data };
|
|
9682
9735
|
}
|
|
9683
9736
|
}
|
|
9684
|
-
];
|
|
9737
|
+
].filter(Boolean);
|
|
9685
9738
|
let firstError = null;
|
|
9686
9739
|
for (const attempt of attempts) {
|
|
9687
9740
|
const response = await fetchImpl(attempt.url, {
|
|
@@ -9715,7 +9768,7 @@ async function callOpenCodePromptWithFallbacks(method, sessionId, flatPayload, s
|
|
|
9715
9768
|
}
|
|
9716
9769
|
throw firstError;
|
|
9717
9770
|
}
|
|
9718
|
-
async function sendOpenCodeSessionEvent(client, { sessionId, agent, text, directory, opencodeBaseUrl, baseUrl, fetchImpl, direct = false } = {}) {
|
|
9771
|
+
async function sendOpenCodeSessionEvent(client, { sessionId, agent, text, directory, opencodeBaseUrl, baseUrl, fetchImpl, direct = false, legacyOnly = false } = {}) {
|
|
9719
9772
|
const directBaseUrl = opencodeBaseUrl || baseUrl;
|
|
9720
9773
|
const parts = [{ type: "text", text }];
|
|
9721
9774
|
const flatPayload = { directory, agent, parts };
|
|
@@ -9739,7 +9792,7 @@ async function sendOpenCodeSessionEvent(client, { sessionId, agent, text, direct
|
|
|
9739
9792
|
firstError ??= error;
|
|
9740
9793
|
}
|
|
9741
9794
|
}
|
|
9742
|
-
if (directBaseUrl) return sendOpenCodeSessionEventDirect({ baseUrl: directBaseUrl, sessionId, text, agent, fetchImpl });
|
|
9795
|
+
if (directBaseUrl) return sendOpenCodeSessionEventDirect({ baseUrl: directBaseUrl, sessionId, text, agent, fetchImpl, legacyOnly });
|
|
9743
9796
|
if (firstError) throw firstError;
|
|
9744
9797
|
throw new Error("OpenCode client does not expose session.prompt or session.promptAsync.");
|
|
9745
9798
|
}
|
|
@@ -9774,18 +9827,20 @@ function openCodeMessageText(message) {
|
|
|
9774
9827
|
for (const part of partList) parts.push(part?.text, part?.content);
|
|
9775
9828
|
return parts.filter((value) => typeof value === "string").join("\n");
|
|
9776
9829
|
}
|
|
9777
|
-
async function verifyOpenCodeSessionEventDelivery(client, { sessionId, beforeMessages = null, expectedText = "", markers = [], attempts =
|
|
9830
|
+
async function verifyOpenCodeSessionEventDelivery(client, { sessionId, beforeMessages = null, expectedText = "", markers = [], attempts = 8, delayMs = 250 } = {}) {
|
|
9778
9831
|
let lastError = "message_verification_unavailable";
|
|
9779
9832
|
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
|
|
9780
9833
|
try {
|
|
9781
9834
|
const afterMessages = await readOpenCodeSessionMessages(client, { sessionId, limit: 50 });
|
|
9782
|
-
if (!afterMessages) return { ok:
|
|
9835
|
+
if (!afterMessages) return { ok: false, reason: "message_verification_unavailable" };
|
|
9783
9836
|
const beforeCount = Array.isArray(beforeMessages) ? beforeMessages.length : null;
|
|
9784
|
-
|
|
9837
|
+
const lastMessage = afterMessages.at(-1) || null;
|
|
9838
|
+
const lastMessageId = lastMessage?.id || lastMessage?.messageID || lastMessage?.messageId || null;
|
|
9839
|
+
if (beforeCount !== null && afterMessages.length > beforeCount) return { ok: true, method: "message_count", beforeCount, afterCount: afterMessages.length, lastMessageId };
|
|
9785
9840
|
const textNeedles = [expectedText, ...markers].map((value) => String(value || "").trim()).filter(Boolean);
|
|
9786
9841
|
const haystack = afterMessages.slice(-20).map(openCodeMessageText).join("\n");
|
|
9787
9842
|
const matched = textNeedles.find((needle) => haystack.includes(needle));
|
|
9788
|
-
if (matched) return { ok: true, method: "message_text", marker: matched, beforeCount, afterCount: afterMessages.length };
|
|
9843
|
+
if (matched) return { ok: true, method: "message_text", marker: matched, beforeCount, afterCount: afterMessages.length, lastMessageId };
|
|
9789
9844
|
lastError = "message_not_visible";
|
|
9790
9845
|
} catch (error) {
|
|
9791
9846
|
lastError = error.message || "message_verification_failed";
|
|
@@ -9820,21 +9875,28 @@ async function deliverClickUpSessionEventWithVerification({ openCodeClient, send
|
|
|
9820
9875
|
const blocker2 = await applyClickUpBlockerTag({ clickupClient, worktree, taskId, reason: error.message, source: "delivery_admission_failed" });
|
|
9821
9876
|
return { ok: false, action: "message_delivery_failed", reason: error.message, taskId, sessionId, fallbackAttempted: false, blockerTag: blocker2 };
|
|
9822
9877
|
}
|
|
9823
|
-
if (admissionVerification) return { ok: true, verification: admissionVerification, fallback: false };
|
|
9824
9878
|
let verification = await verifySessionEventDelivery(openCodeClient, { sessionId, beforeMessages, expectedText: text, markers: eventMarkers });
|
|
9825
|
-
if (verification?.ok) return { ok: true, verification, fallback: false };
|
|
9879
|
+
if (verification?.ok) return { ok: true, verification, admissionVerification, fallback: false };
|
|
9880
|
+
if (verification?.reason === "message_verification_unavailable" && !admissionVerification) {
|
|
9881
|
+
return { ok: true, verification: { ok: true, method: "legacy_prompt_accepted", skipped: true }, fallback: false };
|
|
9882
|
+
}
|
|
9883
|
+
if (admissionVerification) {
|
|
9884
|
+
appendClickUpWebhookLocalLog(worktree, { type: "message_delivery_admitted_but_invisible", taskId, sessionId, admission: admissionVerification, reason: verification?.reason || "message_not_visible" });
|
|
9885
|
+
}
|
|
9826
9886
|
const canFallbackDirect = Boolean(opencodeBaseUrl);
|
|
9827
9887
|
if (canFallbackDirect) {
|
|
9828
9888
|
const retryBeforeMessages = await readOpenCodeSessionMessages(openCodeClient, { sessionId, limit: 50 }).catch(() => beforeMessages);
|
|
9829
|
-
const retrySendResult = await sendSessionEvent(openCodeClient, { sessionId, agent, text, directory, opencodeBaseUrl, direct: true });
|
|
9889
|
+
const retrySendResult = await sendSessionEvent(openCodeClient, { sessionId, agent, text, directory, opencodeBaseUrl, direct: true, legacyOnly: Boolean(admissionVerification) });
|
|
9830
9890
|
try {
|
|
9831
9891
|
admissionVerification = openCodePromptAdmissionVerification(retrySendResult, sessionId);
|
|
9832
9892
|
} catch (error) {
|
|
9833
9893
|
verification = { ok: false, reason: error.message };
|
|
9834
9894
|
}
|
|
9835
|
-
if (admissionVerification) return { ok: true, verification: admissionVerification, fallback: true };
|
|
9836
9895
|
verification = await verifySessionEventDelivery(openCodeClient, { sessionId, beforeMessages: retryBeforeMessages, expectedText: text, markers: eventMarkers });
|
|
9837
|
-
if (verification?.ok) return { ok: true, verification, fallback: true };
|
|
9896
|
+
if (verification?.ok) return { ok: true, verification, admissionVerification, fallback: true };
|
|
9897
|
+
if (verification?.reason === "message_verification_unavailable" && !admissionVerification) {
|
|
9898
|
+
return { ok: true, verification: { ok: true, method: "legacy_prompt_accepted", skipped: true }, fallback: true };
|
|
9899
|
+
}
|
|
9838
9900
|
}
|
|
9839
9901
|
const reason = verification?.reason || "message_delivery_failed";
|
|
9840
9902
|
appendClickUpWebhookLocalLog(worktree, { type: "message_delivery_failed", taskId, sessionId, reason, fallbackAttempted: canFallbackDirect });
|
|
@@ -9880,11 +9942,12 @@ async function recoverClickUpPmSession({ openCodeClient, sendSessionEvent, click
|
|
|
9880
9942
|
const replacementMetadata = clearClickUpPendingSessionMetadata(setClickUpSessionMetadata(metadataWithRouting, config.routing.metadataKey, replacementSessionId), config.routing.metadataKey);
|
|
9881
9943
|
await clickupClient.updateTaskMetadata({ taskId, fieldId: config.routing.metadataFieldId, value: replacementMetadata });
|
|
9882
9944
|
appendClickUpWebhookLocalLog(worktree, { type: "pm_session_recovery_succeeded", taskId, staleSessionId, replacementSessionId });
|
|
9883
|
-
return { ok: true, action: "sent_to_replacement_session", taskId, sessionId: replacementSessionId, staleSessionId, replacementAttempted: true, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath, deliveryVerification: replacementDelivery.verification, deliveryFallback: replacementDelivery.fallback };
|
|
9945
|
+
return { ok: true, action: "sent_to_replacement_session", taskId, sessionId: replacementSessionId, staleSessionId, replacementAttempted: true, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath, deliveryVerification: replacementDelivery.verification, deliveryAdmission: replacementDelivery.admissionVerification, deliveryFallback: replacementDelivery.fallback, deliveryAttempts: replacementDelivery.fallback ? 2 : 1 };
|
|
9884
9946
|
}
|
|
9885
|
-
function formatClickUpWebhookPrompt({ eventType, taskId, payload, branch = "", worktree = "", deliveryEvidencePath = "" }) {
|
|
9947
|
+
function formatClickUpWebhookPrompt({ eventType, taskId, payload, branch = "", worktree = "", deliveryEvidencePath = "", humanRoleContext = [] }) {
|
|
9886
9948
|
const comment = clickUpCommentFromPayload(payload);
|
|
9887
9949
|
const commentText = clickUpCommentText(comment).trim();
|
|
9950
|
+
const humanRoleLines = formatHumanRoleContext(humanRoleContext);
|
|
9888
9951
|
return [
|
|
9889
9952
|
"[ClickUp Webhook Event]",
|
|
9890
9953
|
`Event: ${eventType}`,
|
|
@@ -9892,9 +9955,12 @@ function formatClickUpWebhookPrompt({ eventType, taskId, payload, branch = "", w
|
|
|
9892
9955
|
branch ? `Branch: ${branch}` : null,
|
|
9893
9956
|
worktree ? `Worktree: ${worktree}` : null,
|
|
9894
9957
|
deliveryEvidencePath ? `Delivery evidence path: ${deliveryEvidencePath}` : null,
|
|
9958
|
+
humanRoleLines ? "Human role fallback registry (Optima-provided; non-blocking if repo docs/core/humans.md is missing):" : null,
|
|
9959
|
+
humanRoleLines || null,
|
|
9895
9960
|
commentText ? `Comment: ${commentText}` : null,
|
|
9896
9961
|
"",
|
|
9897
9962
|
"Handle this ClickUp Product Manager event using the ClickUp-first workflow rules. Do not assume broad chat routing; this event passed Optima webhook gates.",
|
|
9963
|
+
"If the task worktree lacks docs/core/humans.md, use the Optima-provided human role fallback registry above instead of blocking solely on that missing file.",
|
|
9898
9964
|
deliveryEvidencePath ? `Final merge-trackable evidence must be written under ${deliveryEvidencePath}; use .optima only as local mirror/staging.` : null
|
|
9899
9965
|
].filter((part) => part !== null).join("\n");
|
|
9900
9966
|
}
|
|
@@ -10145,7 +10211,8 @@ async function routeClickUpWebhookEventUnlocked({ payload, config, state = {}, w
|
|
|
10145
10211
|
evidence_path: `.optima/evidences/${branchSafeClickUpId(taskId)}/SUMMARY.md`
|
|
10146
10212
|
};
|
|
10147
10213
|
const metadataWithRouting = setClickUpTaskRoutingMetadata(existingMetadata, routingMetadata);
|
|
10148
|
-
const
|
|
10214
|
+
const humanRoleContext = buildHumanRoleContext({ roleIds: config.routing?.humanRoleClickUpIds });
|
|
10215
|
+
const prompt = formatClickUpWebhookPrompt({ eventType, taskId, payload, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, humanRoleContext });
|
|
10149
10216
|
if (!existingSessionId) {
|
|
10150
10217
|
let pendingSessionId = getNestedMetadataValue(metadata, clickUpPendingSessionKey(config.routing.metadataKey)) || state.pendingSessions?.[taskId];
|
|
10151
10218
|
if (!pendingSessionId) {
|
|
@@ -10171,7 +10238,7 @@ async function routeClickUpWebhookEventUnlocked({ payload, config, state = {}, w
|
|
|
10171
10238
|
await clickupClient.updateTaskMetadata({ taskId, fieldId: config.routing.metadataFieldId, value: nextMetadata });
|
|
10172
10239
|
const { [taskId]: _completedPending, ...remainingPending } = stateToPersist.pendingSessions || {};
|
|
10173
10240
|
stateToPersist = { ...stateToPersist, pendingSessions: remainingPending };
|
|
10174
|
-
return finish({ ok: true, action: "created_session", taskId, sessionId: pendingSessionId, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, deliveryVerification: delivery.verification, deliveryFallback: delivery.fallback });
|
|
10241
|
+
return finish({ ok: true, action: "created_session", taskId, sessionId: pendingSessionId, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, deliveryVerification: delivery.verification, deliveryAdmission: delivery.admissionVerification, deliveryFallback: delivery.fallback, deliveryAttempts: delivery.fallback ? 2 : 1 });
|
|
10175
10242
|
}
|
|
10176
10243
|
const sessionId = String(existingSessionId);
|
|
10177
10244
|
if (await sessionExists(openCodeClient, sessionId)) {
|
|
@@ -10181,7 +10248,7 @@ async function routeClickUpWebhookEventUnlocked({ payload, config, state = {}, w
|
|
|
10181
10248
|
const recovery2 = await recoverClickUpPmSession({ openCodeClient, sendSessionEvent, clickupClient, worktree, taskId, staleSessionId: sessionId, sessionTitle, taskRoute, metadataWithRouting, config, prompt, eventMarkers: [taskId, eventType], deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, eventKey, createSession, verifySessionEventDelivery });
|
|
10182
10249
|
return finish(recovery2);
|
|
10183
10250
|
}
|
|
10184
|
-
return finish({ ok: true, action: "sent_to_existing_session", taskId, sessionId, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, deliveryVerification: delivery.verification, deliveryFallback: delivery.fallback });
|
|
10251
|
+
return finish({ ok: true, action: "sent_to_existing_session", taskId, sessionId, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, deliveryVerification: delivery.verification, deliveryAdmission: delivery.admissionVerification, deliveryFallback: delivery.fallback, deliveryAttempts: delivery.fallback ? 2 : 1 });
|
|
10185
10252
|
}
|
|
10186
10253
|
const at = now().toISOString();
|
|
10187
10254
|
appendClickUpWebhookLocalLog(worktree, { type: "missing_session", taskId, sessionId, host, at });
|
|
@@ -10192,8 +10259,11 @@ async function routeClickUpWebhookEvent(options = {}) {
|
|
|
10192
10259
|
const taskId = clickUpTaskIdFromPayload(options.payload || {});
|
|
10193
10260
|
return withClickUpTaskRouteLock(taskId, () => routeClickUpWebhookEventUnlocked(options));
|
|
10194
10261
|
}
|
|
10195
|
-
async function reconcileClickUpStartup({ config, state = {}, worktree = process.cwd(), clickupClient, openCodeClient, sessionExists = openCodeSessionExists, createSession = createOpenCodeSession, sendSessionEvent = sendOpenCodeSessionEvent, saveState = null, now = () => /* @__PURE__ */ new Date(), limit = CLICKUP_WEBHOOK_STARTUP_TASK_LIMIT } = {}) {
|
|
10262
|
+
async function reconcileClickUpStartup({ config, state = {}, worktree = process.cwd(), clickupClient, openCodeClient, sessionExists = openCodeSessionExists, createSession = createOpenCodeSession, sendSessionEvent = sendOpenCodeSessionEvent, verifySessionEventDelivery = verifyOpenCodeSessionEventDelivery, waitForReadiness = waitForOpenCodeReadiness, saveState = null, now = () => /* @__PURE__ */ new Date(), limit = CLICKUP_WEBHOOK_STARTUP_TASK_LIMIT } = {}) {
|
|
10196
10263
|
if (!config || !clickupClient?.listAssignedTasks) return { ok: true, skipped: true, reason: "clickup_task_listing_unavailable", assigned: 0, comments: 0 };
|
|
10264
|
+
const readiness = await waitForReadiness(openCodeClient, { worktree, now });
|
|
10265
|
+
appendClickUpWebhookLocalLog(worktree, { type: readiness.ok ? "startup_reconciliation_readiness_ready" : "startup_reconciliation_readiness_failed", ...readiness });
|
|
10266
|
+
if (!readiness.ok) return { ok: false, skipped: true, reason: "opencode_not_ready", readiness, assigned: 0, comments: 0, ignored: 0, errors: 1, undelivered: 1, tasks: [], validation: { undelivered: 1, emptyPromptSessions: [] } };
|
|
10197
10267
|
let authorizedUserId = "";
|
|
10198
10268
|
if (clickupClient?.getAuthorizedUser) {
|
|
10199
10269
|
try {
|
|
@@ -10210,7 +10280,7 @@ async function reconcileClickUpStartup({ config, state = {}, worktree = process.
|
|
|
10210
10280
|
};
|
|
10211
10281
|
const lastWebhookMs = clickUpTimestampMs(mutableState.lastWebhookAt);
|
|
10212
10282
|
const ignored = new Set((config.routing?.ignoredStatuses || CLICKUP_WEBHOOK_TERMINAL_STATUSES).map(normalizeClickUpStatus));
|
|
10213
|
-
const routed = { assigned: 0, comments: 0, ignored: 0, errors: 0 };
|
|
10283
|
+
const routed = { assigned: 0, comments: 0, ignored: 0, errors: 0, undelivered: 0, tasks: [] };
|
|
10214
10284
|
clickUpWebhookLifecycleLog(worktree, { type: "startup_reconciliation_started", lastWebhookAt: state.lastWebhookAt || null });
|
|
10215
10285
|
const listed = await clickupClient.listAssignedTasks({ assigneeId: config.routing.productManagerAssigneeId, limit });
|
|
10216
10286
|
const tasks = clickUpTaskListItems(listed).slice(0, limit);
|
|
@@ -10239,18 +10309,44 @@ async function reconcileClickUpStartup({ config, state = {}, worktree = process.
|
|
|
10239
10309
|
sessionExists,
|
|
10240
10310
|
createSession,
|
|
10241
10311
|
sendSessionEvent,
|
|
10312
|
+
verifySessionEventDelivery,
|
|
10242
10313
|
saveState: persistState,
|
|
10243
10314
|
now
|
|
10244
10315
|
});
|
|
10245
|
-
|
|
10316
|
+
const routeSummary = {
|
|
10317
|
+
taskId,
|
|
10318
|
+
action: result?.action || "unknown",
|
|
10319
|
+
ok: result?.ok === true,
|
|
10320
|
+
sessionId: result?.sessionId || result?.replacementSessionId || null,
|
|
10321
|
+
branch: result?.branch || null,
|
|
10322
|
+
worktree: result?.worktree || null,
|
|
10323
|
+
verification: result?.deliveryVerification?.method || result?.deliveryVerification?.reason || null,
|
|
10324
|
+
delivered: hasActionableClickUpRoute(result),
|
|
10325
|
+
attempts: result?.deliveryAttempts || null,
|
|
10326
|
+
finalMessageCount: result?.deliveryVerification?.afterCount ?? null,
|
|
10327
|
+
finalMessageId: result?.deliveryVerification?.lastMessageId || null
|
|
10328
|
+
};
|
|
10329
|
+
routed.tasks.push(routeSummary);
|
|
10330
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_routed", ...routeSummary });
|
|
10331
|
+
if (routeSummary.delivered) {
|
|
10246
10332
|
routed.assigned += 1;
|
|
10333
|
+
} else if (result?.ok && result.action === "ignored") {
|
|
10334
|
+
routed.ignored += 1;
|
|
10247
10335
|
} else if (result?.ok === false) {
|
|
10248
10336
|
routed.errors += 1;
|
|
10337
|
+
routed.undelivered += 1;
|
|
10338
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_undelivered", taskId, action: result?.action || "error", sessionId: routeSummary.sessionId, reason: result.reason || "startup_reconciliation_route_failed" });
|
|
10249
10339
|
if (!result.blockerTag) await applyClickUpBlockerTag({ clickupClient, worktree, taskId, reason: result.reason || "startup_reconciliation_route_failed", source: "startup_reconciliation_task" });
|
|
10340
|
+
} else {
|
|
10341
|
+
routed.undelivered += 1;
|
|
10342
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_undelivered", taskId, action: routeSummary.action, sessionId: routeSummary.sessionId, reason: "route_not_actionable" });
|
|
10250
10343
|
}
|
|
10251
10344
|
} catch (error) {
|
|
10252
10345
|
routed.errors += 1;
|
|
10346
|
+
routed.undelivered += 1;
|
|
10347
|
+
routed.tasks.push({ taskId, action: "error", ok: false, sessionId: null, branch: null, worktree: null, verification: error.message, delivered: false });
|
|
10253
10348
|
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_failed", taskId, message: error.message });
|
|
10349
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_undelivered", taskId, action: "error", sessionId: null, reason: "startup_reconciliation_task_failed" });
|
|
10254
10350
|
await applyClickUpBlockerTag({ clickupClient, worktree, taskId, reason: "startup_reconciliation_task_failed", source: "startup_reconciliation_task" });
|
|
10255
10351
|
}
|
|
10256
10352
|
if (!lastWebhookMs || !clickupClient?.getTaskComments) continue;
|
|
@@ -10273,6 +10369,7 @@ async function reconcileClickUpStartup({ config, state = {}, worktree = process.
|
|
|
10273
10369
|
sessionExists,
|
|
10274
10370
|
createSession,
|
|
10275
10371
|
sendSessionEvent,
|
|
10372
|
+
verifySessionEventDelivery,
|
|
10276
10373
|
saveState: persistState,
|
|
10277
10374
|
now
|
|
10278
10375
|
});
|
|
@@ -10284,8 +10381,12 @@ async function reconcileClickUpStartup({ config, state = {}, worktree = process.
|
|
|
10284
10381
|
await applyClickUpBlockerTag({ clickupClient, worktree, taskId, reason: "startup_reconciliation_comments_failed", source: "startup_reconciliation_comments" });
|
|
10285
10382
|
}
|
|
10286
10383
|
}
|
|
10384
|
+
const emptyPromptSessions = routed.tasks.filter((task) => task.delivered === false && task.sessionId);
|
|
10385
|
+
if (emptyPromptSessions.length > 0) {
|
|
10386
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_validation_failed", tasks: emptyPromptSessions });
|
|
10387
|
+
}
|
|
10287
10388
|
clickUpWebhookLifecycleLog(worktree, { type: "startup_reconciliation_finished", ...routed });
|
|
10288
|
-
return { ok: routed.errors === 0, ...routed };
|
|
10389
|
+
return { ok: routed.errors === 0 && routed.undelivered === 0, ...routed, validation: { undelivered: routed.undelivered, emptyPromptSessions } };
|
|
10289
10390
|
}
|
|
10290
10391
|
function clickUpWebhookExpectedPath(config) {
|
|
10291
10392
|
try {
|
|
@@ -11403,6 +11504,19 @@ ${additionFragment}`;
|
|
|
11403
11504
|
delete agentConfig.tools.optima_prompt_workflow;
|
|
11404
11505
|
}
|
|
11405
11506
|
}
|
|
11507
|
+
if (id === "workflow_product_manager") {
|
|
11508
|
+
const humanRoleContext = buildHumanRoleContext({ roleIds: options.clickUpWebhookValidation?.config?.routing?.humanRoleClickUpIds });
|
|
11509
|
+
const humanRoleLines = formatHumanRoleContext(humanRoleContext);
|
|
11510
|
+
if (humanRoleLines) {
|
|
11511
|
+
agentConfig.prompt = `${agentConfig.prompt}
|
|
11512
|
+
|
|
11513
|
+
## Optima Human Role Fallback Registry
|
|
11514
|
+
|
|
11515
|
+
${humanRoleLines}
|
|
11516
|
+
|
|
11517
|
+
Use this Optima-provided fallback when the current task worktree lacks docs/core/humans.md; missing repo-local humans.md is not a hard blocker when this context or configured ClickUp IDs are present.`;
|
|
11518
|
+
}
|
|
11519
|
+
}
|
|
11406
11520
|
ourAgents[id] = agentConfig;
|
|
11407
11521
|
if (repoCfg.features?.debug_dumps !== false) {
|
|
11408
11522
|
const debugPath = path2.join(debugDir, `${id}.md`);
|
|
@@ -12071,7 +12185,7 @@ Follow-up: use optima_prompt_workflow with session_id '${sessionId}' to check in
|
|
|
12071
12185
|
}
|
|
12072
12186
|
};
|
|
12073
12187
|
}
|
|
12074
|
-
OptimaPlugin.__internals = { BUNDLE_AGENTS_DIR, BUNDLE_ASSETS_DIR, CLICKUP_DEFINITION_DOC_PARENT, activeClickUpWebhookLifecycleRegistry, cleanupManagedClickUpWebhook, deleteClickUpWebhookBestEffort, BUNDLE_POLICIES_DIR, buildClickUpApplyPayloadResult, buildClickUpCreateSubtasksPayload, buildClickUpStartTaskPayload, buildClickUpSummaryPayload, buildClickUpTransitionPayload, buildOptimaAgents, clickUpCommentLedgerKey, clickUpCommentMentionsProductManager, clickUpWebhookAuditLogDir, clickUpWebhookAuditLogPath, createClickUpApiClient, createOpenCodeSession, deliveryEvidencePathForClickUpTask, ensureClickUpTaskWorktree, ensureClickUpWebhookSubscription, handleClickUpWebhookRequest, isClickUpWebhookStateActive, normalizeClickUpWebhookConfig, normalizeClickUpWebhookLogLevel, normalizeOpenCodeBaseUrl, openCodeSessionExists, readClickUpCommentLedger, readClickUpWebhookState, readOpenCodeSessionMessages, reconcileClickUpStartup, recordClickUpCommentVersionProcessed, resyncClickUpWebhookForSignatureDrift, resolveOptimaPluginOptions, resolveSecretReference, routeClickUpWebhookEvent, sendOpenCodeSessionEvent, sendOpenCodeSessionEventDirect, verifyOpenCodeSessionEventDelivery, stableClickUpCommentVersionMarker, startClickUpWebhookListener, validateClickUpWebhookState, verifyClickUpSignature, writeClickUpWebhookState, decideClickUpStatusAction, deriveClickUpBranchName, deriveClickUpPendingSubtaskBranch, deriveClickUpPrTarget, deriveClickUpWorktree, determineClickUpMergeAuthority, ensureOptimaGitignoreRules, explicitSafeInputWorktree, finalApprovalAssignees, formatValidationResult, isIgnoredClickUpTaskType, isOptimaPluginPackageWorktree, isSafeWritableDirectory, loadHumansRegistry, mergeClickUpAgentMetadata, mergeClickUpSessionMetadata, migrateLegacyOptimaLayout, normalizeAgentMetadataJson, normalizeClickUpStatus, normalizeClickUpTaskType, normalizePromptResponseParts, normalizeWorkflowTaskPath, parseClickUpSubtasksMarkdown, parseHumansRegistry, parseMarkdownArtifact, parseMarkdownSections, preEstimateClickUpWork, readMarkdownArtifact, resolveHumanRoles, resolveSafeWorktree, safeWorktreeOrFailure, stripRawLogSections, validateMainWorkspaceBranchSafety, workflowFinalMessageFromPromptResponse };
|
|
12188
|
+
OptimaPlugin.__internals = { BUNDLE_AGENTS_DIR, BUNDLE_ASSETS_DIR, CLICKUP_DEFINITION_DOC_PARENT, activeClickUpWebhookLifecycleRegistry, cleanupManagedClickUpWebhook, deleteClickUpWebhookBestEffort, BUNDLE_POLICIES_DIR, buildClickUpApplyPayloadResult, buildClickUpCreateSubtasksPayload, buildClickUpStartTaskPayload, buildClickUpSummaryPayload, buildClickUpTransitionPayload, buildOptimaAgents, clickUpCommentLedgerKey, clickUpCommentMentionsProductManager, clickUpWebhookAuditLogDir, clickUpWebhookAuditLogPath, createClickUpApiClient, createOpenCodeSession, deliveryEvidencePathForClickUpTask, ensureClickUpTaskWorktree, ensureClickUpWebhookSubscription, handleClickUpWebhookRequest, isClickUpWebhookStateActive, normalizeClickUpWebhookConfig, normalizeClickUpWebhookLogLevel, normalizeOpenCodeBaseUrl, openCodeSessionExists, readClickUpCommentLedger, readClickUpWebhookState, readOpenCodeSessionMessages, reconcileClickUpStartup, waitForOpenCodeReadiness, recordClickUpCommentVersionProcessed, resyncClickUpWebhookForSignatureDrift, resolveOptimaPluginOptions, resolveSecretReference, routeClickUpWebhookEvent, sendOpenCodeSessionEvent, sendOpenCodeSessionEventDirect, verifyOpenCodeSessionEventDelivery, stableClickUpCommentVersionMarker, startClickUpWebhookListener, validateClickUpWebhookState, verifyClickUpSignature, writeClickUpWebhookState, decideClickUpStatusAction, deriveClickUpBranchName, deriveClickUpPendingSubtaskBranch, deriveClickUpPrTarget, deriveClickUpWorktree, determineClickUpMergeAuthority, ensureOptimaGitignoreRules, explicitSafeInputWorktree, finalApprovalAssignees, formatValidationResult, isIgnoredClickUpTaskType, isOptimaPluginPackageWorktree, isSafeWritableDirectory, loadHumansRegistry, mergeClickUpAgentMetadata, mergeClickUpSessionMetadata, migrateLegacyOptimaLayout, normalizeAgentMetadataJson, normalizeClickUpStatus, normalizeClickUpTaskType, normalizePromptResponseParts, normalizeWorkflowTaskPath, parseClickUpSubtasksMarkdown, parseHumansRegistry, parseMarkdownArtifact, parseMarkdownSections, preEstimateClickUpWork, readMarkdownArtifact, resolveHumanRoles, resolveSafeWorktree, safeWorktreeOrFailure, stripRawLogSections, validateMainWorkspaceBranchSafety, workflowFinalMessageFromPromptResponse };
|
|
12075
12189
|
export {
|
|
12076
12190
|
OptimaPlugin as default
|
|
12077
12191
|
};
|
package/dist/sanitize_cli.js
CHANGED
|
@@ -8137,6 +8137,42 @@ function resolveHumanRoles(roles = CLICKUP_FINAL_APPROVER_ROLES, registry = load
|
|
|
8137
8137
|
function finalApprovalAssignees(roles = CLICKUP_FINAL_APPROVER_ROLES, registry = loadHumansRegistry()) {
|
|
8138
8138
|
return resolveHumanRoles(roles, registry);
|
|
8139
8139
|
}
|
|
8140
|
+
function normalizedHumanRoleRegistry(registry = loadHumansRegistry()) {
|
|
8141
|
+
return Object.fromEntries(
|
|
8142
|
+
Object.entries(registry || {}).map(([role, human]) => [String(role || "").trim(), String(human || "").trim()]).filter(([role, human]) => role && human)
|
|
8143
|
+
);
|
|
8144
|
+
}
|
|
8145
|
+
function normalizeClickUpRoleIds(raw = {}) {
|
|
8146
|
+
const source = isPlainObject(raw) ? raw : {};
|
|
8147
|
+
const entries = Object.entries(source).map(([role, id]) => [String(role || "").trim(), String(id || "").trim()]);
|
|
8148
|
+
return Object.fromEntries(entries.filter(([role, id]) => role && id));
|
|
8149
|
+
}
|
|
8150
|
+
function buildHumanRoleContext({ roles = CLICKUP_FINAL_APPROVER_ROLES, registry = loadHumansRegistry(), roleIds = {} } = {}) {
|
|
8151
|
+
const normalizedRegistry = normalizedHumanRoleRegistry(registry);
|
|
8152
|
+
const normalizedIds = normalizeClickUpRoleIds(roleIds);
|
|
8153
|
+
return [...new Set(roles.map((role) => String(role || "").trim()).filter(Boolean))].map((role) => ({
|
|
8154
|
+
role,
|
|
8155
|
+
human: normalizedRegistry[role] || "",
|
|
8156
|
+
clickupUserId: normalizedIds[role] || ""
|
|
8157
|
+
}));
|
|
8158
|
+
}
|
|
8159
|
+
function formatHumanRoleContext(context = []) {
|
|
8160
|
+
const entries = Array.isArray(context) ? context : [];
|
|
8161
|
+
if (!entries.length) return "";
|
|
8162
|
+
return entries.map((entry) => {
|
|
8163
|
+
const human = entry.human ? ` = ${entry.human}` : "";
|
|
8164
|
+
const id = entry.clickupUserId ? ` (ClickUp ID: ${entry.clickupUserId})` : "";
|
|
8165
|
+
return `- ${entry.role}${human}${id}`;
|
|
8166
|
+
}).join("\n");
|
|
8167
|
+
}
|
|
8168
|
+
function hasActionableClickUpRoute(result = {}) {
|
|
8169
|
+
if (!result?.ok || result.action === "ignored") return false;
|
|
8170
|
+
if (!result.sessionId) return false;
|
|
8171
|
+
if (result.action === "message_delivery_failed" || result.action === "error") return false;
|
|
8172
|
+
if (result.deliveryVerification?.ok === false) return false;
|
|
8173
|
+
if (result.deliveryVerification?.method === "prompt_admission") return false;
|
|
8174
|
+
return true;
|
|
8175
|
+
}
|
|
8140
8176
|
function determineClickUpMergeAuthority({ isSubtask = false, clickupStatus = "", validationPassed = false, mergeFailed = false, finalApprovalRoles = CLICKUP_FINAL_APPROVER_ROLES, humansRegistry } = {}) {
|
|
8141
8177
|
if (mergeFailed) {
|
|
8142
8178
|
return {
|
|
@@ -9022,7 +9058,8 @@ function normalizeClickUpWebhookConfig(rawClickUp = null, worktree = process.cwd
|
|
|
9022
9058
|
ignoredStatuses,
|
|
9023
9059
|
metadataFieldId: String(routing.metadata_field_id || routing.metadataFieldId || "").trim(),
|
|
9024
9060
|
metadataKey: String(routing.metadata_key || routing.metadataKey || CLICKUP_PM_METADATA_KEY).trim(),
|
|
9025
|
-
ignoredCommentAuthorId: String(routing.ignored_comment_author_id || routing.ignoredCommentAuthorId || routing.product_manager_mention_user_id || routing.productManagerMentionUserId || "").trim()
|
|
9061
|
+
ignoredCommentAuthorId: String(routing.ignored_comment_author_id || routing.ignoredCommentAuthorId || routing.product_manager_mention_user_id || routing.productManagerMentionUserId || "").trim(),
|
|
9062
|
+
humanRoleClickUpIds: normalizeClickUpRoleIds(routing.human_role_clickup_ids || routing.humanRoleClickUpIds || routing.role_ids || routing.roleIds)
|
|
9026
9063
|
}
|
|
9027
9064
|
};
|
|
9028
9065
|
const errors = [];
|
|
@@ -9626,6 +9663,22 @@ async function createOpenCodeSession(client, { title, directory, agent } = {}) {
|
|
|
9626
9663
|
}
|
|
9627
9664
|
throw firstError || new Error("OpenCode session create failed.");
|
|
9628
9665
|
}
|
|
9666
|
+
async function waitForOpenCodeReadiness(client, { worktree = process.cwd(), attempts = 10, delayMs = 500, now = () => /* @__PURE__ */ new Date() } = {}) {
|
|
9667
|
+
if (typeof client?.session?.create !== "function") return { ok: true, skipped: true, reason: "session_create_probe_unavailable" };
|
|
9668
|
+
let lastError = "opencode_not_ready";
|
|
9669
|
+
const maxAttempts = Math.max(1, Number(attempts) || 1);
|
|
9670
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
9671
|
+
try {
|
|
9672
|
+
const sessionId = await createOpenCodeSession(client, { title: `Optima startup readiness probe ${now().toISOString()}`, directory: worktree });
|
|
9673
|
+
if (await openCodeSessionExists(client, sessionId)) return { ok: true, method: "session_create_probe", sessionId, attempts: attempt };
|
|
9674
|
+
lastError = "readiness_probe_session_not_visible";
|
|
9675
|
+
} catch (error) {
|
|
9676
|
+
lastError = error.message || "opencode_not_ready";
|
|
9677
|
+
}
|
|
9678
|
+
if (attempt < maxAttempts && delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
9679
|
+
}
|
|
9680
|
+
return { ok: false, reason: lastError, attempts: maxAttempts };
|
|
9681
|
+
}
|
|
9629
9682
|
function assertOpenCodePromptAccepted(result) {
|
|
9630
9683
|
const status = Number(result?.status || result?.response?.status || result?.error?.status || 0);
|
|
9631
9684
|
if (status >= 400 || result?.ok === false || result?.error) {
|
|
@@ -9663,13 +9716,13 @@ async function readOpenCodeJsonResponse(response, endpointName) {
|
|
|
9663
9716
|
return { raw };
|
|
9664
9717
|
}
|
|
9665
9718
|
}
|
|
9666
|
-
async function sendOpenCodeSessionEventDirect({ baseUrl, sessionId, text, agent, fetchImpl = globalThis.fetch } = {}) {
|
|
9719
|
+
async function sendOpenCodeSessionEventDirect({ baseUrl, sessionId, text, agent, fetchImpl = globalThis.fetch, legacyOnly = false } = {}) {
|
|
9667
9720
|
if (typeof fetchImpl !== "function") throw new Error("OpenCode direct prompt delivery requires fetch.");
|
|
9668
9721
|
const root = normalizeOpenCodeBaseUrl(baseUrl, "");
|
|
9669
9722
|
if (!root) throw new Error("OpenCode direct prompt delivery requires a base URL.");
|
|
9670
9723
|
const encodedSession = encodeURIComponent(sessionId);
|
|
9671
9724
|
const attempts = [
|
|
9672
|
-
{
|
|
9725
|
+
legacyOnly ? null : {
|
|
9673
9726
|
name: "v2 prompt",
|
|
9674
9727
|
url: `${root}/api/session/${encodedSession}/prompt`,
|
|
9675
9728
|
body: { prompt: { text }, delivery: "queue", resume: true },
|
|
@@ -9688,7 +9741,7 @@ async function sendOpenCodeSessionEventDirect({ baseUrl, sessionId, text, agent,
|
|
|
9688
9741
|
return { ok: true, method: "http", endpoint: "/session/{sessionID}/prompt_async", status: response.status, data: data?.data || null, response: data };
|
|
9689
9742
|
}
|
|
9690
9743
|
}
|
|
9691
|
-
];
|
|
9744
|
+
].filter(Boolean);
|
|
9692
9745
|
let firstError = null;
|
|
9693
9746
|
for (const attempt of attempts) {
|
|
9694
9747
|
const response = await fetchImpl(attempt.url, {
|
|
@@ -9722,7 +9775,7 @@ async function callOpenCodePromptWithFallbacks(method, sessionId, flatPayload, s
|
|
|
9722
9775
|
}
|
|
9723
9776
|
throw firstError;
|
|
9724
9777
|
}
|
|
9725
|
-
async function sendOpenCodeSessionEvent(client, { sessionId, agent, text, directory, opencodeBaseUrl, baseUrl, fetchImpl, direct = false } = {}) {
|
|
9778
|
+
async function sendOpenCodeSessionEvent(client, { sessionId, agent, text, directory, opencodeBaseUrl, baseUrl, fetchImpl, direct = false, legacyOnly = false } = {}) {
|
|
9726
9779
|
const directBaseUrl = opencodeBaseUrl || baseUrl;
|
|
9727
9780
|
const parts = [{ type: "text", text }];
|
|
9728
9781
|
const flatPayload = { directory, agent, parts };
|
|
@@ -9746,7 +9799,7 @@ async function sendOpenCodeSessionEvent(client, { sessionId, agent, text, direct
|
|
|
9746
9799
|
firstError ??= error;
|
|
9747
9800
|
}
|
|
9748
9801
|
}
|
|
9749
|
-
if (directBaseUrl) return sendOpenCodeSessionEventDirect({ baseUrl: directBaseUrl, sessionId, text, agent, fetchImpl });
|
|
9802
|
+
if (directBaseUrl) return sendOpenCodeSessionEventDirect({ baseUrl: directBaseUrl, sessionId, text, agent, fetchImpl, legacyOnly });
|
|
9750
9803
|
if (firstError) throw firstError;
|
|
9751
9804
|
throw new Error("OpenCode client does not expose session.prompt or session.promptAsync.");
|
|
9752
9805
|
}
|
|
@@ -9781,18 +9834,20 @@ function openCodeMessageText(message) {
|
|
|
9781
9834
|
for (const part of partList) parts.push(part?.text, part?.content);
|
|
9782
9835
|
return parts.filter((value) => typeof value === "string").join("\n");
|
|
9783
9836
|
}
|
|
9784
|
-
async function verifyOpenCodeSessionEventDelivery(client, { sessionId, beforeMessages = null, expectedText = "", markers = [], attempts =
|
|
9837
|
+
async function verifyOpenCodeSessionEventDelivery(client, { sessionId, beforeMessages = null, expectedText = "", markers = [], attempts = 8, delayMs = 250 } = {}) {
|
|
9785
9838
|
let lastError = "message_verification_unavailable";
|
|
9786
9839
|
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
|
|
9787
9840
|
try {
|
|
9788
9841
|
const afterMessages = await readOpenCodeSessionMessages(client, { sessionId, limit: 50 });
|
|
9789
|
-
if (!afterMessages) return { ok:
|
|
9842
|
+
if (!afterMessages) return { ok: false, reason: "message_verification_unavailable" };
|
|
9790
9843
|
const beforeCount = Array.isArray(beforeMessages) ? beforeMessages.length : null;
|
|
9791
|
-
|
|
9844
|
+
const lastMessage = afterMessages.at(-1) || null;
|
|
9845
|
+
const lastMessageId = lastMessage?.id || lastMessage?.messageID || lastMessage?.messageId || null;
|
|
9846
|
+
if (beforeCount !== null && afterMessages.length > beforeCount) return { ok: true, method: "message_count", beforeCount, afterCount: afterMessages.length, lastMessageId };
|
|
9792
9847
|
const textNeedles = [expectedText, ...markers].map((value) => String(value || "").trim()).filter(Boolean);
|
|
9793
9848
|
const haystack = afterMessages.slice(-20).map(openCodeMessageText).join("\n");
|
|
9794
9849
|
const matched = textNeedles.find((needle) => haystack.includes(needle));
|
|
9795
|
-
if (matched) return { ok: true, method: "message_text", marker: matched, beforeCount, afterCount: afterMessages.length };
|
|
9850
|
+
if (matched) return { ok: true, method: "message_text", marker: matched, beforeCount, afterCount: afterMessages.length, lastMessageId };
|
|
9796
9851
|
lastError = "message_not_visible";
|
|
9797
9852
|
} catch (error) {
|
|
9798
9853
|
lastError = error.message || "message_verification_failed";
|
|
@@ -9827,21 +9882,28 @@ async function deliverClickUpSessionEventWithVerification({ openCodeClient, send
|
|
|
9827
9882
|
const blocker2 = await applyClickUpBlockerTag({ clickupClient, worktree, taskId, reason: error.message, source: "delivery_admission_failed" });
|
|
9828
9883
|
return { ok: false, action: "message_delivery_failed", reason: error.message, taskId, sessionId, fallbackAttempted: false, blockerTag: blocker2 };
|
|
9829
9884
|
}
|
|
9830
|
-
if (admissionVerification) return { ok: true, verification: admissionVerification, fallback: false };
|
|
9831
9885
|
let verification = await verifySessionEventDelivery(openCodeClient, { sessionId, beforeMessages, expectedText: text, markers: eventMarkers });
|
|
9832
|
-
if (verification?.ok) return { ok: true, verification, fallback: false };
|
|
9886
|
+
if (verification?.ok) return { ok: true, verification, admissionVerification, fallback: false };
|
|
9887
|
+
if (verification?.reason === "message_verification_unavailable" && !admissionVerification) {
|
|
9888
|
+
return { ok: true, verification: { ok: true, method: "legacy_prompt_accepted", skipped: true }, fallback: false };
|
|
9889
|
+
}
|
|
9890
|
+
if (admissionVerification) {
|
|
9891
|
+
appendClickUpWebhookLocalLog(worktree, { type: "message_delivery_admitted_but_invisible", taskId, sessionId, admission: admissionVerification, reason: verification?.reason || "message_not_visible" });
|
|
9892
|
+
}
|
|
9833
9893
|
const canFallbackDirect = Boolean(opencodeBaseUrl);
|
|
9834
9894
|
if (canFallbackDirect) {
|
|
9835
9895
|
const retryBeforeMessages = await readOpenCodeSessionMessages(openCodeClient, { sessionId, limit: 50 }).catch(() => beforeMessages);
|
|
9836
|
-
const retrySendResult = await sendSessionEvent(openCodeClient, { sessionId, agent, text, directory, opencodeBaseUrl, direct: true });
|
|
9896
|
+
const retrySendResult = await sendSessionEvent(openCodeClient, { sessionId, agent, text, directory, opencodeBaseUrl, direct: true, legacyOnly: Boolean(admissionVerification) });
|
|
9837
9897
|
try {
|
|
9838
9898
|
admissionVerification = openCodePromptAdmissionVerification(retrySendResult, sessionId);
|
|
9839
9899
|
} catch (error) {
|
|
9840
9900
|
verification = { ok: false, reason: error.message };
|
|
9841
9901
|
}
|
|
9842
|
-
if (admissionVerification) return { ok: true, verification: admissionVerification, fallback: true };
|
|
9843
9902
|
verification = await verifySessionEventDelivery(openCodeClient, { sessionId, beforeMessages: retryBeforeMessages, expectedText: text, markers: eventMarkers });
|
|
9844
|
-
if (verification?.ok) return { ok: true, verification, fallback: true };
|
|
9903
|
+
if (verification?.ok) return { ok: true, verification, admissionVerification, fallback: true };
|
|
9904
|
+
if (verification?.reason === "message_verification_unavailable" && !admissionVerification) {
|
|
9905
|
+
return { ok: true, verification: { ok: true, method: "legacy_prompt_accepted", skipped: true }, fallback: true };
|
|
9906
|
+
}
|
|
9845
9907
|
}
|
|
9846
9908
|
const reason = verification?.reason || "message_delivery_failed";
|
|
9847
9909
|
appendClickUpWebhookLocalLog(worktree, { type: "message_delivery_failed", taskId, sessionId, reason, fallbackAttempted: canFallbackDirect });
|
|
@@ -9887,11 +9949,12 @@ async function recoverClickUpPmSession({ openCodeClient, sendSessionEvent, click
|
|
|
9887
9949
|
const replacementMetadata = clearClickUpPendingSessionMetadata(setClickUpSessionMetadata(metadataWithRouting, config.routing.metadataKey, replacementSessionId), config.routing.metadataKey);
|
|
9888
9950
|
await clickupClient.updateTaskMetadata({ taskId, fieldId: config.routing.metadataFieldId, value: replacementMetadata });
|
|
9889
9951
|
appendClickUpWebhookLocalLog(worktree, { type: "pm_session_recovery_succeeded", taskId, staleSessionId, replacementSessionId });
|
|
9890
|
-
return { ok: true, action: "sent_to_replacement_session", taskId, sessionId: replacementSessionId, staleSessionId, replacementAttempted: true, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath, deliveryVerification: replacementDelivery.verification, deliveryFallback: replacementDelivery.fallback };
|
|
9952
|
+
return { ok: true, action: "sent_to_replacement_session", taskId, sessionId: replacementSessionId, staleSessionId, replacementAttempted: true, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath, deliveryVerification: replacementDelivery.verification, deliveryAdmission: replacementDelivery.admissionVerification, deliveryFallback: replacementDelivery.fallback, deliveryAttempts: replacementDelivery.fallback ? 2 : 1 };
|
|
9891
9953
|
}
|
|
9892
|
-
function formatClickUpWebhookPrompt({ eventType, taskId, payload, branch = "", worktree = "", deliveryEvidencePath = "" }) {
|
|
9954
|
+
function formatClickUpWebhookPrompt({ eventType, taskId, payload, branch = "", worktree = "", deliveryEvidencePath = "", humanRoleContext = [] }) {
|
|
9893
9955
|
const comment = clickUpCommentFromPayload(payload);
|
|
9894
9956
|
const commentText = clickUpCommentText(comment).trim();
|
|
9957
|
+
const humanRoleLines = formatHumanRoleContext(humanRoleContext);
|
|
9895
9958
|
return [
|
|
9896
9959
|
"[ClickUp Webhook Event]",
|
|
9897
9960
|
`Event: ${eventType}`,
|
|
@@ -9899,9 +9962,12 @@ function formatClickUpWebhookPrompt({ eventType, taskId, payload, branch = "", w
|
|
|
9899
9962
|
branch ? `Branch: ${branch}` : null,
|
|
9900
9963
|
worktree ? `Worktree: ${worktree}` : null,
|
|
9901
9964
|
deliveryEvidencePath ? `Delivery evidence path: ${deliveryEvidencePath}` : null,
|
|
9965
|
+
humanRoleLines ? "Human role fallback registry (Optima-provided; non-blocking if repo docs/core/humans.md is missing):" : null,
|
|
9966
|
+
humanRoleLines || null,
|
|
9902
9967
|
commentText ? `Comment: ${commentText}` : null,
|
|
9903
9968
|
"",
|
|
9904
9969
|
"Handle this ClickUp Product Manager event using the ClickUp-first workflow rules. Do not assume broad chat routing; this event passed Optima webhook gates.",
|
|
9970
|
+
"If the task worktree lacks docs/core/humans.md, use the Optima-provided human role fallback registry above instead of blocking solely on that missing file.",
|
|
9905
9971
|
deliveryEvidencePath ? `Final merge-trackable evidence must be written under ${deliveryEvidencePath}; use .optima only as local mirror/staging.` : null
|
|
9906
9972
|
].filter((part) => part !== null).join("\n");
|
|
9907
9973
|
}
|
|
@@ -10152,7 +10218,8 @@ async function routeClickUpWebhookEventUnlocked({ payload, config, state = {}, w
|
|
|
10152
10218
|
evidence_path: `.optima/evidences/${branchSafeClickUpId(taskId)}/SUMMARY.md`
|
|
10153
10219
|
};
|
|
10154
10220
|
const metadataWithRouting = setClickUpTaskRoutingMetadata(existingMetadata, routingMetadata);
|
|
10155
|
-
const
|
|
10221
|
+
const humanRoleContext = buildHumanRoleContext({ roleIds: config.routing?.humanRoleClickUpIds });
|
|
10222
|
+
const prompt = formatClickUpWebhookPrompt({ eventType, taskId, payload, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, humanRoleContext });
|
|
10156
10223
|
if (!existingSessionId) {
|
|
10157
10224
|
let pendingSessionId = getNestedMetadataValue(metadata, clickUpPendingSessionKey(config.routing.metadataKey)) || state.pendingSessions?.[taskId];
|
|
10158
10225
|
if (!pendingSessionId) {
|
|
@@ -10178,7 +10245,7 @@ async function routeClickUpWebhookEventUnlocked({ payload, config, state = {}, w
|
|
|
10178
10245
|
await clickupClient.updateTaskMetadata({ taskId, fieldId: config.routing.metadataFieldId, value: nextMetadata });
|
|
10179
10246
|
const { [taskId]: _completedPending, ...remainingPending } = stateToPersist.pendingSessions || {};
|
|
10180
10247
|
stateToPersist = { ...stateToPersist, pendingSessions: remainingPending };
|
|
10181
|
-
return finish({ ok: true, action: "created_session", taskId, sessionId: pendingSessionId, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, deliveryVerification: delivery.verification, deliveryFallback: delivery.fallback });
|
|
10248
|
+
return finish({ ok: true, action: "created_session", taskId, sessionId: pendingSessionId, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, deliveryVerification: delivery.verification, deliveryAdmission: delivery.admissionVerification, deliveryFallback: delivery.fallback, deliveryAttempts: delivery.fallback ? 2 : 1 });
|
|
10182
10249
|
}
|
|
10183
10250
|
const sessionId = String(existingSessionId);
|
|
10184
10251
|
if (await sessionExists(openCodeClient, sessionId)) {
|
|
@@ -10188,7 +10255,7 @@ async function routeClickUpWebhookEventUnlocked({ payload, config, state = {}, w
|
|
|
10188
10255
|
const recovery2 = await recoverClickUpPmSession({ openCodeClient, sendSessionEvent, clickupClient, worktree, taskId, staleSessionId: sessionId, sessionTitle, taskRoute, metadataWithRouting, config, prompt, eventMarkers: [taskId, eventType], deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, eventKey, createSession, verifySessionEventDelivery });
|
|
10189
10256
|
return finish(recovery2);
|
|
10190
10257
|
}
|
|
10191
|
-
return finish({ ok: true, action: "sent_to_existing_session", taskId, sessionId, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, deliveryVerification: delivery.verification, deliveryFallback: delivery.fallback });
|
|
10258
|
+
return finish({ ok: true, action: "sent_to_existing_session", taskId, sessionId, eventKey, branch: taskRoute.branch, worktree: taskRoute.worktree, deliveryEvidencePath, evidencePath: routingMetadata.evidence_path, deliveryVerification: delivery.verification, deliveryAdmission: delivery.admissionVerification, deliveryFallback: delivery.fallback, deliveryAttempts: delivery.fallback ? 2 : 1 });
|
|
10192
10259
|
}
|
|
10193
10260
|
const at = now().toISOString();
|
|
10194
10261
|
appendClickUpWebhookLocalLog(worktree, { type: "missing_session", taskId, sessionId, host, at });
|
|
@@ -10199,8 +10266,11 @@ async function routeClickUpWebhookEvent(options = {}) {
|
|
|
10199
10266
|
const taskId = clickUpTaskIdFromPayload(options.payload || {});
|
|
10200
10267
|
return withClickUpTaskRouteLock(taskId, () => routeClickUpWebhookEventUnlocked(options));
|
|
10201
10268
|
}
|
|
10202
|
-
async function reconcileClickUpStartup({ config, state = {}, worktree = process.cwd(), clickupClient, openCodeClient, sessionExists = openCodeSessionExists, createSession = createOpenCodeSession, sendSessionEvent = sendOpenCodeSessionEvent, saveState = null, now = () => /* @__PURE__ */ new Date(), limit = CLICKUP_WEBHOOK_STARTUP_TASK_LIMIT } = {}) {
|
|
10269
|
+
async function reconcileClickUpStartup({ config, state = {}, worktree = process.cwd(), clickupClient, openCodeClient, sessionExists = openCodeSessionExists, createSession = createOpenCodeSession, sendSessionEvent = sendOpenCodeSessionEvent, verifySessionEventDelivery = verifyOpenCodeSessionEventDelivery, waitForReadiness = waitForOpenCodeReadiness, saveState = null, now = () => /* @__PURE__ */ new Date(), limit = CLICKUP_WEBHOOK_STARTUP_TASK_LIMIT } = {}) {
|
|
10203
10270
|
if (!config || !clickupClient?.listAssignedTasks) return { ok: true, skipped: true, reason: "clickup_task_listing_unavailable", assigned: 0, comments: 0 };
|
|
10271
|
+
const readiness = await waitForReadiness(openCodeClient, { worktree, now });
|
|
10272
|
+
appendClickUpWebhookLocalLog(worktree, { type: readiness.ok ? "startup_reconciliation_readiness_ready" : "startup_reconciliation_readiness_failed", ...readiness });
|
|
10273
|
+
if (!readiness.ok) return { ok: false, skipped: true, reason: "opencode_not_ready", readiness, assigned: 0, comments: 0, ignored: 0, errors: 1, undelivered: 1, tasks: [], validation: { undelivered: 1, emptyPromptSessions: [] } };
|
|
10204
10274
|
let authorizedUserId = "";
|
|
10205
10275
|
if (clickupClient?.getAuthorizedUser) {
|
|
10206
10276
|
try {
|
|
@@ -10217,7 +10287,7 @@ async function reconcileClickUpStartup({ config, state = {}, worktree = process.
|
|
|
10217
10287
|
};
|
|
10218
10288
|
const lastWebhookMs = clickUpTimestampMs(mutableState.lastWebhookAt);
|
|
10219
10289
|
const ignored = new Set((config.routing?.ignoredStatuses || CLICKUP_WEBHOOK_TERMINAL_STATUSES).map(normalizeClickUpStatus));
|
|
10220
|
-
const routed = { assigned: 0, comments: 0, ignored: 0, errors: 0 };
|
|
10290
|
+
const routed = { assigned: 0, comments: 0, ignored: 0, errors: 0, undelivered: 0, tasks: [] };
|
|
10221
10291
|
clickUpWebhookLifecycleLog(worktree, { type: "startup_reconciliation_started", lastWebhookAt: state.lastWebhookAt || null });
|
|
10222
10292
|
const listed = await clickupClient.listAssignedTasks({ assigneeId: config.routing.productManagerAssigneeId, limit });
|
|
10223
10293
|
const tasks = clickUpTaskListItems(listed).slice(0, limit);
|
|
@@ -10246,18 +10316,44 @@ async function reconcileClickUpStartup({ config, state = {}, worktree = process.
|
|
|
10246
10316
|
sessionExists,
|
|
10247
10317
|
createSession,
|
|
10248
10318
|
sendSessionEvent,
|
|
10319
|
+
verifySessionEventDelivery,
|
|
10249
10320
|
saveState: persistState,
|
|
10250
10321
|
now
|
|
10251
10322
|
});
|
|
10252
|
-
|
|
10323
|
+
const routeSummary = {
|
|
10324
|
+
taskId,
|
|
10325
|
+
action: result?.action || "unknown",
|
|
10326
|
+
ok: result?.ok === true,
|
|
10327
|
+
sessionId: result?.sessionId || result?.replacementSessionId || null,
|
|
10328
|
+
branch: result?.branch || null,
|
|
10329
|
+
worktree: result?.worktree || null,
|
|
10330
|
+
verification: result?.deliveryVerification?.method || result?.deliveryVerification?.reason || null,
|
|
10331
|
+
delivered: hasActionableClickUpRoute(result),
|
|
10332
|
+
attempts: result?.deliveryAttempts || null,
|
|
10333
|
+
finalMessageCount: result?.deliveryVerification?.afterCount ?? null,
|
|
10334
|
+
finalMessageId: result?.deliveryVerification?.lastMessageId || null
|
|
10335
|
+
};
|
|
10336
|
+
routed.tasks.push(routeSummary);
|
|
10337
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_routed", ...routeSummary });
|
|
10338
|
+
if (routeSummary.delivered) {
|
|
10253
10339
|
routed.assigned += 1;
|
|
10340
|
+
} else if (result?.ok && result.action === "ignored") {
|
|
10341
|
+
routed.ignored += 1;
|
|
10254
10342
|
} else if (result?.ok === false) {
|
|
10255
10343
|
routed.errors += 1;
|
|
10344
|
+
routed.undelivered += 1;
|
|
10345
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_undelivered", taskId, action: result?.action || "error", sessionId: routeSummary.sessionId, reason: result.reason || "startup_reconciliation_route_failed" });
|
|
10256
10346
|
if (!result.blockerTag) await applyClickUpBlockerTag({ clickupClient, worktree, taskId, reason: result.reason || "startup_reconciliation_route_failed", source: "startup_reconciliation_task" });
|
|
10347
|
+
} else {
|
|
10348
|
+
routed.undelivered += 1;
|
|
10349
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_undelivered", taskId, action: routeSummary.action, sessionId: routeSummary.sessionId, reason: "route_not_actionable" });
|
|
10257
10350
|
}
|
|
10258
10351
|
} catch (error) {
|
|
10259
10352
|
routed.errors += 1;
|
|
10353
|
+
routed.undelivered += 1;
|
|
10354
|
+
routed.tasks.push({ taskId, action: "error", ok: false, sessionId: null, branch: null, worktree: null, verification: error.message, delivered: false });
|
|
10260
10355
|
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_failed", taskId, message: error.message });
|
|
10356
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_task_undelivered", taskId, action: "error", sessionId: null, reason: "startup_reconciliation_task_failed" });
|
|
10261
10357
|
await applyClickUpBlockerTag({ clickupClient, worktree, taskId, reason: "startup_reconciliation_task_failed", source: "startup_reconciliation_task" });
|
|
10262
10358
|
}
|
|
10263
10359
|
if (!lastWebhookMs || !clickupClient?.getTaskComments) continue;
|
|
@@ -10280,6 +10376,7 @@ async function reconcileClickUpStartup({ config, state = {}, worktree = process.
|
|
|
10280
10376
|
sessionExists,
|
|
10281
10377
|
createSession,
|
|
10282
10378
|
sendSessionEvent,
|
|
10379
|
+
verifySessionEventDelivery,
|
|
10283
10380
|
saveState: persistState,
|
|
10284
10381
|
now
|
|
10285
10382
|
});
|
|
@@ -10291,8 +10388,12 @@ async function reconcileClickUpStartup({ config, state = {}, worktree = process.
|
|
|
10291
10388
|
await applyClickUpBlockerTag({ clickupClient, worktree, taskId, reason: "startup_reconciliation_comments_failed", source: "startup_reconciliation_comments" });
|
|
10292
10389
|
}
|
|
10293
10390
|
}
|
|
10391
|
+
const emptyPromptSessions = routed.tasks.filter((task) => task.delivered === false && task.sessionId);
|
|
10392
|
+
if (emptyPromptSessions.length > 0) {
|
|
10393
|
+
appendClickUpWebhookLocalLog(worktree, { type: "startup_reconciliation_validation_failed", tasks: emptyPromptSessions });
|
|
10394
|
+
}
|
|
10294
10395
|
clickUpWebhookLifecycleLog(worktree, { type: "startup_reconciliation_finished", ...routed });
|
|
10295
|
-
return { ok: routed.errors === 0, ...routed };
|
|
10396
|
+
return { ok: routed.errors === 0 && routed.undelivered === 0, ...routed, validation: { undelivered: routed.undelivered, emptyPromptSessions } };
|
|
10296
10397
|
}
|
|
10297
10398
|
function clickUpWebhookExpectedPath(config) {
|
|
10298
10399
|
try {
|
|
@@ -11410,6 +11511,19 @@ ${additionFragment}`;
|
|
|
11410
11511
|
delete agentConfig.tools.optima_prompt_workflow;
|
|
11411
11512
|
}
|
|
11412
11513
|
}
|
|
11514
|
+
if (id === "workflow_product_manager") {
|
|
11515
|
+
const humanRoleContext = buildHumanRoleContext({ roleIds: options.clickUpWebhookValidation?.config?.routing?.humanRoleClickUpIds });
|
|
11516
|
+
const humanRoleLines = formatHumanRoleContext(humanRoleContext);
|
|
11517
|
+
if (humanRoleLines) {
|
|
11518
|
+
agentConfig.prompt = `${agentConfig.prompt}
|
|
11519
|
+
|
|
11520
|
+
## Optima Human Role Fallback Registry
|
|
11521
|
+
|
|
11522
|
+
${humanRoleLines}
|
|
11523
|
+
|
|
11524
|
+
Use this Optima-provided fallback when the current task worktree lacks docs/core/humans.md; missing repo-local humans.md is not a hard blocker when this context or configured ClickUp IDs are present.`;
|
|
11525
|
+
}
|
|
11526
|
+
}
|
|
11413
11527
|
ourAgents[id] = agentConfig;
|
|
11414
11528
|
if (repoCfg.features?.debug_dumps !== false) {
|
|
11415
11529
|
const debugPath = path2.join(debugDir, `${id}.md`);
|
|
@@ -12078,7 +12192,7 @@ Follow-up: use optima_prompt_workflow with session_id '${sessionId}' to check in
|
|
|
12078
12192
|
}
|
|
12079
12193
|
};
|
|
12080
12194
|
}
|
|
12081
|
-
OptimaPlugin.__internals = { BUNDLE_AGENTS_DIR, BUNDLE_ASSETS_DIR, CLICKUP_DEFINITION_DOC_PARENT, activeClickUpWebhookLifecycleRegistry, cleanupManagedClickUpWebhook, deleteClickUpWebhookBestEffort, BUNDLE_POLICIES_DIR, buildClickUpApplyPayloadResult, buildClickUpCreateSubtasksPayload, buildClickUpStartTaskPayload, buildClickUpSummaryPayload, buildClickUpTransitionPayload, buildOptimaAgents, clickUpCommentLedgerKey, clickUpCommentMentionsProductManager, clickUpWebhookAuditLogDir, clickUpWebhookAuditLogPath, createClickUpApiClient, createOpenCodeSession, deliveryEvidencePathForClickUpTask, ensureClickUpTaskWorktree, ensureClickUpWebhookSubscription, handleClickUpWebhookRequest, isClickUpWebhookStateActive, normalizeClickUpWebhookConfig, normalizeClickUpWebhookLogLevel, normalizeOpenCodeBaseUrl, openCodeSessionExists, readClickUpCommentLedger, readClickUpWebhookState, readOpenCodeSessionMessages, reconcileClickUpStartup, recordClickUpCommentVersionProcessed, resyncClickUpWebhookForSignatureDrift, resolveOptimaPluginOptions, resolveSecretReference, routeClickUpWebhookEvent, sendOpenCodeSessionEvent, sendOpenCodeSessionEventDirect, verifyOpenCodeSessionEventDelivery, stableClickUpCommentVersionMarker, startClickUpWebhookListener, validateClickUpWebhookState, verifyClickUpSignature, writeClickUpWebhookState, decideClickUpStatusAction, deriveClickUpBranchName, deriveClickUpPendingSubtaskBranch, deriveClickUpPrTarget, deriveClickUpWorktree, determineClickUpMergeAuthority, ensureOptimaGitignoreRules, explicitSafeInputWorktree, finalApprovalAssignees, formatValidationResult, isIgnoredClickUpTaskType, isOptimaPluginPackageWorktree, isSafeWritableDirectory, loadHumansRegistry, mergeClickUpAgentMetadata, mergeClickUpSessionMetadata, migrateLegacyOptimaLayout, normalizeAgentMetadataJson, normalizeClickUpStatus, normalizeClickUpTaskType, normalizePromptResponseParts, normalizeWorkflowTaskPath, parseClickUpSubtasksMarkdown, parseHumansRegistry, parseMarkdownArtifact, parseMarkdownSections, preEstimateClickUpWork, readMarkdownArtifact, resolveHumanRoles, resolveSafeWorktree, safeWorktreeOrFailure, stripRawLogSections, validateMainWorkspaceBranchSafety, workflowFinalMessageFromPromptResponse };
|
|
12195
|
+
OptimaPlugin.__internals = { BUNDLE_AGENTS_DIR, BUNDLE_ASSETS_DIR, CLICKUP_DEFINITION_DOC_PARENT, activeClickUpWebhookLifecycleRegistry, cleanupManagedClickUpWebhook, deleteClickUpWebhookBestEffort, BUNDLE_POLICIES_DIR, buildClickUpApplyPayloadResult, buildClickUpCreateSubtasksPayload, buildClickUpStartTaskPayload, buildClickUpSummaryPayload, buildClickUpTransitionPayload, buildOptimaAgents, clickUpCommentLedgerKey, clickUpCommentMentionsProductManager, clickUpWebhookAuditLogDir, clickUpWebhookAuditLogPath, createClickUpApiClient, createOpenCodeSession, deliveryEvidencePathForClickUpTask, ensureClickUpTaskWorktree, ensureClickUpWebhookSubscription, handleClickUpWebhookRequest, isClickUpWebhookStateActive, normalizeClickUpWebhookConfig, normalizeClickUpWebhookLogLevel, normalizeOpenCodeBaseUrl, openCodeSessionExists, readClickUpCommentLedger, readClickUpWebhookState, readOpenCodeSessionMessages, reconcileClickUpStartup, waitForOpenCodeReadiness, recordClickUpCommentVersionProcessed, resyncClickUpWebhookForSignatureDrift, resolveOptimaPluginOptions, resolveSecretReference, routeClickUpWebhookEvent, sendOpenCodeSessionEvent, sendOpenCodeSessionEventDirect, verifyOpenCodeSessionEventDelivery, stableClickUpCommentVersionMarker, startClickUpWebhookListener, validateClickUpWebhookState, verifyClickUpSignature, writeClickUpWebhookState, decideClickUpStatusAction, deriveClickUpBranchName, deriveClickUpPendingSubtaskBranch, deriveClickUpPrTarget, deriveClickUpWorktree, determineClickUpMergeAuthority, ensureOptimaGitignoreRules, explicitSafeInputWorktree, finalApprovalAssignees, formatValidationResult, isIgnoredClickUpTaskType, isOptimaPluginPackageWorktree, isSafeWritableDirectory, loadHumansRegistry, mergeClickUpAgentMetadata, mergeClickUpSessionMetadata, migrateLegacyOptimaLayout, normalizeAgentMetadataJson, normalizeClickUpStatus, normalizeClickUpTaskType, normalizePromptResponseParts, normalizeWorkflowTaskPath, parseClickUpSubtasksMarkdown, parseHumansRegistry, parseMarkdownArtifact, parseMarkdownSections, preEstimateClickUpWork, readMarkdownArtifact, resolveHumanRoles, resolveSafeWorktree, safeWorktreeOrFailure, stripRawLogSections, validateMainWorkspaceBranchSafety, workflowFinalMessageFromPromptResponse };
|
|
12082
12196
|
|
|
12083
12197
|
// src/sanitize_cli.js
|
|
12084
12198
|
var { migrateLegacyOptimaLayout: migrateLegacyOptimaLayout2 } = OptimaPlugin.__internals;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
- `product_manager` may answer, investigate, operate dashboards, and pre-estimate "a qué huele" small/medium/large plus rough story points; WPM owns delivery routing.
|
|
23
23
|
- Supported delivery task types are `Tarea`, `Bug`, `Doc`, and `PoC`; ignore `Idea`, legacy `Backlog` alias, `Hito`, `Nota de reunión`, and `Respuesta del formulario` unless converted or linked to delivery work.
|
|
24
|
-
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md
|
|
24
|
+
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md` when present; if a task worktree lacks that file, use the Optima-provided human role fallback context and configured ClickUp IDs instead of blocking solely on the missing repo-local file. Use role identifiers in workflow text and automation config.
|
|
25
25
|
- Status actions are deterministic: `backlog` ignore, `plan` plan plus `Story Points`, test strategy, `Definition`, and `CTO`/`PO` assignment, `in progress` execute, `validation` split Tech Lead and Validator/QA gates, `merge` parent approval state after `CTO`/`PO` move a validated parent task there, and `completed`/`Closed` ignore unless reopened.
|
|
26
26
|
- Store ClickUp `agent_metadata` JSON with session IDs per agent/type/task/subtask; keep `Definition` as the plan contract and final Documentation as delivered behavior docs.
|
|
27
27
|
- `workflow_product_manager` is registered only when explicit ClickUp webhook mode is configured and the local webhook subscription state is active/valid.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
- Routing comes from `docs/core/task_model.md`: `tiny` lightweight, `standard` bounded, `complex` decomposed; full mode supports all, mini mode refuses complex.
|
|
14
14
|
- `product_manager` may answer/investigate/dashboard/pre-estimate "a qué huele" plus rough story points; development asks become routed ClickUp tasks.
|
|
15
15
|
- ClickUp-first types: execute `Tarea`, `Bug`, `Doc`, `PoC`; ignore `Idea`, legacy `Backlog` alias, `Hito`, `Nota de reunión`, `Respuesta del formulario` unless converted/linked.
|
|
16
|
-
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md
|
|
16
|
+
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md` when present; if missing in a task worktree, use Optima-provided fallback role context/configured ClickUp IDs instead of blocking solely on the missing file.
|
|
17
17
|
- ClickUp-first statuses: `backlog` ignore, `plan` plan with `Story Points`, `Definition`, test strategy, and `CTO`/`PO` assignment, `in progress` execute, `validation` split Tech Lead + Validator/QA gates; Validator/QA may merge validated subtasks directly into the parent branch; validated parents stay in `validation` for `CTO`/`PO` approval; `merge` means `CTO`/`PO` approved the parent and Validator/QA may attempt parent merge into `dev`; `completed`/`Closed` ignore unless reopened.
|
|
18
18
|
- Shared-worktree rule: one active `implementation` task at a time; isolated `investigation`/`spec` may run in parallel if non-conflicting.
|
|
19
19
|
- Git rules: principal workspace stays on `dev`, never `main`; parent task pulls remote once at start; subtasks trust parent local branch; PoC branches stay `poc/<clickup-task-id>`; subtasks PR to parent branch, parents PR to `dev`, releases PR `dev` -> `main`; failed/conflicted subtask or parent merges return the affected item to `in progress` for the coding owner; no direct `main` pushes.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
- Human-readable task/evidence summaries, validation results, AC coverage, documentation impact, blockers, reopen history, status-transition rationale, and final handoffs must be posted to linked ClickUp task/subtask comments or fields.
|
|
21
21
|
- Raw logs stay in evidence storage; ClickUp receives concise summaries, paths/links, or relevant excerpts only, never wholesale raw logs.
|
|
22
22
|
- WPM owns ClickUp `Story Points` during `plan`, re-estimation on material plan changes, `agent_metadata` session JSON, `Definition` plan-contract linking, and parent approval routing after validation.
|
|
23
|
-
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md
|
|
23
|
+
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md` when present; if a task worktree lacks that file, use Optima-provided fallback role context and configured ClickUp IDs instead of blocking solely on the missing repo-local file. Use role identifiers in workflow text and automation config.
|
|
24
24
|
- Subtask merge authority belongs to Validator/QA after successful subtask validation: subtask PRs target and merge into the parent branch/workspace without `CTO`/`PO` approval.
|
|
25
25
|
- Parent merge authority is split: after Tech Lead and Validator/QA pass, WPM/Validator assigns `CTO` and `PO` while the parent task remains in `validation`; `CTO`/`PO` approve by moving the parent task to `merge`; Validator/QA then attempts the parent PR merge into `dev`.
|
|
26
26
|
- If a subtask or parent merge conflicts or fails, Validator/QA returns the affected ClickUp task/subtask to `in progress` and routes it back to the coding owner.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
- Sync human-readable task/evidence summaries, validation results, AC coverage, documentation impact, blockers, reopen history, status-transition rationale, and final handoffs to linked ClickUp task/subtask comments or fields.
|
|
15
15
|
- Keep raw logs in evidence storage; ClickUp receives concise summaries, paths/links, or relevant excerpts only, never wholesale raw logs.
|
|
16
16
|
- WPM owns `Story Points` during `plan`, re-estimation on material plan changes, `agent_metadata`, `Definition` plan-contract linking, and parent approval routing after validation.
|
|
17
|
-
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md
|
|
17
|
+
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md` when present; if missing in a task worktree, use Optima fallback role context/configured ClickUp IDs instead of blocking solely on the missing file.
|
|
18
18
|
- Validator/QA may merge validated subtask PRs into the parent branch/workspace without `CTO`/`PO` approval.
|
|
19
19
|
- Parent merge authority requires `CTO`/`PO` approval: after Tech Lead + Validator/QA pass, assign both roles while the parent stays in `validation`; they approve by moving it to `merge`; Validator/QA then attempts the parent PR merge into `dev`.
|
|
20
20
|
- Failed or conflicted subtask/parent merges return the affected ClickUp item to `in progress` for the coding owner.
|
package/docs/core/task_model.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- Delivery task types: `Tarea`, `Bug`, `Doc`, `PoC`.
|
|
12
12
|
- Ignored task types: `Idea`, legacy `Backlog` alias, `Hito`, `Nota de reunión`, `Respuesta del formulario` unless converted or linked to delivery work; `Idea` is non-delivery.
|
|
13
13
|
- `product_manager` may pre-estimate "a qué huele" small/medium/large plus rough story points, but development requests must be converted to routed ClickUp work.
|
|
14
|
-
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md
|
|
14
|
+
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md` when present; if a task worktree lacks that file, use Optima fallback role context and configured ClickUp IDs instead of blocking solely on the missing repo-local file. Use role identifiers in workflow text and automation config.
|
|
15
15
|
- Status-to-action mapping: `backlog` -> `ignore`, `plan` -> `plan`, `in progress` -> `execute`, `validation` -> `validate`, `merge` -> `merge`, `completed`/`Closed` -> `ignore` unless reopened. For parent tasks, `merge` means `CTO`/`PO` have approved by moving the task out of `validation`; for subtasks, Validator/QA may merge after successful validation without waiting for `merge` human approval.
|
|
16
16
|
- Branch-safe type slugs are lowercase ASCII: `tarea`, `bug`, `doc`, `poc`.
|
|
17
17
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
- Slices: `foundation`, `core`, `logic`, `ui`, `polish`, `qa`, `docs`.
|
|
7
7
|
- ClickUp-first delivery types: `Tarea`, `Bug`, `Doc`, `PoC`; ignored types: `Idea`, legacy `Backlog` alias, `Hito`, `Nota de reunión`, `Respuesta del formulario` unless converted/linked.
|
|
8
8
|
- Product Manager without workflow never develops; it may pre-estimate "a qué huele" small/medium/large plus rough story points and route development into ClickUp tasks.
|
|
9
|
-
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md
|
|
9
|
+
- Human role registry: resolve `CTO` and `PO` from `docs/core/humans.md` when present; if missing in a task worktree, use Optima fallback role context/configured ClickUp IDs instead of blocking solely on the missing file.
|
|
10
10
|
- ClickUp-first actions: `backlog` ignore, `plan` plan with `Story Points`, `Definition`, test strategy, and `CTO`/`PO` assignment, `in progress` execute, `validation` split Tech Lead + Validator/QA; validated subtasks may be merged by Validator/QA into the parent branch without human approval; validated parent tasks assign `CTO`/`PO` and wait for them to move the task to `merge`; `merge` lets Validator/QA attempt parent PR merge into `dev`; `completed`/`Closed` ignore unless reopened.
|
|
11
11
|
- Routing: keep `tiny` to one slice and usually one specialist; keep `standard` bounded; decompose `complex` into slice-based subtasks.
|
|
12
12
|
- `complex + implementation` normally uses `workflow_runner` in full mode.
|
|
@@ -18,7 +18,7 @@ We adhere to a strict test pyramid strategy to ensure 100% reliability.
|
|
|
18
18
|
- **Planning Participation:** Validator/QA participates during `plan` for test strategy, required new coverage, Playwright needs, regression scope, and documentation-validation expectations.
|
|
19
19
|
- **Regression:** A full regression suite must be run by the Developer before handing over for technical review.
|
|
20
20
|
- **Split Validation:** Tech Lead reviews architecture, code, PR readiness, standards, and repo-skill use; Validator/QA verifies tests, Playwright flows, regression, required coverage, evidence, and final documentation freshness.
|
|
21
|
-
- **Human Role Registry:** Resolve `CTO` and `PO` from `docs/core/humans.md
|
|
21
|
+
- **Human Role Registry:** Resolve `CTO` and `PO` from `docs/core/humans.md` when present; if a task worktree lacks that file, use Optima fallback role context and configured ClickUp IDs instead of blocking solely on the missing repo-local file. Use role identifiers in workflow text and automation config.
|
|
22
22
|
- **Merge Execution Gate:** Validator/QA may merge validated subtask PRs into the parent branch/workspace without human approval. Parent PRs to `dev` require Tech Lead and Validator/QA pass plus `CTO`/`PO` approval by moving the parent task to `merge` before Validator/QA attempts the merge. Any conflicted or failed merge returns the affected task/subtask to `in progress` for the coding owner.
|
|
23
23
|
- **Documentation Gate:** Validator/QA must fail validation when final documentation is missing or outdated; `Definition` is only the plan contract, not the delivered documentation.
|
|
24
24
|
- **QA Output Contract:** QA handoffs should state the test strategy used, the results observed, the AC coverage achieved, any documentation impact, open risks, and the recommended next step.
|