@yemi33/minions 0.1.1835 → 0.1.1837
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/CHANGELOG.md +10 -0
- package/dashboard.js +139 -1990
- package/engine/copilot-models.json +1 -1
- package/package.json +1 -1
package/dashboard.js
CHANGED
|
@@ -1602,13 +1602,6 @@ function renderDocChatSystemPromptForTurn(turnId) {
|
|
|
1602
1602
|
.replace(/\{\{dashboard_port\}\}/g, String(PORT));
|
|
1603
1603
|
}
|
|
1604
1604
|
|
|
1605
|
-
// Doc-chat document rewrite delimiter constants. Kept for backwards-compat
|
|
1606
|
-
// while the system migrates to using Edit/Write tools directly (see
|
|
1607
|
-
// prompts/cc-system.md "Document edits" section). Once CC always uses
|
|
1608
|
-
// Edit/Write, these can be retired.
|
|
1609
|
-
const DOC_CHAT_DOCUMENT_DELIMITER = '---MINIONS-DOC-CHAT-DOCUMENT-v1-6f2f90e3---';
|
|
1610
|
-
const LEGACY_DOC_CHAT_DOCUMENT_DELIMITER = '---DOCUMENT---';
|
|
1611
|
-
|
|
1612
1605
|
// Per-CC-turn correlation. CC's tool calls (Bash curl to /api/...) attach an
|
|
1613
1606
|
// X-CC-Turn-Id header; the mutating endpoints record creations into this map
|
|
1614
1607
|
// keyed by the turn ID so the CC handler can surface them as confirmation
|
|
@@ -1700,6 +1693,25 @@ function _buildSyntheticActionResultsForTurn(turnId, message, requestedAt) {
|
|
|
1700
1693
|
return { actions, results };
|
|
1701
1694
|
}
|
|
1702
1695
|
|
|
1696
|
+
// Cheap mtime+size snapshot used by doc-chat to detect whether CC's `Edit` /
|
|
1697
|
+
// `Write` tool calls actually changed the target file during a turn. A diff
|
|
1698
|
+
// triggers a "Document saved" chip via the same _ccTurnCreations surface used
|
|
1699
|
+
// by mutating /api/* endpoints.
|
|
1700
|
+
function _snapshotDocFile(fullPath) {
|
|
1701
|
+
if (!fullPath) return null;
|
|
1702
|
+
try {
|
|
1703
|
+
const st = fs.statSync(fullPath);
|
|
1704
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
1705
|
+
} catch { return { mtimeMs: 0, size: 0 }; } // missing file → all changes look like edits
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function _docFileChanged(before, fullPath) {
|
|
1709
|
+
if (!fullPath) return false;
|
|
1710
|
+
const after = _snapshotDocFile(fullPath);
|
|
1711
|
+
if (!before || !after) return false;
|
|
1712
|
+
return before.mtimeMs !== after.mtimeMs || before.size !== after.size;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1703
1715
|
function _ccTurnEntryToActionType(kind) {
|
|
1704
1716
|
switch (kind) {
|
|
1705
1717
|
case 'work-item': return 'dispatch';
|
|
@@ -2073,135 +2085,25 @@ For all state files, look under \`${MINIONS_DIR}\`.${indexSection}`;
|
|
|
2073
2085
|
return result;
|
|
2074
2086
|
}
|
|
2075
2087
|
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
// Single helper that handles both the strict (well-formed) and loose forms of
|
|
2082
|
-
// the ===ACTIONS=== delimiter. `parseable` is true only for the strict form
|
|
2083
|
-
// that parseCCActions can JSON.parse; loose matches still split display text
|
|
2084
|
-
// but are not parsed (they shouldn't reach the user as actions).
|
|
2085
|
-
function findCCActionsHeader(text) {
|
|
2086
|
-
if (!text) return null;
|
|
2087
|
-
// Tier 1 — strict, parseable: the exact canonical delimiter on its own line.
|
|
2088
|
-
const strict = /(?:^|\r?\n)===ACTIONS===[ \t]*(?=\r?\n|$)/m.exec(text);
|
|
2089
|
-
if (strict) {
|
|
2090
|
-
const headerStart = strict.index + strict[0].indexOf('===ACTIONS');
|
|
2091
|
-
const headerMatch = text.slice(headerStart).match(/^===ACTIONS===[ \t]*/);
|
|
2092
|
-
return {
|
|
2093
|
-
index: headerStart,
|
|
2094
|
-
headerLength: headerMatch ? headerMatch[0].length : '===ACTIONS==='.length,
|
|
2095
|
-
parseable: true,
|
|
2096
|
-
};
|
|
2097
|
-
}
|
|
2098
|
-
// Tier 2 — loose: sentinel-looking malformed delimiters such as ===ACTIONS ->
|
|
2099
|
-
// should still be hidden, but prose like "===ACTIONS are documented" must render.
|
|
2100
|
-
const loose = /(?:^|\r?\n)===ACTIONS(?:[ \t]*(?:[-=]>?|={1,}|$)|[^A-Za-z0-9_\s\r\n][^\r\n]*)(?=\r?\n|$)/m.exec(text);
|
|
2101
|
-
if (loose) {
|
|
2102
|
-
const headerStart = loose.index + loose[0].indexOf('===ACTIONS');
|
|
2103
|
-
return { index: headerStart, headerLength: 0, parseable: false };
|
|
2104
|
-
}
|
|
2105
|
-
// Tier 3 — very loose: 2+ leading equals, optional whitespace, ACTIONS keyword
|
|
2106
|
-
// (case-insensitive), optional trailing equals. Catches the common model
|
|
2107
|
-
// fumbles: ====ACTIONS===, ===actions===, ===ACTIONS=====, ==ACTIONS==. Must
|
|
2108
|
-
// still be a complete line (preceded by line start, followed by EOL or EOS)
|
|
2109
|
-
// so prose like "the answer is 2 + 3 = 5" or "==Important==" doesn't match.
|
|
2110
|
-
const veryLoose = /(?:^|\r?\n)={2,}[ \t]*ACTIONS[ \t]*={0,}[ \t]*(?=\r?\n|$)/im.exec(text);
|
|
2111
|
-
if (veryLoose) {
|
|
2112
|
-
// Skip the leading newline (if any) so headerStart points to first '='.
|
|
2113
|
-
const offset = veryLoose[0].search(/=/);
|
|
2114
|
-
const headerStart = veryLoose.index + (offset >= 0 ? offset : 0);
|
|
2115
|
-
return { index: headerStart, headerLength: 0, parseable: false };
|
|
2116
|
-
}
|
|
2117
|
-
return null;
|
|
2118
|
-
}
|
|
2088
|
+
// The ===ACTIONS=== delimiter parser tiers (findCCActionsHeader,
|
|
2089
|
+
// findCCActionsPartialDelimiter, stripCCActionsForStream/Display) and the
|
|
2090
|
+
// _extractActionsJson Copilot fence-stripper were retired with the move to
|
|
2091
|
+
// direct /api/* tool calls. Action surfacing now flows entirely through the
|
|
2092
|
+
// X-CC-Turn-Id header → _ccTurnCreations → _buildSyntheticActionResultsForTurn.
|
|
2119
2093
|
|
|
2120
|
-
function
|
|
2121
|
-
if (!
|
|
2122
|
-
const
|
|
2123
|
-
const
|
|
2124
|
-
const
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
if (/^=+$/.test(trailingLine)) return lineStart;
|
|
2130
|
-
// Strict prefix of the canonical "===ACTIONS===" (case-insensitive so
|
|
2131
|
-
// "===act" and "===ACT" both strip).
|
|
2132
|
-
if (trailingLine.length >= 1 && trailingLine.length < delimiter.length
|
|
2133
|
-
&& delimiter.toLowerCase().startsWith(trailingLine.toLowerCase())) {
|
|
2134
|
-
return lineStart;
|
|
2135
|
-
}
|
|
2136
|
-
return -1;
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
function stripCCActionsForStream(text) {
|
|
2140
|
-
if (!text) return '';
|
|
2141
|
-
// Fast path: 95% of streamed chunks contain no '=' before any actions block
|
|
2142
|
-
// appears. Skip all regex work in that case.
|
|
2143
|
-
if (text.indexOf('===') < 0) return text;
|
|
2144
|
-
const header = findCCActionsHeader(text);
|
|
2145
|
-
if (header) return text.slice(0, header.index).trim();
|
|
2146
|
-
const partialIdx = findCCActionsPartialDelimiter(text);
|
|
2147
|
-
if (partialIdx >= 0) return text.slice(0, partialIdx).trimEnd();
|
|
2148
|
-
return text;
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
function stripCCActionsForDisplay(text) {
|
|
2152
|
-
if (!text) return '';
|
|
2153
|
-
const header = findCCActionsHeader(text);
|
|
2154
|
-
if (header) return text.slice(0, header.index).trim();
|
|
2155
|
-
const partialIdx = findCCActionsPartialDelimiter(text);
|
|
2156
|
-
if (partialIdx >= 0) return text.slice(0, partialIdx).trimEnd();
|
|
2157
|
-
return text;
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
// Issue #1834: non-Claude runtimes (Copilot/GPT) routinely wrap the action JSON
|
|
2161
|
-
// in ```json fences or append trailing prose ("Let me know if that helps!").
|
|
2162
|
-
// JSON.parse on the raw segment fails silently → actions dropped, user sees
|
|
2163
|
-
// inert text. This extractor pulls out the balanced JSON value (array or
|
|
2164
|
-
// object) regardless of fences, leading whitespace, or trailing junk so the
|
|
2165
|
-
// downstream parse can succeed. Returns null if no plausible JSON value is
|
|
2166
|
-
// present (caller surfaces the failure via _actionParseError).
|
|
2167
|
-
function _extractActionsJson(segment) {
|
|
2168
|
-
if (!segment) return null;
|
|
2169
|
-
let body = segment.trim();
|
|
2170
|
-
// Strip ```json / ``` fences (open + close). The model sometimes only emits
|
|
2171
|
-
// an opening fence (truncation), so handle both halves independently.
|
|
2172
|
-
body = body.replace(/^```[a-zA-Z0-9_-]*\s*\r?\n?/, '').replace(/\r?\n?```\s*$/, '').trim();
|
|
2173
|
-
if (!body) return null;
|
|
2174
|
-
const first = body.indexOf('[');
|
|
2175
|
-
const firstObj = body.indexOf('{');
|
|
2176
|
-
let start = -1;
|
|
2177
|
-
let openCh = '';
|
|
2178
|
-
let closeCh = '';
|
|
2179
|
-
if (first >= 0 && (firstObj < 0 || first <= firstObj)) {
|
|
2180
|
-
start = first; openCh = '['; closeCh = ']';
|
|
2181
|
-
} else if (firstObj >= 0) {
|
|
2182
|
-
start = firstObj; openCh = '{'; closeCh = '}';
|
|
2183
|
-
}
|
|
2184
|
-
if (start < 0) return null;
|
|
2185
|
-
let depth = 0;
|
|
2186
|
-
let inString = false;
|
|
2187
|
-
let escape = false;
|
|
2188
|
-
for (let i = start; i < body.length; i++) {
|
|
2189
|
-
const ch = body[i];
|
|
2190
|
-
if (escape) { escape = false; continue; }
|
|
2191
|
-
if (ch === '\\') { escape = true; continue; }
|
|
2192
|
-
if (ch === '"') { inString = !inString; continue; }
|
|
2193
|
-
if (inString) continue;
|
|
2194
|
-
if (ch === openCh) depth++;
|
|
2195
|
-
else if (ch === closeCh) {
|
|
2196
|
-
depth--;
|
|
2197
|
-
if (depth === 0) return body.slice(start, i + 1);
|
|
2198
|
-
}
|
|
2094
|
+
function normalizeMeetingParticipants(participants) {
|
|
2095
|
+
if (!Array.isArray(participants)) return [];
|
|
2096
|
+
const seen = new Set();
|
|
2097
|
+
const normalized = [];
|
|
2098
|
+
for (const participant of participants) {
|
|
2099
|
+
const id = String(participant || '').trim();
|
|
2100
|
+
if (!id || seen.has(id)) continue;
|
|
2101
|
+
seen.add(id);
|
|
2102
|
+
normalized.push(id);
|
|
2199
2103
|
}
|
|
2200
|
-
return
|
|
2104
|
+
return normalized;
|
|
2201
2105
|
}
|
|
2202
2106
|
|
|
2203
|
-
const CC_DISPATCH_ACTION_ALIASES = new Set(['fix', 'explore', 'review', 'test', 'implement', 'implement:large', 'ask', 'verify']);
|
|
2204
|
-
const CC_ACTION_INTENT_WORK_TYPES = new Set(['fix', 'implement', 'implement:large', 'explore', 'review', 'test', 'ask', 'verify']);
|
|
2205
2107
|
|
|
2206
2108
|
function getWorkItemPrRef(input) {
|
|
2207
2109
|
if (!input || typeof input !== 'object') return null;
|
|
@@ -2229,1677 +2131,6 @@ function extractPrRefFromText(value) {
|
|
|
2229
2131
|
return numberMatch ? numberMatch[1] : null;
|
|
2230
2132
|
}
|
|
2231
2133
|
|
|
2232
|
-
function normalizeCCAction(action) {
|
|
2233
|
-
if (!action || typeof action !== 'object') return action;
|
|
2234
|
-
if (typeof action.type !== 'string') return action;
|
|
2235
|
-
const type = action.type.trim().toLowerCase();
|
|
2236
|
-
if (type === 'dispatch') {
|
|
2237
|
-
return action.type === 'dispatch' ? action : { ...action, type: 'dispatch' };
|
|
2238
|
-
}
|
|
2239
|
-
if (!CC_DISPATCH_ACTION_ALIASES.has(type)) return action;
|
|
2240
|
-
return { ...action, type: 'dispatch', workType: action.workType || type };
|
|
2241
|
-
}
|
|
2242
|
-
|
|
2243
|
-
function _ccCleanIntentString(value, max = 500) {
|
|
2244
|
-
if (typeof value !== 'string') return '';
|
|
2245
|
-
let out = '';
|
|
2246
|
-
let lastWasSpace = true;
|
|
2247
|
-
for (const ch of value) {
|
|
2248
|
-
const isSpace = ch === '\0' || ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t';
|
|
2249
|
-
if (isSpace) {
|
|
2250
|
-
if (!lastWasSpace) out += ' ';
|
|
2251
|
-
lastWasSpace = true;
|
|
2252
|
-
} else {
|
|
2253
|
-
out += ch;
|
|
2254
|
-
lastWasSpace = false;
|
|
2255
|
-
}
|
|
2256
|
-
}
|
|
2257
|
-
return out.trim().slice(0, max);
|
|
2258
|
-
}
|
|
2259
|
-
|
|
2260
|
-
function _ccNormalizeIntentMetadata(meta) {
|
|
2261
|
-
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {};
|
|
2262
|
-
const out = {};
|
|
2263
|
-
for (const key of ['intent', 'type', 'title', 'description', 'priority', 'project', 'branchStrategy', 'branch_strategy', 'pr', 'targetPr', 'prId', 'prNumber', 'pullRequest', 'sourcePr', 'prUrl']) {
|
|
2264
|
-
const value = _ccCleanIntentString(meta[key], key === 'description' ? 2000 : 300);
|
|
2265
|
-
if (value) out[key] = value;
|
|
2266
|
-
}
|
|
2267
|
-
if (Array.isArray(meta.agents)) {
|
|
2268
|
-
out.agents = meta.agents.map(a => _ccCleanIntentString(a, 80)).filter(Boolean);
|
|
2269
|
-
}
|
|
2270
|
-
if (Array.isArray(meta.projects)) {
|
|
2271
|
-
out.projects = meta.projects.map(p => _ccCleanIntentString(p, 120)).filter(Boolean);
|
|
2272
|
-
}
|
|
2273
|
-
if (meta.fanout === true) out.fanout = true;
|
|
2274
|
-
return out;
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
function _messageExplicitlyRequestsMinionsOrchestration(message) {
|
|
2278
|
-
const normalized = _normalizeIntentText(message);
|
|
2279
|
-
if (!normalized.trim()) return false;
|
|
2280
|
-
if (_messageHasDelegationIntent(message)) return true;
|
|
2281
|
-
if (_intentHasAnyToken(normalized, ['minions']) &&
|
|
2282
|
-
(_messageHasMediumLargeWorkIntent(message) || _messageRequestsPlanIntent(message))) return true;
|
|
2283
|
-
if (_intentHasAnyToken(normalized, ['agent', 'agents']) && _messageHasMediumLargeWorkIntent(message)) return true;
|
|
2284
|
-
return _intentHasVerbObject(normalized, ['create', 'make', 'draft', 'write'], ['plan']) &&
|
|
2285
|
-
_intentHasAnyToken(normalized, ['minion', 'minions']);
|
|
2286
|
-
}
|
|
2287
|
-
|
|
2288
|
-
function _ccInferMessageActionIntent(message, source = 'command-center') {
|
|
2289
|
-
const normalized = _normalizeIntentText(message);
|
|
2290
|
-
if (!normalized.trim()) return null;
|
|
2291
|
-
if (source === 'doc-chat' && !_messageExplicitlyRequestsMinionsOrchestration(message)) return null;
|
|
2292
|
-
if (_messageRequestsPlanIntent(message)) {
|
|
2293
|
-
return { kind: 'plan' };
|
|
2294
|
-
}
|
|
2295
|
-
if (!_messageHasDelegationIntent(message) && !_messageHasMediumLargeWorkIntent(message, { source })) return null;
|
|
2296
|
-
return { kind: 'work-item', workType: _inferDelegatedWorkType(message) };
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
function _ccInferMetadataActionIntent(meta) {
|
|
2300
|
-
if (meta.intent === 'plan') return { kind: 'plan' };
|
|
2301
|
-
if (meta.intent === 'work-item' && CC_ACTION_INTENT_WORK_TYPES.has(String(meta.type || '').toLowerCase())) {
|
|
2302
|
-
return { kind: 'work-item', workType: String(meta.type).toLowerCase() };
|
|
2303
|
-
}
|
|
2304
|
-
return null;
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
function _ccActionIntentTargetText(message, intent) {
|
|
2308
|
-
const tokens = _intentTokens(_normalizeIntentText(_ccCleanIntentString(message, 500)));
|
|
2309
|
-
let start = 0;
|
|
2310
|
-
const skip = (terms) => {
|
|
2311
|
-
if (terms.includes(tokens[start])) {
|
|
2312
|
-
start++;
|
|
2313
|
-
return true;
|
|
2314
|
-
}
|
|
2315
|
-
return false;
|
|
2316
|
-
};
|
|
2317
|
-
skip(['please']);
|
|
2318
|
-
if (tokens[start] && tokens[start].startsWith('/')) start++;
|
|
2319
|
-
if (['dispatch', 'delegate', 'assign', 'queue', 'enqueue'].includes(tokens[start])) {
|
|
2320
|
-
start++;
|
|
2321
|
-
while (['a', 'an', 'the', 'work', 'item', 'task', 'agent', 'to'].includes(tokens[start])) start++;
|
|
2322
|
-
} else if (['create', 'open', 'file'].includes(tokens[start]) && tokens[start + 1] && ['a', 'an', 'the', 'work', 'item', 'task'].includes(tokens[start + 1])) {
|
|
2323
|
-
start++;
|
|
2324
|
-
while (['a', 'an', 'the', 'work', 'item', 'task', 'to', 'for'].includes(tokens[start])) start++;
|
|
2325
|
-
} else if (['have', 'ask', 'tell'].includes(tokens[start])) {
|
|
2326
|
-
start++;
|
|
2327
|
-
while (tokens[start] && !['to', 'fix', 'implement', 'review', 'test', 'explore', 'investigate', 'audit'].includes(tokens[start])) start++;
|
|
2328
|
-
if (tokens[start] === 'to') start++;
|
|
2329
|
-
}
|
|
2330
|
-
if (intent?.kind === 'plan') {
|
|
2331
|
-
while (['make', 'create', 'draft', 'write', 'design', 'plan', 'out', 'a', 'an', 'the', 'for', 'to', 'about', 'how'].includes(tokens[start])) start++;
|
|
2332
|
-
} else if (intent?.workType) {
|
|
2333
|
-
while (['fix', 'repair', 'resolve', 'patch', 'debug', 'implement', 'build', 'add', 'create', 'ship', 'review', 'inspect', 'audit', 'test', 'verify', 'validate', 'run', 'write', 'explore', 'investigate', 'research', 'analyze', 'analyse', 'understand', 'look', 'map', 'survey', 'a', 'an', 'the', 'for', 'to', 'of', 'on', 'about'].includes(tokens[start])) start++;
|
|
2334
|
-
}
|
|
2335
|
-
while (['for', 'to', 'of', 'on', 'about', 'a', 'an', 'the'].includes(tokens[start])) start++;
|
|
2336
|
-
return tokens.slice(start).join(' ');
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
|
-
function _ccIntentHasConcreteTarget(message, metadataTitle, intent) {
|
|
2340
|
-
const raw = String(message || '');
|
|
2341
|
-
const normalized = _normalizeIntentText(raw);
|
|
2342
|
-
const tokens = _intentTokens(normalized);
|
|
2343
|
-
if (raw.includes('http://') || raw.includes('https://')) return true;
|
|
2344
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
2345
|
-
if (['pr', 'issue'].includes(tokens[i]) && tokens[i + 1] && _intentTokenIsNumeric(tokens[i + 1])) return true;
|
|
2346
|
-
if (tokens[i] === 'pull' && tokens[i + 1] === 'request' && tokens[i + 2] && _intentTokenIsNumeric(tokens[i + 2])) return true;
|
|
2347
|
-
if (tokens[i] === 'w' && tokens[i + 1] && tokens[i + 1].length >= 2) return true;
|
|
2348
|
-
}
|
|
2349
|
-
const target = _ccActionIntentTargetText(metadataTitle || raw, intent).trim().toLowerCase();
|
|
2350
|
-
if (!target) return false;
|
|
2351
|
-
const targetTokens = _intentTokens(_normalizeIntentText(target));
|
|
2352
|
-
const generic = new Set(['this', 'that', 'it', 'these', 'those', 'same', 'above', 'here', 'there', 'one', 'thing', 'issue', 'bug', 'task', 'request', 'change', 'pr', 'pull', 'code', 'test', 'plan', 'doc', 'document', 'file']);
|
|
2353
|
-
if (targetTokens.length > 0 && targetTokens.every(token => generic.has(token))) return false;
|
|
2354
|
-
return target.length >= 3;
|
|
2355
|
-
}
|
|
2356
|
-
|
|
2357
|
-
function _ccIntentTitle(message, metadataTitle, intent) {
|
|
2358
|
-
const metaTitle = _ccCleanIntentString(metadataTitle, 300);
|
|
2359
|
-
if (metaTitle && _ccIntentHasConcreteTarget(message, metaTitle, intent)) return metaTitle;
|
|
2360
|
-
const cleaned = _ccCleanIntentString(message, 300);
|
|
2361
|
-
if (!cleaned) return '';
|
|
2362
|
-
const title = intent?.kind === 'plan'
|
|
2363
|
-
? cleaned
|
|
2364
|
-
: _delegatedWorkTitle(cleaned, intent?.workType || 'ask');
|
|
2365
|
-
return title.charAt(0).toUpperCase() + title.slice(1);
|
|
2366
|
-
}
|
|
2367
|
-
|
|
2368
|
-
function _ccFallbackMissingTargetError(intent) {
|
|
2369
|
-
const label = intent?.kind === 'plan' ? 'plan' : (intent?.workType || 'dispatch');
|
|
2370
|
-
return `Missing target for ${label} request. Specify a concrete title, PR/work item, file, or feature to ${label}.`;
|
|
2371
|
-
}
|
|
2372
|
-
|
|
2373
|
-
function _actionsWithIntentFallback(actions, opts = {}) {
|
|
2374
|
-
const { message = '', intentMetadata = null, source = 'command-center', filePath = null, title: docTitle = null, answerText = '', toolUses = [] } = opts;
|
|
2375
|
-
const existing = (Array.isArray(actions) ? actions.map(normalizeCCAction) : [])
|
|
2376
|
-
.filter(action => !_ccShouldSuppressAnsweredAskDispatch(action, { message, source, answerText, toolUses }));
|
|
2377
|
-
if (_messageRequestsDirectHandling(message, { answerText, toolUses })) return existing.filter(a => normalizeCCAction(a)?.type !== 'dispatch');
|
|
2378
|
-
if (existing.some(a => normalizeCCAction(a)?.type === 'dispatch')) return existing;
|
|
2379
|
-
if (existing.length > 0) return existing;
|
|
2380
|
-
const meta = _ccNormalizeIntentMetadata(intentMetadata);
|
|
2381
|
-
const messageIntent = _ccInferMessageActionIntent(message, source);
|
|
2382
|
-
const metadataIntent = source === 'doc-chat' ? null : _ccInferMetadataActionIntent(meta);
|
|
2383
|
-
const intent = messageIntent || metadataIntent;
|
|
2384
|
-
if (!intent) return _ensureDelegationForIntent(existing, { message, source, filePath, title: docTitle, answerText, toolUses });
|
|
2385
|
-
|
|
2386
|
-
const title = _ccIntentTitle(message, meta.title, intent);
|
|
2387
|
-
const hasTarget = _ccIntentHasConcreteTarget(message, meta.title, intent);
|
|
2388
|
-
const description = meta.description || _ccCleanIntentString(message, 2000);
|
|
2389
|
-
const common = {
|
|
2390
|
-
...(title ? { title } : {}),
|
|
2391
|
-
...(description ? { description } : {}),
|
|
2392
|
-
...(meta.priority ? { priority: meta.priority } : {}),
|
|
2393
|
-
...(meta.project ? { project: meta.project } : {}),
|
|
2394
|
-
...(Array.isArray(meta.agents) && meta.agents.length ? { agents: meta.agents } : {}),
|
|
2395
|
-
...(meta.fanout ? { scope: 'fan-out' } : {}),
|
|
2396
|
-
};
|
|
2397
|
-
if (intent.kind === 'plan') {
|
|
2398
|
-
const action = {
|
|
2399
|
-
type: 'plan',
|
|
2400
|
-
...common,
|
|
2401
|
-
branchStrategy: meta.branchStrategy || meta.branch_strategy || 'parallel',
|
|
2402
|
-
};
|
|
2403
|
-
if (!hasTarget) action._intentFallbackError = _ccFallbackMissingTargetError(intent);
|
|
2404
|
-
return [action];
|
|
2405
|
-
}
|
|
2406
|
-
const action = {
|
|
2407
|
-
type: 'dispatch',
|
|
2408
|
-
workType: intent.workType,
|
|
2409
|
-
...common,
|
|
2410
|
-
};
|
|
2411
|
-
const prRef = getWorkItemPrRef(meta) || extractPrRefFromText([message, meta.title, meta.description].filter(Boolean).join('\n'));
|
|
2412
|
-
if (prRef && isPrTargetedWorkType(intent.workType)) action.pr = prRef;
|
|
2413
|
-
if (!hasTarget) action._intentFallbackError = _ccFallbackMissingTargetError(intent);
|
|
2414
|
-
if (_ccShouldSuppressAnsweredAskDispatch(action, { message, source, answerText, toolUses })) return existing;
|
|
2415
|
-
return [action];
|
|
2416
|
-
}
|
|
2417
|
-
|
|
2418
|
-
function parseCCActions(text) {
|
|
2419
|
-
let actions = [];
|
|
2420
|
-
let displayText = stripCCActionsForDisplay(text);
|
|
2421
|
-
let parseError = null;
|
|
2422
|
-
const header = findCCActionsHeader(text);
|
|
2423
|
-
let segment = '';
|
|
2424
|
-
if (header) {
|
|
2425
|
-
displayText = text.slice(0, header.index).trim();
|
|
2426
|
-
if (header.parseable) {
|
|
2427
|
-
segment = text.slice(header.index + header.headerLength);
|
|
2428
|
-
const jsonStr = _extractActionsJson(segment);
|
|
2429
|
-
if (jsonStr) {
|
|
2430
|
-
try {
|
|
2431
|
-
const parsed = JSON.parse(jsonStr);
|
|
2432
|
-
if (Array.isArray(parsed)) {
|
|
2433
|
-
actions = parsed;
|
|
2434
|
-
} else {
|
|
2435
|
-
parseError = 'actions JSON must be an array after ===ACTIONS=== delimiter';
|
|
2436
|
-
}
|
|
2437
|
-
} catch (e) {
|
|
2438
|
-
parseError = e.message || 'invalid JSON';
|
|
2439
|
-
}
|
|
2440
|
-
} else if (segment.trim()) {
|
|
2441
|
-
parseError = 'no JSON value found after ===ACTIONS=== delimiter';
|
|
2442
|
-
}
|
|
2443
|
-
} else {
|
|
2444
|
-
// Loose/very-loose match: delimiter present but malformed (e.g. extra
|
|
2445
|
-
// equals, lowercase, trailing punct). Surface so the client banner fires
|
|
2446
|
-
// instead of silently dropping actions — user resends with the strict
|
|
2447
|
-
// shape.
|
|
2448
|
-
parseError = 'Malformed ===ACTIONS=== delimiter (extra equals, lowercase, or trailing punctuation). Actions silently discarded — fix the model output.';
|
|
2449
|
-
}
|
|
2450
|
-
}
|
|
2451
|
-
actions = actions.map(normalizeCCAction);
|
|
2452
|
-
const result = { text: displayText, actions };
|
|
2453
|
-
if (parseError && actions.length === 0) {
|
|
2454
|
-
result._actionParseError = parseError;
|
|
2455
|
-
// Visibility for the engine log — shared.log applies SEC-09 redaction before persistence.
|
|
2456
|
-
try {
|
|
2457
|
-
const snippet = (segment.trim() || '').slice(0, 200);
|
|
2458
|
-
if (typeof shared !== 'undefined' && shared && typeof shared.log === 'function') {
|
|
2459
|
-
shared.log('warn', `CC action JSON parse failed: ${parseError} — segment: ${snippet}`);
|
|
2460
|
-
}
|
|
2461
|
-
} catch { /* logging is best-effort */ }
|
|
2462
|
-
}
|
|
2463
|
-
return result;
|
|
2464
|
-
}
|
|
2465
|
-
|
|
2466
|
-
const DELEGATION_ACTION_TERMS = ['dispatch', 'delegate', 'assign', 'queue', 'enqueue'];
|
|
2467
|
-
const DELEGATION_REQUEST_PREFIX_TERMS = ['please', 'can', 'could', 'would', 'will', 'should', 'you', 'cc', 'minions', 'minion'];
|
|
2468
|
-
const DELEGATION_PERSON_ACTION_TERMS = ['ask', 'tell', 'have'];
|
|
2469
|
-
// Subset of delegation action terms that double as common nouns in status
|
|
2470
|
-
// questions (e.g. "queue status", "dispatch contents"). When followed by a
|
|
2471
|
-
// status-noun follow-on these are noun usage, not imperative delegation.
|
|
2472
|
-
const DELEGATION_AMBIGUOUS_NOUN_TERMS = new Set(['dispatch', 'queue']);
|
|
2473
|
-
const DELEGATION_NON_VERB_FOLLOW_ONS = new Set([
|
|
2474
|
-
'status', 'contents', 'content', 'state', 'count', 'list', 'items', 'item',
|
|
2475
|
-
'size', 'length', 'depth', 'health', 'info', 'order', 'queue', 'history',
|
|
2476
|
-
'log', 'logs', 'summary', 'overview', 'report', 'is', 'was', 'are', 'were',
|
|
2477
|
-
'has', 'have', 'looks', 'shows',
|
|
2478
|
-
]);
|
|
2479
|
-
const DELEGATION_MINIONS_PHRASES = ['have minions', 'ask minions', 'tell minions', 'hand off', 'hand it off', 'hand this off', 'send to agent', 'send it to agent', 'send this to agent'];
|
|
2480
|
-
const MEDIUM_INVESTIGATION_TERMS = ['audit', 'investigate', 'research', 'explore', 'analyze', 'analyse'];
|
|
2481
|
-
const MEDIUM_INVESTIGATION_PHRASES = ['deep dive', 'root cause'];
|
|
2482
|
-
const MEDIUM_IMPLEMENT_TERMS = ['implement', 'fix', 'debug', 'repair', 'refactor', 'migrate', 'harden', 'redesign'];
|
|
2483
|
-
const MEDIUM_BUILD_VERBS = ['build', 'add', 'create'];
|
|
2484
|
-
const MEDIUM_BUILD_OBJECTS = ['feature', 'support', 'endpoint', 'api', 'ui', 'page', 'component', 'flow', 'integration', 'automation', 'test', 'tests', 'tool', 'command', 'dashboard', 'service', 'module'];
|
|
2485
|
-
const MEDIUM_REVIEW_TERMS = ['review', 'test', 'verify', 'validate'];
|
|
2486
|
-
const MEDIUM_SIZE_TERMS = ['medium', 'large', 'larger'];
|
|
2487
|
-
const MEDIUM_SIZE_PHRASES = ['cross cutting', 'multi file', 'multi step', 'multi stage', 'multi module'];
|
|
2488
|
-
const PLAN_INTENT_TERMS = ['plan'];
|
|
2489
|
-
const PLAN_CREATE_TERMS = ['make', 'create', 'draft', 'write', 'design'];
|
|
2490
|
-
const PLAN_CREATE_PHRASES = ['come up with'];
|
|
2491
|
-
const DOC_CHAT_DIRECT_DOC_TERMS = ['summarize', 'summary', 'quote', 'extract', 'rewrite', 'reword', 'copyedit', 'proofread', 'format', 'typo'];
|
|
2492
|
-
const DOC_CHAT_DOC_ACTION_TERMS = ['edit', 'update', 'change', 'review', 'check'];
|
|
2493
|
-
const DOC_CHAT_DOC_OBJECTS = ['document', 'doc', 'paragraph', 'section', 'selection', 'text', 'wording'];
|
|
2494
|
-
const DIRECT_QUICK_TERMS = ['quick', 'simple', 'small'];
|
|
2495
|
-
const DIRECT_QUICK_OBJECTS = ['question', 'answer', 'lookup', 'check'];
|
|
2496
|
-
const DIRECT_REPLY_TERMS = ['answer', 'respond', 'reply'];
|
|
2497
|
-
const DIRECT_REPLY_TARGETS = ['directly', 'inline', 'here'];
|
|
2498
|
-
const DIRECT_SELF_TERMS = ['do', 'handle', 'answer', 'fix', 'edit', 'update', 'change', 'review', 'check'];
|
|
2499
|
-
const DIRECT_SELF_TARGETS = ['yourself', 'directly'];
|
|
2500
|
-
const DIRECT_HANDLING_PHRASES = [
|
|
2501
|
-
'you do it yourself', 'you handle it yourself', 'you handle this yourself',
|
|
2502
|
-
'cc do it yourself', 'doc chat do it yourself', 'doc chat handle it yourself',
|
|
2503
|
-
'do not dispatch', 'dont dispatch', 'do not delegate', 'dont delegate',
|
|
2504
|
-
'do not assign', 'dont assign', 'do not queue', 'dont queue',
|
|
2505
|
-
'do not enqueue', 'dont enqueue', 'do not create work item', 'dont create work item',
|
|
2506
|
-
'no dispatch', 'no delegate', 'no delegation', 'no work item',
|
|
2507
|
-
'without dispatching', 'without delegating', 'without creating work item',
|
|
2508
|
-
];
|
|
2509
|
-
const ANSWERED_ASK_MIN_CHARS = 80;
|
|
2510
|
-
const ANSWERED_ASK_INCOMPLETE_PHRASES = [
|
|
2511
|
-
'i will dispatch', 'ill dispatch', 'i will delegate', 'ill delegate',
|
|
2512
|
-
'i will ask', 'ill ask', 'need to dispatch', 'needs to dispatch',
|
|
2513
|
-
'need to delegate', 'needs to delegate', 'need an agent', 'needs an agent',
|
|
2514
|
-
'hand off', 'hand this off', 'deeper investigation', 'further investigation',
|
|
2515
|
-
'cannot answer', 'cant answer', 'could not answer', 'couldnt answer',
|
|
2516
|
-
'not enough information',
|
|
2517
|
-
];
|
|
2518
|
-
|
|
2519
|
-
function _isIntentWordChar(ch) {
|
|
2520
|
-
const code = ch.charCodeAt(0);
|
|
2521
|
-
return (code >= 48 && code <= 57) || (code >= 97 && code <= 122);
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
|
-
function _normalizeIntentText(message) {
|
|
2525
|
-
const raw = String(message || '').toLowerCase();
|
|
2526
|
-
let out = ' ';
|
|
2527
|
-
let lastWasSpace = true;
|
|
2528
|
-
for (const ch of raw) {
|
|
2529
|
-
if (ch === '\'' || ch === '\u2019') continue;
|
|
2530
|
-
if (_isIntentWordChar(ch)) {
|
|
2531
|
-
out += ch;
|
|
2532
|
-
lastWasSpace = false;
|
|
2533
|
-
} else if (!lastWasSpace) {
|
|
2534
|
-
out += ' ';
|
|
2535
|
-
lastWasSpace = true;
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
return lastWasSpace ? out : out + ' ';
|
|
2539
|
-
}
|
|
2540
|
-
|
|
2541
|
-
function _intentTokens(normalized) {
|
|
2542
|
-
const text = String(normalized || '').trim();
|
|
2543
|
-
return text ? text.split(' ') : [];
|
|
2544
|
-
}
|
|
2545
|
-
|
|
2546
|
-
function _intentHasAnyToken(normalized, terms) {
|
|
2547
|
-
const tokens = new Set(_intentTokens(normalized));
|
|
2548
|
-
return terms.some(term => tokens.has(term));
|
|
2549
|
-
}
|
|
2550
|
-
|
|
2551
|
-
function _intentHasPhrase(normalized, phrase) {
|
|
2552
|
-
return String(normalized || '').includes(` ${phrase} `);
|
|
2553
|
-
}
|
|
2554
|
-
|
|
2555
|
-
function _intentHasAnyPhrase(normalized, phrases) {
|
|
2556
|
-
return phrases.some(phrase => _intentHasPhrase(normalized, phrase));
|
|
2557
|
-
}
|
|
2558
|
-
|
|
2559
|
-
function _intentTokenIsNumeric(token) {
|
|
2560
|
-
if (!token) return false;
|
|
2561
|
-
for (const ch of String(token)) {
|
|
2562
|
-
const code = ch.charCodeAt(0);
|
|
2563
|
-
if (code < 48 || code > 57) return false;
|
|
2564
|
-
}
|
|
2565
|
-
return true;
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
|
-
function _intentHasVerbObject(normalized, verbs, objects) {
|
|
2569
|
-
const verbSet = new Set(verbs);
|
|
2570
|
-
const objectSet = new Set(objects);
|
|
2571
|
-
const filler = new Set(['a', 'an', 'the', 'this', 'that', 'it', 'new', 'proper', 'some']);
|
|
2572
|
-
const tokens = _intentTokens(normalized);
|
|
2573
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
2574
|
-
if (!verbSet.has(tokens[i])) continue;
|
|
2575
|
-
for (let j = i + 1; j < tokens.length && j <= i + 4; j++) {
|
|
2576
|
-
if (filler.has(tokens[j])) continue;
|
|
2577
|
-
if (objectSet.has(tokens[j])) return true;
|
|
2578
|
-
break;
|
|
2579
|
-
}
|
|
2580
|
-
}
|
|
2581
|
-
return false;
|
|
2582
|
-
}
|
|
2583
|
-
|
|
2584
|
-
function _messageRequestsPlanIntent(message) {
|
|
2585
|
-
const normalized = _normalizeIntentText(message);
|
|
2586
|
-
const tokens = _intentTokens(normalized);
|
|
2587
|
-
if (!tokens.length) return false;
|
|
2588
|
-
if (tokens[0] === 'plan') return true;
|
|
2589
|
-
if (_intentHasAnyPhrase(normalized, PLAN_CREATE_PHRASES) && _intentHasAnyToken(normalized, PLAN_INTENT_TERMS)) return true;
|
|
2590
|
-
if (_intentHasVerbObject(normalized, PLAN_CREATE_TERMS, PLAN_INTENT_TERMS)) return true;
|
|
2591
|
-
return _intentHasAnyToken(normalized, PLAN_INTENT_TERMS) &&
|
|
2592
|
-
_intentHasAnyToken(normalized, ['this', 'that', 'it', 'for', 'how', 'out']);
|
|
2593
|
-
}
|
|
2594
|
-
|
|
2595
|
-
function _messageHasDelegationIntent(message) {
|
|
2596
|
-
const normalized = _normalizeIntentText(message);
|
|
2597
|
-
if (!normalized.trim()) return false;
|
|
2598
|
-
if (_intentHasAnyToken(normalized, DELEGATION_ACTION_TERMS)) return true;
|
|
2599
|
-
if (_intentHasPhrase(normalized, 'work item') && _intentHasAnyToken(normalized, ['create', 'open', 'add'])) return true;
|
|
2600
|
-
return _intentHasAnyPhrase(normalized, DELEGATION_MINIONS_PHRASES);
|
|
2601
|
-
}
|
|
2602
|
-
|
|
2603
|
-
function _delegationTokenIsImperativeVerb(tokens, idx) {
|
|
2604
|
-
const term = tokens[idx];
|
|
2605
|
-
if (!term) return false;
|
|
2606
|
-
if (!DELEGATION_AMBIGUOUS_NOUN_TERMS.has(term)) return true;
|
|
2607
|
-
const next = tokens[idx + 1];
|
|
2608
|
-
if (!next) return true;
|
|
2609
|
-
return !DELEGATION_NON_VERB_FOLLOW_ONS.has(next);
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
function _messageExplicitlyRequestsDelegation(message) {
|
|
2613
|
-
const normalized = _normalizeIntentText(message);
|
|
2614
|
-
const tokens = _intentTokens(normalized);
|
|
2615
|
-
if (!tokens.length) return false;
|
|
2616
|
-
if (_intentHasAnyPhrase(normalized, DELEGATION_MINIONS_PHRASES)) return true;
|
|
2617
|
-
if (_intentHasPhrase(normalized, 'work item') && _intentHasAnyToken(normalized, ['create', 'open', 'add'])) return true;
|
|
2618
|
-
|
|
2619
|
-
let i = 0;
|
|
2620
|
-
while (DELEGATION_REQUEST_PREFIX_TERMS.includes(tokens[i])) i++;
|
|
2621
|
-
if (DELEGATION_ACTION_TERMS.includes(tokens[i]) && _delegationTokenIsImperativeVerb(tokens, i)) return true;
|
|
2622
|
-
if (DELEGATION_PERSON_ACTION_TERMS.includes(tokens[i])) {
|
|
2623
|
-
return tokens.slice(i + 1, i + 7).includes('to');
|
|
2624
|
-
}
|
|
2625
|
-
for (let j = 0; j < tokens.length && j <= 6; j++) {
|
|
2626
|
-
if (!DELEGATION_ACTION_TERMS.includes(tokens[j])) continue;
|
|
2627
|
-
if (!_delegationTokenIsImperativeVerb(tokens, j)) continue;
|
|
2628
|
-
const prefix = tokens.slice(0, j);
|
|
2629
|
-
if (prefix.includes('you') || prefix.includes('minions') || prefix.includes('minion')) return true;
|
|
2630
|
-
}
|
|
2631
|
-
return false;
|
|
2632
|
-
}
|
|
2633
|
-
|
|
2634
|
-
function _messageRequestsDirectHandling(message, opts = {}) {
|
|
2635
|
-
const normalized = _normalizeIntentText(message);
|
|
2636
|
-
if (!normalized.trim()) return false;
|
|
2637
|
-
if (_messageRequestsInformationOnly(message) && !_unansweredInvestigationSignal(opts)) return true;
|
|
2638
|
-
if (_intentHasAnyPhrase(normalized, DIRECT_HANDLING_PHRASES)) return true;
|
|
2639
|
-
if (_intentHasAnyToken(normalized, DIRECT_QUICK_TERMS) && _intentHasAnyToken(normalized, DIRECT_QUICK_OBJECTS)) return true;
|
|
2640
|
-
if (_intentHasVerbObject(normalized, DIRECT_REPLY_TERMS, DIRECT_REPLY_TARGETS)) return true;
|
|
2641
|
-
return _intentHasVerbObject(normalized, DIRECT_SELF_TERMS, DIRECT_SELF_TARGETS);
|
|
2642
|
-
}
|
|
2643
|
-
|
|
2644
|
-
function _unansweredInvestigationSignal({ answerText = '', toolUses = [] } = {}) {
|
|
2645
|
-
const answerLen = String(answerText || '').trim().length;
|
|
2646
|
-
const toolCount = Array.isArray(toolUses) ? toolUses.length : 0;
|
|
2647
|
-
return toolCount >= 4 && answerLen < ANSWERED_ASK_MIN_CHARS;
|
|
2648
|
-
}
|
|
2649
|
-
|
|
2650
|
-
function _messageRequestsInformationOnly(message) {
|
|
2651
|
-
const normalized = _normalizeIntentText(message);
|
|
2652
|
-
const tokens = _intentTokens(normalized);
|
|
2653
|
-
if (!tokens.length) return false;
|
|
2654
|
-
if (['what', 'which', 'who', 'when', 'where', 'why', 'how'].includes(tokens[0])) return true;
|
|
2655
|
-
if (['show', 'list'].includes(tokens[0])) return true;
|
|
2656
|
-
if (tokens[0] === 'tell' && tokens[1] === 'me') return true;
|
|
2657
|
-
if (String(message || '').trim().endsWith('?') && !_intentHasAnyToken(normalized, DELEGATION_ACTION_TERMS)) return true;
|
|
2658
|
-
return false;
|
|
2659
|
-
}
|
|
2660
|
-
|
|
2661
|
-
function _messageIsSmallDocOnlyRequest(normalized) {
|
|
2662
|
-
if (_intentHasAnyToken(normalized, DOC_CHAT_DIRECT_DOC_TERMS)) return true;
|
|
2663
|
-
return _intentHasVerbObject(normalized, DOC_CHAT_DOC_ACTION_TERMS, DOC_CHAT_DOC_OBJECTS);
|
|
2664
|
-
}
|
|
2665
|
-
|
|
2666
|
-
function _messageHasMediumLargeWorkIntent(message, { source = 'command-center' } = {}) {
|
|
2667
|
-
const normalized = _normalizeIntentText(message);
|
|
2668
|
-
if (!normalized.trim()) return false;
|
|
2669
|
-
if (_messageRequestsDirectHandling(message)) return false;
|
|
2670
|
-
if (source === 'doc-chat' && _messageIsSmallDocOnlyRequest(normalized)) return false;
|
|
2671
|
-
return _intentHasAnyToken(normalized, MEDIUM_INVESTIGATION_TERMS) ||
|
|
2672
|
-
_intentHasAnyPhrase(normalized, MEDIUM_INVESTIGATION_PHRASES) ||
|
|
2673
|
-
_intentHasAnyToken(normalized, MEDIUM_IMPLEMENT_TERMS) ||
|
|
2674
|
-
_intentHasVerbObject(normalized, MEDIUM_BUILD_VERBS, MEDIUM_BUILD_OBJECTS) ||
|
|
2675
|
-
_intentHasAnyToken(normalized, MEDIUM_REVIEW_TERMS) ||
|
|
2676
|
-
_intentHasAnyToken(normalized, MEDIUM_SIZE_TERMS) ||
|
|
2677
|
-
_intentHasAnyPhrase(normalized, MEDIUM_SIZE_PHRASES);
|
|
2678
|
-
}
|
|
2679
|
-
|
|
2680
|
-
function _inferDelegatedWorkType(message) {
|
|
2681
|
-
const normalized = _normalizeIntentText(message);
|
|
2682
|
-
if (_intentHasAnyToken(normalized, ['test', 'verify', 'validate']) || _intentHasPhrase(normalized, 'build and test')) return 'test';
|
|
2683
|
-
if (_intentHasAnyToken(normalized, ['review']) || _intentHasPhrase(normalized, 'code review')) return 'review';
|
|
2684
|
-
if (_intentHasAnyToken(normalized, ['fix', 'debug', 'repair', 'bug', 'broken', 'failing', 'failure', 'regression']) || _intentHasPhrase(normalized, 'root cause')) return 'fix';
|
|
2685
|
-
if (_intentHasAnyToken(normalized, ['implement', 'refactor', 'migrate', 'harden', 'redesign']) ||
|
|
2686
|
-
_intentHasVerbObject(normalized, MEDIUM_BUILD_VERBS, MEDIUM_BUILD_OBJECTS)) return 'implement';
|
|
2687
|
-
if (_intentHasAnyToken(normalized, MEDIUM_INVESTIGATION_TERMS) || _intentHasAnyPhrase(normalized, MEDIUM_INVESTIGATION_PHRASES)) return 'explore';
|
|
2688
|
-
return 'ask';
|
|
2689
|
-
}
|
|
2690
|
-
|
|
2691
|
-
function _ccAnswerLooksCompleteForAsk(answerText, toolUses = []) {
|
|
2692
|
-
if (!Array.isArray(toolUses) || toolUses.length === 0) return false;
|
|
2693
|
-
const answer = _collapseWhitespace(stripCCActionSyntax(answerText || ''));
|
|
2694
|
-
if (answer.length < ANSWERED_ASK_MIN_CHARS) return false;
|
|
2695
|
-
const normalized = _normalizeIntentText(answer);
|
|
2696
|
-
if (_intentHasAnyPhrase(normalized, ANSWERED_ASK_INCOMPLETE_PHRASES)) return false;
|
|
2697
|
-
if (_intentHasVerbObject(normalized, ['need', 'needs', 'require', 'requires'], ['agent', 'delegation', 'investigation', 'research'])) return false;
|
|
2698
|
-
return true;
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
|
-
function _ccShouldSuppressAnsweredAskDispatch(action, opts = {}) {
|
|
2702
|
-
const normalized = normalizeCCAction(action);
|
|
2703
|
-
if (normalized?.type !== 'dispatch') return false;
|
|
2704
|
-
if (String(normalized.workType || '').trim().toLowerCase() !== 'ask') return false;
|
|
2705
|
-
if (_messageExplicitlyRequestsDelegation(opts.message)) return false;
|
|
2706
|
-
return _ccAnswerLooksCompleteForAsk(opts.answerText, opts.toolUses);
|
|
2707
|
-
}
|
|
2708
|
-
|
|
2709
|
-
function _collapseWhitespace(text) {
|
|
2710
|
-
let out = '';
|
|
2711
|
-
let lastWasSpace = true;
|
|
2712
|
-
for (const ch of String(text || '')) {
|
|
2713
|
-
const isSpace = ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t';
|
|
2714
|
-
if (isSpace) {
|
|
2715
|
-
if (!lastWasSpace) out += ' ';
|
|
2716
|
-
lastWasSpace = true;
|
|
2717
|
-
} else {
|
|
2718
|
-
out += ch;
|
|
2719
|
-
lastWasSpace = false;
|
|
2720
|
-
}
|
|
2721
|
-
}
|
|
2722
|
-
return out.trim();
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
|
-
function _stripEdgeQuotes(text) {
|
|
2726
|
-
const quotes = new Set(['"', "'", '`']);
|
|
2727
|
-
let start = 0;
|
|
2728
|
-
let end = String(text || '').length;
|
|
2729
|
-
while (start < end && quotes.has(text[start])) start++;
|
|
2730
|
-
while (end > start && quotes.has(text[end - 1])) end--;
|
|
2731
|
-
return text.slice(start, end);
|
|
2732
|
-
}
|
|
2733
|
-
|
|
2734
|
-
function _delegatedWorkTitle(message, workType) {
|
|
2735
|
-
const compact = _stripEdgeQuotes(_collapseWhitespace(String(message || '')));
|
|
2736
|
-
const trimmed = compact.length > 90 ? compact.slice(0, 87).trimEnd() + '...' : compact;
|
|
2737
|
-
const fallback = {
|
|
2738
|
-
fix: 'Fix requested issue',
|
|
2739
|
-
review: 'Review requested work',
|
|
2740
|
-
test: 'Test requested work',
|
|
2741
|
-
implement: 'Implement requested work',
|
|
2742
|
-
'implement:large': 'Implement requested work',
|
|
2743
|
-
explore: 'Investigate requested work',
|
|
2744
|
-
ask: 'Answer requested query',
|
|
2745
|
-
verify: 'Verify requested work',
|
|
2746
|
-
}[workType] || 'Handle requested work';
|
|
2747
|
-
if (!trimmed) return fallback;
|
|
2748
|
-
const prefix = {
|
|
2749
|
-
fix: 'Fix',
|
|
2750
|
-
review: 'Review',
|
|
2751
|
-
test: 'Test',
|
|
2752
|
-
implement: 'Implement',
|
|
2753
|
-
'implement:large': 'Implement',
|
|
2754
|
-
explore: 'Investigate',
|
|
2755
|
-
ask: 'Answer',
|
|
2756
|
-
verify: 'Verify',
|
|
2757
|
-
}[workType] || 'Handle';
|
|
2758
|
-
const lower = trimmed.toLowerCase();
|
|
2759
|
-
const prefixLower = prefix.toLowerCase();
|
|
2760
|
-
if (lower === prefixLower || lower.startsWith(prefixLower + ' ')) return trimmed;
|
|
2761
|
-
return `${prefix}: ${trimmed}`;
|
|
2762
|
-
}
|
|
2763
|
-
|
|
2764
|
-
function _priorityFromDelegationMessage(message) {
|
|
2765
|
-
const normalized = _normalizeIntentText(message);
|
|
2766
|
-
if (_intentHasAnyToken(normalized, ['urgent', 'asap', 'critical', 'blocker', 'p0', 'p1']) || _intentHasPhrase(normalized, 'high priority')) return 'high';
|
|
2767
|
-
if (_intentHasPhrase(normalized, 'low priority') || _intentHasPhrase(normalized, 'when you can') || _intentHasAnyToken(normalized, ['whenever'])) return 'low';
|
|
2768
|
-
return 'medium';
|
|
2769
|
-
}
|
|
2770
|
-
|
|
2771
|
-
function _inferDelegationActionFromMessage(message, { source = 'command-center', filePath = null, title = null, answerText = '', toolUses = [] } = {}) {
|
|
2772
|
-
if (_messageRequestsDirectHandling(message, { answerText, toolUses })) return null;
|
|
2773
|
-
const explicit = _messageHasDelegationIntent(message);
|
|
2774
|
-
const mediumLarge = _messageHasMediumLargeWorkIntent(message, { source });
|
|
2775
|
-
const toolCount = Array.isArray(toolUses) ? toolUses.length : 0;
|
|
2776
|
-
if (!explicit && !mediumLarge && toolCount < 4) return null;
|
|
2777
|
-
|
|
2778
|
-
const workType = _inferDelegatedWorkType(message);
|
|
2779
|
-
const inferredProject = source === 'doc-chat' && filePath && typeof _inferDocChatProject === 'function'
|
|
2780
|
-
? _inferDocChatProject(filePath)
|
|
2781
|
-
: null;
|
|
2782
|
-
const context = [];
|
|
2783
|
-
context.push(`${source === 'doc-chat' ? 'Doc-chat' : 'Command Center'} detected ${explicit ? 'an explicit dispatch/delegation request' : (toolCount >= 4 ? 'a medium-sized tool-using request' : 'a medium/large work request')} and routed it through the engine.`);
|
|
2784
|
-
if (filePath) context.push(`Document: ${filePath}`);
|
|
2785
|
-
if (title && title !== filePath) context.push(`Title: ${title}`);
|
|
2786
|
-
if (answerText && String(answerText).trim()) context.push(`Assistant draft before delegation:\n${String(answerText).trim().slice(0, 1200)}`);
|
|
2787
|
-
context.push(`Original request:\n${String(message || '').trim()}`);
|
|
2788
|
-
|
|
2789
|
-
return {
|
|
2790
|
-
type: 'dispatch',
|
|
2791
|
-
title: _delegatedWorkTitle(message, workType),
|
|
2792
|
-
workType,
|
|
2793
|
-
priority: _priorityFromDelegationMessage(message),
|
|
2794
|
-
description: context.join('\n\n'),
|
|
2795
|
-
...(inferredProject ? { project: inferredProject } : {}),
|
|
2796
|
-
_autoDelegated: true,
|
|
2797
|
-
};
|
|
2798
|
-
}
|
|
2799
|
-
|
|
2800
|
-
function _ensureDelegationForIntent(actions, opts = {}) {
|
|
2801
|
-
const list = (Array.isArray(actions) ? actions.map(normalizeCCAction) : [])
|
|
2802
|
-
.filter(action => !_ccShouldSuppressAnsweredAskDispatch(action, opts));
|
|
2803
|
-
if (_messageRequestsDirectHandling(opts.message, opts)) return list.filter(a => normalizeCCAction(a)?.type !== 'dispatch');
|
|
2804
|
-
if (list.some(a => normalizeCCAction(a)?.type === 'dispatch')) return list;
|
|
2805
|
-
if (list.length > 0) return list;
|
|
2806
|
-
const inferred = _inferDelegationActionFromMessage(opts.message, opts);
|
|
2807
|
-
if (_ccShouldSuppressAnsweredAskDispatch(inferred, opts)) return list;
|
|
2808
|
-
return inferred ? [...list, inferred] : list;
|
|
2809
|
-
}
|
|
2810
|
-
|
|
2811
|
-
function stripCCActionSyntax(text) {
|
|
2812
|
-
if (!text) return '';
|
|
2813
|
-
let displayText = text;
|
|
2814
|
-
const header = findCCActionsHeader(text);
|
|
2815
|
-
if (header) {
|
|
2816
|
-
displayText = text.slice(0, header.index).trim();
|
|
2817
|
-
} else {
|
|
2818
|
-
// C-2: doc-chat streaming must also catch partial ===ACTIONS===
|
|
2819
|
-
// delimiters at the chunk tail — without this, a tail like `===ACT`
|
|
2820
|
-
// leaks raw to the modal until the next chunk completes the header.
|
|
2821
|
-
const partialIdx = findCCActionsPartialDelimiter(displayText);
|
|
2822
|
-
if (partialIdx >= 0) displayText = displayText.slice(0, partialIdx).trimEnd();
|
|
2823
|
-
}
|
|
2824
|
-
return displayText.replace(/`{3,}\s*action\s*\r?\n[\s\S]*?`{3,}\n?/g, '').trim();
|
|
2825
|
-
}
|
|
2826
|
-
|
|
2827
|
-
function _escapeRegExp(str) {
|
|
2828
|
-
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
|
-
function findLineBoundedDelimiter(text, delimiter) {
|
|
2832
|
-
const re = new RegExp(`(?:^|\\r?\\n)${_escapeRegExp(delimiter)}[ \\t]*(?=\\r?\\n|$)`);
|
|
2833
|
-
const match = re.exec(text || '');
|
|
2834
|
-
if (!match) return null;
|
|
2835
|
-
return {
|
|
2836
|
-
index: match.index + match[0].indexOf(delimiter),
|
|
2837
|
-
length: delimiter.length,
|
|
2838
|
-
};
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
function findDocChatDocumentDelimiter(text) {
|
|
2842
|
-
return findLineBoundedDelimiter(text, DOC_CHAT_DOCUMENT_DELIMITER)
|
|
2843
|
-
|| findLineBoundedDelimiter(text, LEGACY_DOC_CHAT_DOCUMENT_DELIMITER);
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
|
-
function markdownFenceFor(content) {
|
|
2847
|
-
const runs = String(content || '').match(/`+/g) || [];
|
|
2848
|
-
const maxRun = runs.reduce((max, run) => Math.max(max, run.length), 0);
|
|
2849
|
-
return '`'.repeat(Math.max(4, maxRun + 1));
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
|
-
function fencedUntrustedBlock(label, content) {
|
|
2853
|
-
const value = String(content || '');
|
|
2854
|
-
const fence = markdownFenceFor(value);
|
|
2855
|
-
return `### ${label}\n${fence}text\n${value}\n${fence}`;
|
|
2856
|
-
}
|
|
2857
|
-
|
|
2858
|
-
function _messageExplicitlyRequestsMonitoring(message) {
|
|
2859
|
-
const text = String(message || '').toLowerCase();
|
|
2860
|
-
if (!text.trim()) return false;
|
|
2861
|
-
if (/\b(?:do\s+not|don't|dont|never|without|no\s+need\s+to)\s+(?:monitor(?:ing)?|watch(?:ing)?|poll(?:ing)?|check(?:ing)?|notify(?:ing)?|ping(?:ing)?|keep(?:ing)?\s+an\s+eye)\b/.test(text)) {
|
|
2862
|
-
return false;
|
|
2863
|
-
}
|
|
2864
|
-
return [
|
|
2865
|
-
/\bmonitor(?:ing)?\b/,
|
|
2866
|
-
/\bwatch(?:ing)?\b/,
|
|
2867
|
-
/\bkeep(?:ing)?\s+an\s+eye\s+on\b/,
|
|
2868
|
-
/\bcheck(?:ing)?\s+(?:it|this|that|[^.?!\n]+)\s+periodically\b/,
|
|
2869
|
-
/\bperiodically\s+check\b/,
|
|
2870
|
-
/\bcheck(?:ing)?\s+[^.?!\n]+\bevery\s+\d+\s*(?:s|sec|seconds?|m|min|minutes?|h|hr|hours?)\b/,
|
|
2871
|
-
/\bevery\s+\d+\s*(?:s|sec|seconds?|m|min|minutes?|h|hr|hours?)\b[^.?!\n]+\b(?:check|poll|monitor|watch)\b/,
|
|
2872
|
-
/\bping\s+(?:me\s+)?(?:on|when|after)\b/,
|
|
2873
|
-
/\bping\s+on\s+completion\b/,
|
|
2874
|
-
/\bnotify\s+(?:me\s+)?(?:on|when|after)\b/,
|
|
2875
|
-
/\blet\s+me\s+know\s+(?:when|once|if)\b/,
|
|
2876
|
-
/\bpoll(?:ing)?\b/,
|
|
2877
|
-
].some(pattern => pattern.test(text));
|
|
2878
|
-
}
|
|
2879
|
-
|
|
2880
|
-
function _isDispatchLikeCCAction(action) {
|
|
2881
|
-
const type = String(action?.type || '').trim().toLowerCase();
|
|
2882
|
-
return type === 'dispatch' || CC_DISPATCH_ACTION_ALIASES.has(type);
|
|
2883
|
-
}
|
|
2884
|
-
|
|
2885
|
-
function _filterImplicitPostDispatchActions(actions, humanMessage) {
|
|
2886
|
-
if (!Array.isArray(actions) || actions.length === 0) return [];
|
|
2887
|
-
if (_messageExplicitlyRequestsMonitoring(humanMessage)) return actions;
|
|
2888
|
-
let sawDispatch = false;
|
|
2889
|
-
const filtered = [];
|
|
2890
|
-
for (const action of actions) {
|
|
2891
|
-
if (_isDispatchLikeCCAction(action)) {
|
|
2892
|
-
sawDispatch = true;
|
|
2893
|
-
filtered.push(action);
|
|
2894
|
-
continue;
|
|
2895
|
-
}
|
|
2896
|
-
if (!sawDispatch) filtered.push(action);
|
|
2897
|
-
}
|
|
2898
|
-
return filtered;
|
|
2899
|
-
}
|
|
2900
|
-
|
|
2901
|
-
// ── /loop → create-watch safety net ──────────────────────────────────────────
|
|
2902
|
-
// CC sometimes invokes the /loop skill instead of emitting a create-watch action.
|
|
2903
|
-
// This pure function detects /loop invocation in CC response text and synthesizes
|
|
2904
|
-
// a create-watch action as a fallback. Returns null if no conversion needed.
|
|
2905
|
-
|
|
2906
|
-
function _detectLoopInvocation(text, actions, toolUses, humanMessage) {
|
|
2907
|
-
const observedToolUses = Array.isArray(toolUses) ? toolUses : [];
|
|
2908
|
-
if (!text && observedToolUses.length === 0) return null;
|
|
2909
|
-
// If a create-watch action was already emitted, no fallback needed
|
|
2910
|
-
if (actions && actions.some(a => a.type === 'create-watch')) return null;
|
|
2911
|
-
if (humanMessage !== undefined && !_messageExplicitlyRequestsMonitoring(humanMessage)) return null;
|
|
2912
|
-
|
|
2913
|
-
function _extractTargetFromValue(value, keyHint) {
|
|
2914
|
-
if (value == null) return null;
|
|
2915
|
-
const hint = String(keyHint || '').toLowerCase();
|
|
2916
|
-
if (Array.isArray(value)) {
|
|
2917
|
-
for (const item of value) {
|
|
2918
|
-
const nested = _extractTargetFromValue(item, hint);
|
|
2919
|
-
if (nested) return nested;
|
|
2920
|
-
}
|
|
2921
|
-
return null;
|
|
2922
|
-
}
|
|
2923
|
-
if (typeof value === 'object') {
|
|
2924
|
-
for (const [k, v] of Object.entries(value)) {
|
|
2925
|
-
const nested = _extractTargetFromValue(v, k);
|
|
2926
|
-
if (nested) return nested;
|
|
2927
|
-
}
|
|
2928
|
-
return null;
|
|
2929
|
-
}
|
|
2930
|
-
const str = String(value).trim();
|
|
2931
|
-
if (!str) return null;
|
|
2932
|
-
const prUrlMatch = str.match(/\/pull\/(\d+)\b/i) || str.match(/\/pullrequest\/(\d+)\b/i);
|
|
2933
|
-
if (prUrlMatch) return { target: prUrlMatch[1], targetType: 'pr' };
|
|
2934
|
-
const prMatch = str.match(/\bPR[- #:]?(\d+)\b/i) || str.match(/\bpull[- ]request[- #:]?(\d+)\b/i);
|
|
2935
|
-
if (prMatch) return { target: prMatch[1], targetType: 'pr' };
|
|
2936
|
-
const wiMatch = str.match(/\bW-([a-z0-9]+)\b/i);
|
|
2937
|
-
if (wiMatch) return { target: 'W-' + wiMatch[1], targetType: 'work-item' };
|
|
2938
|
-
if ((hint.includes('pr') || hint.includes('pull')) && /^\d+$/.test(str)) return { target: str, targetType: 'pr' };
|
|
2939
|
-
if ((hint.includes('work') || hint.includes('item') || hint === 'id') && /^W-[a-z0-9]+$/i.test(str)) return { target: str.toUpperCase().startsWith('W-') ? 'W-' + str.slice(2) : str, targetType: 'work-item' };
|
|
2940
|
-
if (hint.includes('target')) {
|
|
2941
|
-
if (/^\d+$/.test(str)) return { target: str, targetType: 'pr' };
|
|
2942
|
-
if (/^W-[a-z0-9]+$/i.test(str)) return { target: 'W-' + str.slice(2), targetType: 'work-item' };
|
|
2943
|
-
}
|
|
2944
|
-
return null;
|
|
2945
|
-
}
|
|
2946
|
-
|
|
2947
|
-
function _extractIntervalFromValue(value, keyHint) {
|
|
2948
|
-
if (value == null) return null;
|
|
2949
|
-
const hint = String(keyHint || '').toLowerCase();
|
|
2950
|
-
if (Array.isArray(value)) {
|
|
2951
|
-
for (const item of value) {
|
|
2952
|
-
const nested = _extractIntervalFromValue(item, hint);
|
|
2953
|
-
if (nested) return nested;
|
|
2954
|
-
}
|
|
2955
|
-
return null;
|
|
2956
|
-
}
|
|
2957
|
-
if (typeof value === 'object') {
|
|
2958
|
-
for (const [k, v] of Object.entries(value)) {
|
|
2959
|
-
const nested = _extractIntervalFromValue(v, k);
|
|
2960
|
-
if (nested) return nested;
|
|
2961
|
-
}
|
|
2962
|
-
return null;
|
|
2963
|
-
}
|
|
2964
|
-
if (!(hint.includes('interval') || hint.includes('every') || hint.includes('frequency'))) return null;
|
|
2965
|
-
const str = String(value).trim().toLowerCase();
|
|
2966
|
-
if (!str) return null;
|
|
2967
|
-
if (/^\d+$/.test(str)) return str;
|
|
2968
|
-
const match = str.match(/^(\d+(?:\.\d+)?)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?)$/i);
|
|
2969
|
-
if (!match) return null;
|
|
2970
|
-
return match[1] + match[2][0].toLowerCase();
|
|
2971
|
-
}
|
|
2972
|
-
|
|
2973
|
-
function _extractConditionFromValue(value, keyHint) {
|
|
2974
|
-
if (value == null) return null;
|
|
2975
|
-
const hint = String(keyHint || '').toLowerCase();
|
|
2976
|
-
if (Array.isArray(value)) {
|
|
2977
|
-
for (const item of value) {
|
|
2978
|
-
const nested = _extractConditionFromValue(item, hint);
|
|
2979
|
-
if (nested) return nested;
|
|
2980
|
-
}
|
|
2981
|
-
return null;
|
|
2982
|
-
}
|
|
2983
|
-
if (typeof value === 'object') {
|
|
2984
|
-
for (const [k, v] of Object.entries(value)) {
|
|
2985
|
-
const nested = _extractConditionFromValue(v, k);
|
|
2986
|
-
if (nested) return nested;
|
|
2987
|
-
}
|
|
2988
|
-
return null;
|
|
2989
|
-
}
|
|
2990
|
-
if (!(hint.includes('condition') || hint.includes('until') || hint.includes('goal') || hint.includes('status'))) return null;
|
|
2991
|
-
const str = String(value).trim().toLowerCase();
|
|
2992
|
-
if (['merged', 'build-pass', 'build-fail', 'completed', 'failed', 'status-change', 'any', 'new-comments', 'vote-change'].includes(str)) return str;
|
|
2993
|
-
if (/\b(?:pass(?:es|ing|ed)?|green|succeed(?:s|ed)?|success)\b/i.test(str)) return 'build-pass';
|
|
2994
|
-
if (/\b(?:fail(?:s|ing|ed)?|red|broken|broke)\b/i.test(str)) return 'build-fail';
|
|
2995
|
-
if (/\bmerge(?:d)?\b/i.test(str)) return 'merged';
|
|
2996
|
-
if (/\bcomplete(?:d)?\b/i.test(str)) return 'completed';
|
|
2997
|
-
if (/\bfail(?:ed)?\b/i.test(str)) return 'failed';
|
|
2998
|
-
if (/\bcomment/i.test(str)) return 'new-comments';
|
|
2999
|
-
if (/\bvote|review/i.test(str)) return 'vote-change';
|
|
3000
|
-
if (/\bstatus/i.test(str)) return 'any';
|
|
3001
|
-
return null;
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
const loopToolSeen = observedToolUses.some(t => /\bloop\b/i.test(String(t?.name || '')));
|
|
3005
|
-
const toolText = observedToolUses.map(t => {
|
|
3006
|
-
try { return [String(t?.name || ''), JSON.stringify(t?.input || {})].filter(Boolean).join(' '); }
|
|
3007
|
-
catch { return String(t?.name || ''); }
|
|
3008
|
-
}).join('\n');
|
|
3009
|
-
const combinedText = [text || '', toolText].filter(Boolean).join('\n');
|
|
3010
|
-
|
|
3011
|
-
// Check for /loop invocation patterns in CC response
|
|
3012
|
-
const loopPatterns = [
|
|
3013
|
-
/\/loop\b/i,
|
|
3014
|
-
/\bloop skill\b/i,
|
|
3015
|
-
/\bSkill.*\bloop\b/i,
|
|
3016
|
-
/\bstarted.*\bloop\b/i,
|
|
3017
|
-
/\bmonitoring.*\bloop\b/i,
|
|
3018
|
-
/\binvok(?:e|ed|ing).*\bloop\b/i,
|
|
3019
|
-
];
|
|
3020
|
-
if (!loopToolSeen && !loopPatterns.some(p => p.test(combinedText))) return null;
|
|
3021
|
-
|
|
3022
|
-
// Extract target — PR number or work item ID
|
|
3023
|
-
const directTarget = observedToolUses.map(t => _extractTargetFromValue(t && t.input, t && t.name)).find(Boolean);
|
|
3024
|
-
const prMatch = combinedText.match(/\/pull\/(\d+)\b/i) ||
|
|
3025
|
-
combinedText.match(/\/pullrequest\/(\d+)\b/i) ||
|
|
3026
|
-
combinedText.match(/\bPR[- #:]?(\d+)\b/i) ||
|
|
3027
|
-
combinedText.match(/\bpull[- ]request[- #:]?(\d+)/i);
|
|
3028
|
-
const wiMatch = combinedText.match(/\bW-([a-z0-9]+)\b/i);
|
|
3029
|
-
|
|
3030
|
-
let target = null, targetType = 'pr';
|
|
3031
|
-
if (directTarget) {
|
|
3032
|
-
target = directTarget.target;
|
|
3033
|
-
targetType = directTarget.targetType;
|
|
3034
|
-
} else if (prMatch) {
|
|
3035
|
-
target = prMatch[1];
|
|
3036
|
-
targetType = 'pr';
|
|
3037
|
-
} else if (wiMatch) {
|
|
3038
|
-
target = 'W-' + wiMatch[1];
|
|
3039
|
-
targetType = 'work-item';
|
|
3040
|
-
}
|
|
3041
|
-
if (!target) return null; // Can't synthesize without a target
|
|
3042
|
-
|
|
3043
|
-
// Extract interval (e.g. "every 15 minutes", "every 5m")
|
|
3044
|
-
const directInterval = observedToolUses.map(t => _extractIntervalFromValue(t && t.input, t && t.name)).find(Boolean);
|
|
3045
|
-
const intervalMatch = combinedText.match(/every\s+(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?)\b/i);
|
|
3046
|
-
let interval = '5m';
|
|
3047
|
-
if (directInterval) interval = directInterval;
|
|
3048
|
-
else if (intervalMatch) interval = intervalMatch[1] + intervalMatch[2][0];
|
|
3049
|
-
|
|
3050
|
-
// Infer condition from keywords
|
|
3051
|
-
let condition = observedToolUses.map(t => _extractConditionFromValue(t && t.input, t && t.name)).find(Boolean) || 'any';
|
|
3052
|
-
if (condition === 'any') {
|
|
3053
|
-
if (/\bbuild\b/i.test(combinedText) && /\b(?:pass(?:es|ing|ed)?|green|succeed(?:s|ed)?|success)\b/i.test(combinedText)) condition = 'build-pass';
|
|
3054
|
-
else if (/\bbuild\b/i.test(combinedText) && /\b(?:fail(?:s|ing|ed)?|red|broken|broke)\b/i.test(combinedText)) condition = 'build-fail';
|
|
3055
|
-
else if (/\bmerge[d]?\b/i.test(combinedText)) condition = 'merged';
|
|
3056
|
-
else if (/\bcomplete[d]?\b/i.test(combinedText)) condition = 'completed';
|
|
3057
|
-
}
|
|
3058
|
-
|
|
3059
|
-
return {
|
|
3060
|
-
type: 'create-watch',
|
|
3061
|
-
target,
|
|
3062
|
-
targetType,
|
|
3063
|
-
condition,
|
|
3064
|
-
interval,
|
|
3065
|
-
owner: 'human',
|
|
3066
|
-
description: 'Auto-converted from /loop invocation',
|
|
3067
|
-
stopAfter: condition === 'any' ? 0 : 1,
|
|
3068
|
-
};
|
|
3069
|
-
}
|
|
3070
|
-
|
|
3071
|
-
function _extractToolUsesFromRaw(raw) {
|
|
3072
|
-
const toolUses = [];
|
|
3073
|
-
if (!raw) return toolUses;
|
|
3074
|
-
for (const line of String(raw).split('\n')) {
|
|
3075
|
-
const trimmed = line.trim();
|
|
3076
|
-
if (!trimmed || !trimmed.startsWith('{')) continue;
|
|
3077
|
-
try {
|
|
3078
|
-
const obj = JSON.parse(trimmed);
|
|
3079
|
-
if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content)) continue;
|
|
3080
|
-
for (const block of obj.message.content) {
|
|
3081
|
-
if (block?.type === 'tool_use' && block.name) toolUses.push({ name: block.name, input: block.input || {} });
|
|
3082
|
-
}
|
|
3083
|
-
} catch {}
|
|
3084
|
-
}
|
|
3085
|
-
return toolUses;
|
|
3086
|
-
}
|
|
3087
|
-
|
|
3088
|
-
// ── Server-side CC action execution ──────────────────────────────────────────
|
|
3089
|
-
// Actions are executed server-side so all clients (frontend, curl, Teams) get the same behavior.
|
|
3090
|
-
// The frontend still shows status toasts but no longer needs to fire the API calls.
|
|
3091
|
-
|
|
3092
|
-
// Parse interval from CC action — accepts ms number, "15m", "1h", "30s", or null (default 5m).
|
|
3093
|
-
function _parseWatchInterval(val) {
|
|
3094
|
-
if (!val) return 300000;
|
|
3095
|
-
if (typeof val === 'number') return Math.max(60000, val);
|
|
3096
|
-
const s = String(val).trim().toLowerCase();
|
|
3097
|
-
if (/^\d+$/.test(s)) { const n = parseInt(s, 10); return Math.max(60000, n >= 1000 ? n : n * 1000); }
|
|
3098
|
-
const m = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|hours?)$/);
|
|
3099
|
-
if (!m) return 300000;
|
|
3100
|
-
const n = parseFloat(m[1]), u = m[2][0];
|
|
3101
|
-
return Math.max(60000, Math.round(u === 's' ? n * 1000 : u === 'm' ? n * 60000 : n * 3600000));
|
|
3102
|
-
}
|
|
3103
|
-
|
|
3104
|
-
function normalizeMeetingParticipants(participants) {
|
|
3105
|
-
if (!Array.isArray(participants)) return [];
|
|
3106
|
-
const seen = new Set();
|
|
3107
|
-
const normalized = [];
|
|
3108
|
-
for (const participant of participants) {
|
|
3109
|
-
const id = String(participant || '').trim();
|
|
3110
|
-
if (!id || seen.has(id)) continue;
|
|
3111
|
-
seen.add(id);
|
|
3112
|
-
normalized.push(id);
|
|
3113
|
-
}
|
|
3114
|
-
return normalized;
|
|
3115
|
-
}
|
|
3116
|
-
|
|
3117
|
-
function meetingParticipantsFromAction(action) {
|
|
3118
|
-
return normalizeMeetingParticipants(
|
|
3119
|
-
Array.isArray(action?.participants) && action.participants.length > 0
|
|
3120
|
-
? action.participants
|
|
3121
|
-
: action?.agents
|
|
3122
|
-
);
|
|
3123
|
-
}
|
|
3124
|
-
|
|
3125
|
-
function normalizePipelineForCompare(pipeline) {
|
|
3126
|
-
if (!pipeline || typeof pipeline !== 'object') return null;
|
|
3127
|
-
return {
|
|
3128
|
-
title: pipeline.title || '',
|
|
3129
|
-
stages: Array.isArray(pipeline.stages) ? pipeline.stages : [],
|
|
3130
|
-
trigger: pipeline.trigger && typeof pipeline.trigger === 'object' ? pipeline.trigger : {},
|
|
3131
|
-
enabled: pipeline.enabled !== false,
|
|
3132
|
-
project: pipeline.project !== undefined ? pipeline.project : null,
|
|
3133
|
-
projects: Array.isArray(pipeline.projects) ? pipeline.projects : [],
|
|
3134
|
-
stopWhen: pipeline.stopWhen || null,
|
|
3135
|
-
monitoredResources: Array.isArray(pipeline.monitoredResources) ? pipeline.monitoredResources : [],
|
|
3136
|
-
};
|
|
3137
|
-
}
|
|
3138
|
-
|
|
3139
|
-
function buildPipelineFromAction(action) {
|
|
3140
|
-
const pipeline = {
|
|
3141
|
-
id: String(action.id || '').trim(),
|
|
3142
|
-
title: String(action.title || '').trim(),
|
|
3143
|
-
stages: action.stages,
|
|
3144
|
-
trigger: action.trigger && typeof action.trigger === 'object' ? action.trigger : {},
|
|
3145
|
-
enabled: action.enabled !== false,
|
|
3146
|
-
};
|
|
3147
|
-
if (action.project !== undefined) pipeline.project = action.project;
|
|
3148
|
-
if (Array.isArray(action.projects)) pipeline.projects = action.projects;
|
|
3149
|
-
if (action.stopWhen) pipeline.stopWhen = action.stopWhen;
|
|
3150
|
-
if (Array.isArray(action.monitoredResources) && action.monitoredResources.length > 0) {
|
|
3151
|
-
pipeline.monitoredResources = action.monitoredResources;
|
|
3152
|
-
}
|
|
3153
|
-
return pipeline;
|
|
3154
|
-
}
|
|
3155
|
-
|
|
3156
|
-
function pipelineDefinitionsEqual(a, b) {
|
|
3157
|
-
return JSON.stringify(normalizePipelineForCompare(a)) === JSON.stringify(normalizePipelineForCompare(b));
|
|
3158
|
-
}
|
|
3159
|
-
|
|
3160
|
-
function createPipelineFromAction(action) {
|
|
3161
|
-
const { savePipeline, getPipeline } = require('./engine/pipeline');
|
|
3162
|
-
const pipeline = buildPipelineFromAction(action);
|
|
3163
|
-
const projectError = validatePipelineProjects(pipeline);
|
|
3164
|
-
if (projectError) {
|
|
3165
|
-
return { type: 'create-pipeline', id: pipeline.id, error: projectError };
|
|
3166
|
-
}
|
|
3167
|
-
const existing = getPipeline(pipeline.id);
|
|
3168
|
-
if (existing) {
|
|
3169
|
-
if (pipelineDefinitionsEqual(existing, pipeline)) {
|
|
3170
|
-
return {
|
|
3171
|
-
type: 'create-pipeline',
|
|
3172
|
-
id: pipeline.id,
|
|
3173
|
-
ok: true,
|
|
3174
|
-
duplicate: true,
|
|
3175
|
-
duplicateOf: pipeline.id,
|
|
3176
|
-
warning: `Pipeline "${pipeline.id}" already exists; no changes made.`,
|
|
3177
|
-
};
|
|
3178
|
-
}
|
|
3179
|
-
return {
|
|
3180
|
-
type: 'create-pipeline',
|
|
3181
|
-
id: pipeline.id,
|
|
3182
|
-
error: `Pipeline "${pipeline.id}" already exists with a different definition. Use edit-pipeline to update it.`,
|
|
3183
|
-
};
|
|
3184
|
-
}
|
|
3185
|
-
savePipeline(pipeline);
|
|
3186
|
-
const persisted = getPipeline(pipeline.id);
|
|
3187
|
-
if (!persisted) {
|
|
3188
|
-
return { type: 'create-pipeline', id: pipeline.id, error: `Pipeline "${pipeline.id}" was not persisted.` };
|
|
3189
|
-
}
|
|
3190
|
-
if (!pipelineDefinitionsEqual(persisted, pipeline)) {
|
|
3191
|
-
return { type: 'create-pipeline', id: pipeline.id, error: `Pipeline "${pipeline.id}" persisted with unexpected contents.` };
|
|
3192
|
-
}
|
|
3193
|
-
invalidateStatusCache();
|
|
3194
|
-
return { type: 'create-pipeline', id: pipeline.id, ok: true, created: true };
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
// Required-field validator for CC actions. Returns null when valid, an error string when not.
|
|
3198
|
-
// Centralises field-required checks so the model can't quietly emit a malformed action and have
|
|
3199
|
-
// the server silently fall back to placeholder values (e.g. "Untitled"). The handler invokes this
|
|
3200
|
-
// before `try` to avoid filling `results` with cryptic per-handler error messages.
|
|
3201
|
-
function _ccValidateAction(action) {
|
|
3202
|
-
if (!action || typeof action !== 'object' || !action.type) return 'action is missing required field: type';
|
|
3203
|
-
const normalized = normalizeCCAction(action);
|
|
3204
|
-
switch (normalized.type) {
|
|
3205
|
-
case 'dispatch':
|
|
3206
|
-
if (!normalized.title || typeof normalized.title !== 'string' || !normalized.title.trim()) return `${action.type} action missing required field: title`;
|
|
3207
|
-
return null;
|
|
3208
|
-
case 'implement':
|
|
3209
|
-
return 'Unsupported action type "implement"; use type="dispatch" with workType="implement".';
|
|
3210
|
-
case 'build-and-test':
|
|
3211
|
-
if (!action.pr) return 'build-and-test action missing required field: pr';
|
|
3212
|
-
return null;
|
|
3213
|
-
case 'note':
|
|
3214
|
-
if (!action.title) return 'note action missing required field: title';
|
|
3215
|
-
if (!action.content && !action.description) return 'note action missing required field: content (or description)';
|
|
3216
|
-
return null;
|
|
3217
|
-
case 'knowledge':
|
|
3218
|
-
if (!action.title) return 'knowledge action missing required field: title';
|
|
3219
|
-
if (!action.content) return 'knowledge action missing required field: content';
|
|
3220
|
-
if (!action.category) return 'knowledge action missing required field: category';
|
|
3221
|
-
return null;
|
|
3222
|
-
case 'pin-to-pinned':
|
|
3223
|
-
if (!action.title || !action.content) return 'pin-to-pinned action missing title or content';
|
|
3224
|
-
return null;
|
|
3225
|
-
case 'plan':
|
|
3226
|
-
if (!action.title) return 'plan action missing required field: title';
|
|
3227
|
-
return null;
|
|
3228
|
-
case 'create-meeting': {
|
|
3229
|
-
if (!action.title || typeof action.title !== 'string' || !action.title.trim()) return 'create-meeting action missing required field: title';
|
|
3230
|
-
if (!action.agenda || typeof action.agenda !== 'string' || !action.agenda.trim()) return 'create-meeting action missing required field: agenda';
|
|
3231
|
-
if (meetingParticipantsFromAction(action).length < 2) return 'create-meeting action requires at least 2 participants';
|
|
3232
|
-
return null;
|
|
3233
|
-
}
|
|
3234
|
-
case 'create-pipeline':
|
|
3235
|
-
if (!action.id || typeof action.id !== 'string' || !action.id.trim()) return 'create-pipeline action missing required field: id';
|
|
3236
|
-
if (!action.title || typeof action.title !== 'string' || !action.title.trim()) return 'create-pipeline action missing required field: title';
|
|
3237
|
-
if (!Array.isArray(action.stages) || action.stages.length === 0) return 'create-pipeline action requires non-empty stages array';
|
|
3238
|
-
return null;
|
|
3239
|
-
default:
|
|
3240
|
-
return null; // unknown types fall through to existing handler / generic fallback
|
|
3241
|
-
}
|
|
3242
|
-
}
|
|
3243
|
-
|
|
3244
|
-
let _ccLocalApiInvokerForTest = null;
|
|
3245
|
-
|
|
3246
|
-
function _setCcLocalApiInvokerForTest(fn) {
|
|
3247
|
-
_ccLocalApiInvokerForTest = typeof fn === 'function' ? fn : null;
|
|
3248
|
-
}
|
|
3249
|
-
|
|
3250
|
-
function _ccRouteMethodsForPath(pathname) {
|
|
3251
|
-
if (!Array.isArray(_ccApiRoutesMeta) || _ccApiRoutesMeta.length === 0) return null;
|
|
3252
|
-
const methods = new Set();
|
|
3253
|
-
for (const route of _ccApiRoutesMeta) {
|
|
3254
|
-
if (route._pathRegex instanceof RegExp) {
|
|
3255
|
-
route._pathRegex.lastIndex = 0;
|
|
3256
|
-
if (route._pathRegex.test(pathname)) methods.add(String(route.method || '').toUpperCase());
|
|
3257
|
-
} else if (route.path === pathname) {
|
|
3258
|
-
methods.add(String(route.method || '').toUpperCase());
|
|
3259
|
-
}
|
|
3260
|
-
}
|
|
3261
|
-
return methods;
|
|
3262
|
-
}
|
|
3263
|
-
|
|
3264
|
-
function _ccValidateLocalApiFallback(endpoint, method) {
|
|
3265
|
-
if (typeof endpoint !== 'string' || !endpoint.trim()) return 'generic API fallback requires endpoint';
|
|
3266
|
-
const raw = endpoint.trim();
|
|
3267
|
-
if (!(raw === '/api' || raw.startsWith('/api/'))) return 'generic API fallback endpoint must be a local /api/ path';
|
|
3268
|
-
if (/[\0\r\n\\]/.test(raw) || raw.includes('..') || /%2e/i.test(raw) || /%5c/i.test(raw)) {
|
|
3269
|
-
return 'generic API fallback endpoint is unsafe';
|
|
3270
|
-
}
|
|
3271
|
-
let parsed;
|
|
3272
|
-
try {
|
|
3273
|
-
parsed = new URL(raw, 'http://127.0.0.1');
|
|
3274
|
-
} catch {
|
|
3275
|
-
return 'generic API fallback endpoint is invalid';
|
|
3276
|
-
}
|
|
3277
|
-
if (parsed.origin !== 'http://127.0.0.1' || !(parsed.pathname === '/api' || parsed.pathname.startsWith('/api/'))) {
|
|
3278
|
-
return 'generic API fallback endpoint must be a local /api/ path';
|
|
3279
|
-
}
|
|
3280
|
-
if (CC_API_FALLBACK_BLOCKED_PREFIXES.some(prefix => parsed.pathname === prefix || parsed.pathname.startsWith(prefix + '/'))) {
|
|
3281
|
-
return 'generic API fallback cannot call Command Center, doc-chat, or bot endpoints';
|
|
3282
|
-
}
|
|
3283
|
-
if (/stream/i.test(parsed.pathname) || parsed.pathname === '/api/hot-reload') {
|
|
3284
|
-
return 'generic API fallback cannot call streaming endpoints';
|
|
3285
|
-
}
|
|
3286
|
-
const normalizedMethod = String(method || 'POST').toUpperCase();
|
|
3287
|
-
if (!CC_API_FALLBACK_METHODS.has(normalizedMethod)) {
|
|
3288
|
-
return `generic API fallback method ${normalizedMethod} is not allowed`;
|
|
3289
|
-
}
|
|
3290
|
-
const routeMethods = _ccRouteMethodsForPath(parsed.pathname);
|
|
3291
|
-
if (routeMethods && routeMethods.size > 0 && !routeMethods.has(normalizedMethod)) {
|
|
3292
|
-
return `API endpoint ${parsed.pathname} does not allow ${normalizedMethod}; allowed methods: ${[...routeMethods].join(', ')}`;
|
|
3293
|
-
}
|
|
3294
|
-
if (routeMethods && routeMethods.size === 0) {
|
|
3295
|
-
return `API endpoint ${parsed.pathname} is not in the local API index`;
|
|
3296
|
-
}
|
|
3297
|
-
return null;
|
|
3298
|
-
}
|
|
3299
|
-
|
|
3300
|
-
function _ccBuildQueryString(params) {
|
|
3301
|
-
if (!params || typeof params !== 'object' || Array.isArray(params)) return '';
|
|
3302
|
-
const search = new URLSearchParams();
|
|
3303
|
-
for (const [key, value] of Object.entries(params)) {
|
|
3304
|
-
if (value === undefined || value === null) continue;
|
|
3305
|
-
if (Array.isArray(value)) {
|
|
3306
|
-
for (const item of value) search.append(key, String(item));
|
|
3307
|
-
} else if (typeof value === 'object') {
|
|
3308
|
-
search.append(key, JSON.stringify(value));
|
|
3309
|
-
} else {
|
|
3310
|
-
search.append(key, String(value));
|
|
3311
|
-
}
|
|
3312
|
-
}
|
|
3313
|
-
const text = search.toString();
|
|
3314
|
-
return text ? '?' + text : '';
|
|
3315
|
-
}
|
|
3316
|
-
|
|
3317
|
-
function _ccRequestPath(endpoint, method, params) {
|
|
3318
|
-
const parsed = new URL(endpoint, 'http://127.0.0.1');
|
|
3319
|
-
if (method === 'GET') {
|
|
3320
|
-
const extra = _ccBuildQueryString(params);
|
|
3321
|
-
if (extra) {
|
|
3322
|
-
const glue = parsed.search ? '&' : '?';
|
|
3323
|
-
return parsed.pathname + parsed.search + glue + extra.slice(1);
|
|
3324
|
-
}
|
|
3325
|
-
}
|
|
3326
|
-
return parsed.pathname + parsed.search;
|
|
3327
|
-
}
|
|
3328
|
-
|
|
3329
|
-
async function _ccInvokeLocalApi({ method, endpoint, params }) {
|
|
3330
|
-
if (_ccLocalApiInvokerForTest) return _ccLocalApiInvokerForTest({ method, endpoint, params });
|
|
3331
|
-
const requestPath = _ccRequestPath(endpoint, method, params);
|
|
3332
|
-
return new Promise((resolve, reject) => {
|
|
3333
|
-
const body = method === 'GET' ? null : JSON.stringify(params || {});
|
|
3334
|
-
const req = http.request({
|
|
3335
|
-
hostname: '127.0.0.1',
|
|
3336
|
-
port: PORT,
|
|
3337
|
-
method,
|
|
3338
|
-
path: requestPath,
|
|
3339
|
-
timeout: CC_API_FALLBACK_TIMEOUT_MS,
|
|
3340
|
-
headers: body ? {
|
|
3341
|
-
'Content-Type': 'application/json',
|
|
3342
|
-
'Content-Length': Buffer.byteLength(body),
|
|
3343
|
-
} : {},
|
|
3344
|
-
}, res => {
|
|
3345
|
-
let text = '';
|
|
3346
|
-
res.setEncoding('utf8');
|
|
3347
|
-
res.on('data', chunk => { text += chunk; });
|
|
3348
|
-
res.on('end', () => {
|
|
3349
|
-
let data = text;
|
|
3350
|
-
try { data = text ? JSON.parse(text) : {}; } catch { /* non-JSON API response */ }
|
|
3351
|
-
resolve({ status: res.statusCode || 0, data });
|
|
3352
|
-
});
|
|
3353
|
-
});
|
|
3354
|
-
req.on('timeout', () => {
|
|
3355
|
-
req.destroy(new Error(`local API fallback timed out after ${CC_API_FALLBACK_TIMEOUT_MS}ms`));
|
|
3356
|
-
});
|
|
3357
|
-
req.on('error', reject);
|
|
3358
|
-
if (body) req.write(body);
|
|
3359
|
-
req.end();
|
|
3360
|
-
});
|
|
3361
|
-
}
|
|
3362
|
-
|
|
3363
|
-
function _ccApiRequest(endpoint, params = {}, method = 'POST') {
|
|
3364
|
-
return { endpoint, params, method };
|
|
3365
|
-
}
|
|
3366
|
-
|
|
3367
|
-
function _ccMappedApiRequests(action) {
|
|
3368
|
-
switch (action.type) {
|
|
3369
|
-
case 'pin':
|
|
3370
|
-
case 'pin-to-pinned':
|
|
3371
|
-
return _ccApiRequest('/api/pinned', { title: action.title, content: action.content || action.description, level: action.level || '' });
|
|
3372
|
-
case 'plan': {
|
|
3373
|
-
const branchStrategy = action.branch_strategy || action.branchStrategy || 'parallel';
|
|
3374
|
-
return _ccApiRequest('/api/plan', {
|
|
3375
|
-
title: action.title, description: action.description || '', priority: action.priority,
|
|
3376
|
-
project: action.project, agent: action.agent, branch_strategy: branchStrategy,
|
|
3377
|
-
});
|
|
3378
|
-
}
|
|
3379
|
-
case 'cancel':
|
|
3380
|
-
return _ccApiRequest('/api/agents/cancel', {
|
|
3381
|
-
agent: action.agent || action.agentId,
|
|
3382
|
-
task: action.task || action.cancelTask,
|
|
3383
|
-
reason: action.reason || 'Cancelled via command center',
|
|
3384
|
-
});
|
|
3385
|
-
case 'retry':
|
|
3386
|
-
return (action.ids || []).map(id => _ccApiRequest('/api/work-items/retry', { id, source: action.source || '' }));
|
|
3387
|
-
case 'pause-plan':
|
|
3388
|
-
return _ccApiRequest('/api/plans/pause', { file: action.file });
|
|
3389
|
-
case 'approve-plan':
|
|
3390
|
-
return _ccApiRequest('/api/plans/approve', { file: action.file });
|
|
3391
|
-
case 'reject-plan':
|
|
3392
|
-
return _ccApiRequest('/api/plans/reject', { file: action.file, reason: action.reason || '' });
|
|
3393
|
-
case 'archive-plan':
|
|
3394
|
-
return _ccApiRequest('/api/plans/archive', { file: action.file });
|
|
3395
|
-
case 'unarchive-plan':
|
|
3396
|
-
return _ccApiRequest('/api/plans/unarchive', { file: action.file });
|
|
3397
|
-
case 'execute-plan':
|
|
3398
|
-
return _ccApiRequest('/api/plans/execute', { file: action.file, project: action.project || '' });
|
|
3399
|
-
case 'trigger-verify':
|
|
3400
|
-
return _ccApiRequest('/api/plans/trigger-verify', { file: action.file });
|
|
3401
|
-
case 'regenerate-plan':
|
|
3402
|
-
return _ccApiRequest('/api/plans/approve', { file: action.file, forceRegen: true });
|
|
3403
|
-
case 'revise-plan':
|
|
3404
|
-
return _ccApiRequest('/api/plans/revise', { file: action.file, feedback: action.feedback || action.description, requestedBy: 'command-center' });
|
|
3405
|
-
case 'edit-prd-item':
|
|
3406
|
-
return _ccApiRequest('/api/prd-items/update', {
|
|
3407
|
-
source: action.source, itemId: action.itemId, name: action.name, description: action.description,
|
|
3408
|
-
priority: action.priority, estimated_complexity: action.estimated_complexity || action.complexity,
|
|
3409
|
-
});
|
|
3410
|
-
case 'remove-prd-item':
|
|
3411
|
-
return _ccApiRequest('/api/prd-items/remove', { source: action.source, itemId: action.itemId });
|
|
3412
|
-
case 'reopen-prd-item':
|
|
3413
|
-
return _ccApiRequest('/api/prd-items/update', { source: action.file, itemId: action.id, status: 'updated' });
|
|
3414
|
-
case 'delete-work-item':
|
|
3415
|
-
return _ccApiRequest('/api/work-items/delete', { id: action.id, source: action.source || '' });
|
|
3416
|
-
case 'cancel-work-item':
|
|
3417
|
-
return _ccApiRequest('/api/work-items/cancel', { id: action.id, source: action.source || '', reason: action.reason || 'cc' });
|
|
3418
|
-
case 'archive-work-item':
|
|
3419
|
-
return _ccApiRequest('/api/work-items/archive', { id: action.id });
|
|
3420
|
-
case 'work-item-feedback':
|
|
3421
|
-
return _ccApiRequest('/api/work-items/feedback', { id: action.id, rating: action.rating || 'up', comment: action.comment || '' });
|
|
3422
|
-
case 'schedule':
|
|
3423
|
-
return _ccApiRequest(action._update ? '/api/schedules/update' : '/api/schedules', {
|
|
3424
|
-
id: action.id, title: action.title, cron: action.cron, type: action.workType || 'implement',
|
|
3425
|
-
project: action.project, agent: action.agent, description: action.description,
|
|
3426
|
-
priority: action.priority, enabled: action.enabled !== false,
|
|
3427
|
-
});
|
|
3428
|
-
case 'delete-schedule':
|
|
3429
|
-
return _ccApiRequest('/api/schedules/delete', { id: action.id });
|
|
3430
|
-
case 'edit-pipeline':
|
|
3431
|
-
return _ccApiRequest('/api/pipelines/update', {
|
|
3432
|
-
id: action.id, title: action.title, stages: action.stages,
|
|
3433
|
-
trigger: action.trigger, enabled: action.enabled, stopWhen: action.stopWhen,
|
|
3434
|
-
monitoredResources: action.monitoredResources,
|
|
3435
|
-
});
|
|
3436
|
-
case 'delete-pipeline':
|
|
3437
|
-
return _ccApiRequest('/api/pipelines/delete', { id: action.id });
|
|
3438
|
-
case 'trigger-pipeline':
|
|
3439
|
-
return _ccApiRequest('/api/pipelines/trigger', { id: action.id });
|
|
3440
|
-
case 'continue-pipeline':
|
|
3441
|
-
return _ccApiRequest('/api/pipelines/continue', { id: action.id, stageId: action.stageId });
|
|
3442
|
-
case 'abort-pipeline':
|
|
3443
|
-
return _ccApiRequest('/api/pipelines/abort', { id: action.id });
|
|
3444
|
-
case 'retrigger-pipeline':
|
|
3445
|
-
return _ccApiRequest('/api/pipelines/retrigger', { id: action.id });
|
|
3446
|
-
case 'add-meeting-note':
|
|
3447
|
-
return _ccApiRequest('/api/meetings/note', { id: action.id, note: action.note || action.content });
|
|
3448
|
-
case 'advance-meeting':
|
|
3449
|
-
return _ccApiRequest('/api/meetings/advance', { id: action.id });
|
|
3450
|
-
case 'end-meeting':
|
|
3451
|
-
return _ccApiRequest('/api/meetings/end', { id: action.id });
|
|
3452
|
-
case 'archive-meeting':
|
|
3453
|
-
return _ccApiRequest('/api/meetings/archive', { id: action.id });
|
|
3454
|
-
case 'unarchive-meeting':
|
|
3455
|
-
return _ccApiRequest('/api/meetings/unarchive', { id: action.id });
|
|
3456
|
-
case 'delete-meeting':
|
|
3457
|
-
return _ccApiRequest('/api/meetings/delete', { id: action.id });
|
|
3458
|
-
case 'set-config':
|
|
3459
|
-
return _ccApiRequest('/api/settings', { engine: { [action.setting]: action.value } });
|
|
3460
|
-
case 'update-routing':
|
|
3461
|
-
return _ccApiRequest('/api/settings/routing', { content: action.content });
|
|
3462
|
-
case 'steer-agent':
|
|
3463
|
-
return _ccApiRequest('/api/agents/steer', { agent: action.agent, message: action.message || action.content });
|
|
3464
|
-
case 'link-pr':
|
|
3465
|
-
return _ccApiRequest('/api/pull-requests/link', { url: action.url, title: action.title || '', project: action.project || '', autoObserve: action.autoObserve !== false });
|
|
3466
|
-
case 'delete-pr':
|
|
3467
|
-
return _ccApiRequest('/api/pull-requests/delete', { id: action.id, project: action.project || '' });
|
|
3468
|
-
case 'file-bug':
|
|
3469
|
-
return _ccApiRequest('/api/issues/create', { title: action.title, description: action.description, labels: action.labels });
|
|
3470
|
-
case 'promote-to-kb':
|
|
3471
|
-
return _ccApiRequest('/api/inbox/promote-kb', { name: action.file, category: action.category || 'project-notes' });
|
|
3472
|
-
case 'kb-sweep':
|
|
3473
|
-
return _ccApiRequest('/api/knowledge/sweep', {});
|
|
3474
|
-
case 'toggle-kb-pin':
|
|
3475
|
-
return _ccApiRequest('/api/kb-pins/toggle', { key: action.key });
|
|
3476
|
-
case 'unpin':
|
|
3477
|
-
return _ccApiRequest('/api/pinned' + '/remove', { title: action.title });
|
|
3478
|
-
case 'add-project':
|
|
3479
|
-
return _ccApiRequest('/api/projects/add', {
|
|
3480
|
-
path: action.path || action.localPath, name: action.name || '',
|
|
3481
|
-
repoHost: action.repoHost || 'github', allowNonRepo: action.allowNonRepo,
|
|
3482
|
-
confirmToken: action.confirmToken,
|
|
3483
|
-
});
|
|
3484
|
-
case 'restart-engine':
|
|
3485
|
-
return _ccApiRequest('/api/engine/restart', {});
|
|
3486
|
-
case 'reset-settings':
|
|
3487
|
-
return _ccApiRequest('/api/settings/reset', {});
|
|
3488
|
-
default:
|
|
3489
|
-
if (action.endpoint) return _ccApiRequest(action.endpoint, action.params || {}, action.method || 'POST');
|
|
3490
|
-
return null;
|
|
3491
|
-
}
|
|
3492
|
-
}
|
|
3493
|
-
|
|
3494
|
-
async function _ccExecuteLocalApiAction(action) {
|
|
3495
|
-
const mapped = _ccMappedApiRequests(action);
|
|
3496
|
-
if (!mapped) return null;
|
|
3497
|
-
const requests = Array.isArray(mapped) ? mapped : [mapped];
|
|
3498
|
-
if (requests.length === 0) throw new Error(`${action.type} action has no API requests to execute`);
|
|
3499
|
-
const apiResults = [];
|
|
3500
|
-
for (const request of requests) {
|
|
3501
|
-
const method = String(request.method || 'POST').toUpperCase();
|
|
3502
|
-
const endpoint = String(request.endpoint || '').trim();
|
|
3503
|
-
const params = request.params || {};
|
|
3504
|
-
const validationError = _ccValidateLocalApiFallback(endpoint, method);
|
|
3505
|
-
if (validationError) throw new Error(validationError);
|
|
3506
|
-
const response = await _ccInvokeLocalApi({ method, endpoint, params });
|
|
3507
|
-
const status = Number(response?.status) || 0;
|
|
3508
|
-
const data = response?.data === undefined ? {} : response.data;
|
|
3509
|
-
if (status < 200 || status >= 300) {
|
|
3510
|
-
const detail = data && typeof data === 'object' && data.error ? data.error : `HTTP ${status}`;
|
|
3511
|
-
throw new Error(`${method} ${endpoint} failed: ${detail}`);
|
|
3512
|
-
}
|
|
3513
|
-
if (data && typeof data === 'object' && data.error) throw new Error(`${method} ${endpoint} failed: ${data.error}`);
|
|
3514
|
-
apiResults.push({ status, data, endpoint, method });
|
|
3515
|
-
}
|
|
3516
|
-
const firstData = apiResults[0]?.data && typeof apiResults[0].data === 'object' ? apiResults[0].data : {};
|
|
3517
|
-
return {
|
|
3518
|
-
type: action.type,
|
|
3519
|
-
ok: true,
|
|
3520
|
-
endpoint: apiResults[0]?.endpoint,
|
|
3521
|
-
method: apiResults[0]?.method,
|
|
3522
|
-
status: apiResults[0]?.status,
|
|
3523
|
-
...(firstData.id ? { id: firstData.id } : {}),
|
|
3524
|
-
...(firstData.file ? { file: firstData.file } : {}),
|
|
3525
|
-
...(firstData.message ? { message: firstData.message } : {}),
|
|
3526
|
-
...(apiResults.length > 1 ? { count: apiResults.length, results: apiResults.map(r => r.data) } : { data: firstData }),
|
|
3527
|
-
};
|
|
3528
|
-
}
|
|
3529
|
-
|
|
3530
|
-
function _ccActionFailureLabel(action) {
|
|
3531
|
-
const type = String(action?.type || 'action').trim() || 'action';
|
|
3532
|
-
const title = String(action?.title || action?.id || action?.file || action?.target || '').replace(/\s+/g, ' ').trim();
|
|
3533
|
-
return title ? `${type} failed for '${title.slice(0, 120)}'` : `${type} failed`;
|
|
3534
|
-
}
|
|
3535
|
-
|
|
3536
|
-
function _ccMultiProjectRequiredError(action) {
|
|
3537
|
-
return `${_ccActionFailureLabel(action)}: project field is required when ${PROJECTS.length} projects are configured: ${PROJECTS.map(p => p.name).join(', ')}`;
|
|
3538
|
-
}
|
|
3539
|
-
|
|
3540
|
-
async function executeCCActions(actions, { source = 'command-center', inferredProject = null } = {}) {
|
|
3541
|
-
const results = [];
|
|
3542
|
-
const dispatchIdsCreatedInThisCall = new Map();
|
|
3543
|
-
for (let actionIndex = 0; actionIndex < actions.length; actionIndex++) {
|
|
3544
|
-
const rawAction = actions[actionIndex];
|
|
3545
|
-
const action = normalizeCCAction(rawAction);
|
|
3546
|
-
if (action?._intentFallbackError) {
|
|
3547
|
-
results.push({
|
|
3548
|
-
type: action.type || 'dispatch',
|
|
3549
|
-
...(action.workType ? { workType: action.workType } : {}),
|
|
3550
|
-
error: action._intentFallbackError,
|
|
3551
|
-
missingTarget: true,
|
|
3552
|
-
});
|
|
3553
|
-
continue;
|
|
3554
|
-
}
|
|
3555
|
-
const validationError = _ccValidateAction(action);
|
|
3556
|
-
if (validationError) {
|
|
3557
|
-
results.push({ type: action?.type || 'unknown', error: validationError });
|
|
3558
|
-
continue;
|
|
3559
|
-
}
|
|
3560
|
-
try {
|
|
3561
|
-
switch (action.type) {
|
|
3562
|
-
case 'dispatch': {
|
|
3563
|
-
const workType = routing.normalizeWorkType(action.workType || (action.type !== 'dispatch' ? action.type : WORK_TYPE.IMPLEMENT), WORK_TYPE.IMPLEMENT);
|
|
3564
|
-
const id = 'W-' + shared.uid();
|
|
3565
|
-
const project = action.project || '';
|
|
3566
|
-
const prTargetedWorkType = isPrTargetedWorkType(workType);
|
|
3567
|
-
let prRef = getWorkItemPrRef(action);
|
|
3568
|
-
let linkedPr = null;
|
|
3569
|
-
let allPrsForAction = null;
|
|
3570
|
-
if (!prRef && prTargetedWorkType) {
|
|
3571
|
-
allPrsForAction = getPullRequests().filter(p => !p._ghost);
|
|
3572
|
-
linkedPr = inferActionPrRecord(action, allPrsForAction, null);
|
|
3573
|
-
if (linkedPr) prRef = linkedPr.id || linkedPr.url || linkedPr.prNumber;
|
|
3574
|
-
}
|
|
3575
|
-
|
|
3576
|
-
// Strict project resolution. Silent fallback to PROJECTS[0] when the model named an unknown
|
|
3577
|
-
// project caused work items to land in the wrong repo. Now: unknown name → error; ambiguous
|
|
3578
|
-
// (multiple projects + no field) → error; single-project deployments fall through; zero
|
|
3579
|
-
// projects → root-level work-items.json (orchestration system standalone use).
|
|
3580
|
-
let targetProject = null;
|
|
3581
|
-
if (project) {
|
|
3582
|
-
const target = resolveProjectSourceTarget(project, PROJECTS, { allowCentral: false });
|
|
3583
|
-
targetProject = target.project;
|
|
3584
|
-
if (target.error) {
|
|
3585
|
-
results.push({ type: action.type, error: target.error });
|
|
3586
|
-
break;
|
|
3587
|
-
}
|
|
3588
|
-
} else if (prRef) {
|
|
3589
|
-
const allPrs = allPrsForAction || getPullRequests().filter(p => !p._ghost);
|
|
3590
|
-
if (!linkedPr) linkedPr = shared.findPrRecord(allPrs, prRef) || null;
|
|
3591
|
-
if (linkedPr?._project && linkedPr._project !== 'central') {
|
|
3592
|
-
targetProject = resolveProjectSourceTarget(linkedPr._project, PROJECTS, { allowCentral: false }).project || null;
|
|
3593
|
-
}
|
|
3594
|
-
} else if (inferredProject) {
|
|
3595
|
-
// Doc-chat fallback: filePath-derived project when the LLM omits the field. Validated against
|
|
3596
|
-
// PROJECTS upstream by _inferDocChatProject — a stale lookup would just yield null here.
|
|
3597
|
-
targetProject = resolveProjectSourceTarget(inferredProject, PROJECTS, { allowCentral: false }).project || null;
|
|
3598
|
-
}
|
|
3599
|
-
if (!targetProject) {
|
|
3600
|
-
if (PROJECTS.length > 1) {
|
|
3601
|
-
results.push({ type: action.type, error: _ccMultiProjectRequiredError(action) });
|
|
3602
|
-
break;
|
|
3603
|
-
}
|
|
3604
|
-
if (PROJECTS.length === 1) targetProject = PROJECTS[0];
|
|
3605
|
-
}
|
|
3606
|
-
// PROJECTS.length === 0 → targetProject stays null, falls back to root work-items.json (existing behavior).
|
|
3607
|
-
|
|
3608
|
-
if (targetProject && (!linkedPr || !prRef)) {
|
|
3609
|
-
const projectPrs = shared.safeJson(shared.projectPrPath(targetProject)) || [];
|
|
3610
|
-
shared.normalizePrRecords(projectPrs, targetProject);
|
|
3611
|
-
if (!prRef && prTargetedWorkType) {
|
|
3612
|
-
linkedPr = inferActionPrRecord(action, projectPrs, targetProject);
|
|
3613
|
-
if (linkedPr) prRef = linkedPr.id || linkedPr.url || linkedPr.prNumber;
|
|
3614
|
-
} else if (prRef && !linkedPr) {
|
|
3615
|
-
linkedPr = shared.findPrRecord(projectPrs, prRef, targetProject) || null;
|
|
3616
|
-
}
|
|
3617
|
-
}
|
|
3618
|
-
if (prRef && prTargetedWorkType && !linkedPr) {
|
|
3619
|
-
results.push({ type: action.type, error: `PR not found: ${prRef}` });
|
|
3620
|
-
break;
|
|
3621
|
-
}
|
|
3622
|
-
|
|
3623
|
-
const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : shared.centralWorkItemsPath(MINIONS_DIR);
|
|
3624
|
-
|
|
3625
|
-
// Promote `agent` (singular) → `agents` (array). Models emit either shape and the prior code
|
|
3626
|
-
// only read `action.agents`, silently dropping `agent: "lambert"` style hints.
|
|
3627
|
-
const agentHints = (() => {
|
|
3628
|
-
if (Array.isArray(action.agents) && action.agents.length > 0) return action.agents.map(String).filter(Boolean);
|
|
3629
|
-
if (typeof action.agent === 'string' && action.agent) return [action.agent];
|
|
3630
|
-
return [];
|
|
3631
|
-
})();
|
|
3632
|
-
const knownAgents = Object.keys(CONFIG.agents || {});
|
|
3633
|
-
const unknownAgent = agentHints.find(a => !knownAgents.includes(a));
|
|
3634
|
-
if (unknownAgent) {
|
|
3635
|
-
results.push({ type: action.type, error: `Unknown agent "${unknownAgent}". Configured agents: ${knownAgents.join(', ') || '(none)'}` });
|
|
3636
|
-
break;
|
|
3637
|
-
}
|
|
3638
|
-
|
|
3639
|
-
// Issue #1772: CC review/explore/test are human-initiated one-offs.
|
|
3640
|
-
// Mark oneShot so any discovered PR is tagged _contextOnly (skips eval loop).
|
|
3641
|
-
const ccOneShotTypes = new Set([WORK_TYPE.REVIEW, WORK_TYPE.EXPLORE, WORK_TYPE.TEST, WORK_TYPE.ASK, WORK_TYPE.VERIFY]);
|
|
3642
|
-
const isOneShot = action.oneShot === true || (action.oneShot !== false && ccOneShotTypes.has(workType));
|
|
3643
|
-
const item = {
|
|
3644
|
-
id, title: action.title.trim(), type: workType,
|
|
3645
|
-
priority: action.priority || 'medium', description: action.description || '',
|
|
3646
|
-
status: WI_STATUS.PENDING, created: new Date().toISOString(),
|
|
3647
|
-
createdBy: source, project: targetProject?.name || project,
|
|
3648
|
-
...(action.scope ? { scope: action.scope } : {}),
|
|
3649
|
-
...(agentHints.length ? { preferred_agent: agentHints[0], agents: agentHints } : {}),
|
|
3650
|
-
...(isOneShot ? { oneShot: true } : {}),
|
|
3651
|
-
};
|
|
3652
|
-
copyWorkItemPrFields(item, action, linkedPr);
|
|
3653
|
-
const createResult = createWorkItemWithDedup(wiPath, item);
|
|
3654
|
-
if (!createResult.created) {
|
|
3655
|
-
const duplicateId = createResult.duplicateOf || createResult.item?.id;
|
|
3656
|
-
if (duplicateId && dispatchIdsCreatedInThisCall.has(duplicateId)) {
|
|
3657
|
-
results.push({
|
|
3658
|
-
type: action.type,
|
|
3659
|
-
id: duplicateId,
|
|
3660
|
-
ok: true,
|
|
3661
|
-
reusedFromAction: dispatchIdsCreatedInThisCall.get(duplicateId),
|
|
3662
|
-
});
|
|
3663
|
-
break;
|
|
3664
|
-
}
|
|
3665
|
-
results.push({ type: action.type, id: duplicateId, ok: true, duplicate: true, duplicateOf: duplicateId });
|
|
3666
|
-
break;
|
|
3667
|
-
}
|
|
3668
|
-
dispatchIdsCreatedInThisCall.set(id, actionIndex);
|
|
3669
|
-
results.push({ type: action.type, id, ok: true });
|
|
3670
|
-
|
|
3671
|
-
// Pre-flight routing check: warn the user if no agent is currently available so the new
|
|
3672
|
-
// item won't sit pending invisibly. Routing failure is non-fatal — the WI was created.
|
|
3673
|
-
try {
|
|
3674
|
-
const resolvedAgent = routing.resolveAgent(workType, CONFIG, { agentHints, dryRun: true });
|
|
3675
|
-
if (!resolvedAgent) {
|
|
3676
|
-
const lastResult = results[results.length - 1];
|
|
3677
|
-
lastResult.warning = `Created ${id} but no agent is currently available to dispatch (routing returned no match for workType=${workType}${agentHints.length ? ', hints=' + agentHints.join(',') : ''}). Item will sit pending until an agent becomes available.`;
|
|
3678
|
-
}
|
|
3679
|
-
} catch (e) {
|
|
3680
|
-
shared.log('warn', `CC dispatch routing pre-flight: ${e.message}`);
|
|
3681
|
-
}
|
|
3682
|
-
break;
|
|
3683
|
-
}
|
|
3684
|
-
case 'build-and-test': {
|
|
3685
|
-
// Resolve PR by number, ID, or URL — same lookup that drives the link-pr / PR-row paths.
|
|
3686
|
-
const allPrs = getPullRequests().filter(p => !p._ghost);
|
|
3687
|
-
const pr = shared.findPrRecord(allPrs, action.pr) || null;
|
|
3688
|
-
if (!pr) {
|
|
3689
|
-
results.push({ type: 'build-and-test', error: `PR not found: ${action.pr}` });
|
|
3690
|
-
break;
|
|
3691
|
-
}
|
|
3692
|
-
// Resolve project: explicit param wins, else PR's _project. No silent first-project fallback —
|
|
3693
|
-
// unresolved → error so build-and-test can't accidentally run against the wrong repo.
|
|
3694
|
-
const projectName = action.project || pr._project || null;
|
|
3695
|
-
const project = projectName
|
|
3696
|
-
? resolveProjectSourceTarget(projectName, PROJECTS, { allowCentral: false }).project
|
|
3697
|
-
: null;
|
|
3698
|
-
if (!project) {
|
|
3699
|
-
results.push({ type: 'build-and-test', error: `Project not found for PR ${pr.id}: ${projectName || '(none)'}` });
|
|
3700
|
-
break;
|
|
3701
|
-
}
|
|
3702
|
-
// Pick agent: explicit param wins; else routing for 'test' work type.
|
|
3703
|
-
let agentId = action.agent && CONFIG.agents?.[action.agent] ? action.agent : null;
|
|
3704
|
-
if (!agentId) {
|
|
3705
|
-
agentId = routing.resolveAgent('test', CONFIG, { authorAgent: pr.agent });
|
|
3706
|
-
}
|
|
3707
|
-
if (!agentId) {
|
|
3708
|
-
results.push({ type: 'build-and-test', error: 'No available agent for test routing' });
|
|
3709
|
-
break;
|
|
3710
|
-
}
|
|
3711
|
-
const prNumber = shared.getPrNumber(pr);
|
|
3712
|
-
const dispatchKey = `cc-bt-${project.name}-${pr.id}`;
|
|
3713
|
-
const item = playbook.buildPrDispatch(agentId, CONFIG, project, pr, 'test', {
|
|
3714
|
-
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
|
|
3715
|
-
pr_author: pr.agent || '', pr_url: pr.url || '',
|
|
3716
|
-
project_path: project.localPath || '',
|
|
3717
|
-
task: `Build & test ${pr.id}: ${pr.title || ''}`,
|
|
3718
|
-
}, `Build & test ${pr.id}: ${pr.title || ''}`,
|
|
3719
|
-
{ dispatchKey, source: 'cc-build-and-test', pr, branch: pr.branch, project: { name: project.name, localPath: project.localPath } });
|
|
3720
|
-
if (!item) {
|
|
3721
|
-
if (agentId?.startsWith('temp-')) {
|
|
3722
|
-
routing.tempAgents.delete(agentId);
|
|
3723
|
-
routing._claimedAgents.delete(agentId);
|
|
3724
|
-
}
|
|
3725
|
-
results.push({ type: 'build-and-test', error: 'Failed to render build-and-test playbook' });
|
|
3726
|
-
break;
|
|
3727
|
-
}
|
|
3728
|
-
const id = dispatchMod.addToDispatch(item);
|
|
3729
|
-
if (agentId?.startsWith('temp-')) {
|
|
3730
|
-
routing.tempAgents.delete(agentId);
|
|
3731
|
-
routing._claimedAgents.delete(agentId);
|
|
3732
|
-
}
|
|
3733
|
-
results.push({ type: 'build-and-test', id, agent: agentId, pr: pr.id, ok: true });
|
|
3734
|
-
break;
|
|
3735
|
-
}
|
|
3736
|
-
case 'note': {
|
|
3737
|
-
shared.writeToInbox('command-center', shared.slugify(action.title || 'note'), `# ${action.title || 'Note'}\n\n${action.content || action.description || ''}`);
|
|
3738
|
-
results.push({ type: 'note', ok: true });
|
|
3739
|
-
break;
|
|
3740
|
-
}
|
|
3741
|
-
case 'knowledge': {
|
|
3742
|
-
const validCategories = ['architecture', 'conventions', 'project-notes', 'build-reports', 'reviews'];
|
|
3743
|
-
const category = action.category || 'project-notes';
|
|
3744
|
-
if (!validCategories.includes(category)) { results.push({ type: 'knowledge', error: 'Invalid category: ' + category }); break; }
|
|
3745
|
-
const slug = shared.slugify(action.title || 'entry');
|
|
3746
|
-
const kbDir = path.join(MINIONS_DIR, 'knowledge', category);
|
|
3747
|
-
if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
|
|
3748
|
-
shared.safeWrite(path.join(kbDir, slug + '.md'), `# ${action.title}\n\n${action.content || action.description || ''}`);
|
|
3749
|
-
queries.invalidateKnowledgeBaseCache();
|
|
3750
|
-
results.push({ type: 'knowledge', ok: true });
|
|
3751
|
-
break;
|
|
3752
|
-
}
|
|
3753
|
-
case 'reopen-work-item': {
|
|
3754
|
-
const project = action.project || '';
|
|
3755
|
-
let targetProject = null;
|
|
3756
|
-
if (project) {
|
|
3757
|
-
const target = resolveProjectSourceTarget(project, PROJECTS, { allowCentral: false });
|
|
3758
|
-
targetProject = target.project;
|
|
3759
|
-
if (target.error) {
|
|
3760
|
-
results.push({ type: 'reopen-work-item', id: action.id, error: target.error });
|
|
3761
|
-
break;
|
|
3762
|
-
}
|
|
3763
|
-
} else if (inferredProject) {
|
|
3764
|
-
targetProject = resolveProjectSourceTarget(inferredProject, PROJECTS, { allowCentral: false }).project || null;
|
|
3765
|
-
}
|
|
3766
|
-
if (!targetProject) {
|
|
3767
|
-
if (PROJECTS.length > 1) {
|
|
3768
|
-
results.push({ type: 'reopen-work-item', id: action.id, error: _ccMultiProjectRequiredError({ ...action, type: 'reopen-work-item' }) });
|
|
3769
|
-
break;
|
|
3770
|
-
}
|
|
3771
|
-
if (PROJECTS.length === 1) targetProject = PROJECTS[0];
|
|
3772
|
-
}
|
|
3773
|
-
const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : shared.centralWorkItemsPath(MINIONS_DIR);
|
|
3774
|
-
let reopenResult = null;
|
|
3775
|
-
mutateJsonFileLocked(wiPath, items => {
|
|
3776
|
-
if (!Array.isArray(items)) items = [];
|
|
3777
|
-
const item = items.find(i => i.id === action.id);
|
|
3778
|
-
if (!item) { reopenResult = { error: 'item not found' }; return items; }
|
|
3779
|
-
if (item.status !== WI_STATUS.DONE && item.status !== WI_STATUS.FAILED && !DONE_STATUSES.has(item.status)) {
|
|
3780
|
-
reopenResult = { error: 'can only reopen done or failed items' }; return items;
|
|
3781
|
-
}
|
|
3782
|
-
reopenWorkItem(item);
|
|
3783
|
-
if (action.description) item.description = action.description;
|
|
3784
|
-
reopenResult = { ok: true };
|
|
3785
|
-
return items;
|
|
3786
|
-
}, { defaultValue: [] });
|
|
3787
|
-
if (reopenResult?.ok) {
|
|
3788
|
-
// Clear dispatch history outside lock
|
|
3789
|
-
const sourcePrefix = targetProject ? `work-${targetProject.name}-` : 'central-work-';
|
|
3790
|
-
const dispatchKey = sourcePrefix + action.id;
|
|
3791
|
-
try {
|
|
3792
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
3793
|
-
mutateJsonFileLocked(dispatchPath, dispatch => {
|
|
3794
|
-
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
3795
|
-
dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
|
|
3796
|
-
dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
|
|
3797
|
-
return dispatch;
|
|
3798
|
-
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
3799
|
-
} catch { /* best effort */ }
|
|
3800
|
-
invalidateStatusCache();
|
|
3801
|
-
}
|
|
3802
|
-
results.push({ type: 'reopen-work-item', id: action.id, ...(reopenResult || { error: 'unexpected' }) });
|
|
3803
|
-
break;
|
|
3804
|
-
}
|
|
3805
|
-
case 'create-watch': {
|
|
3806
|
-
const intervalMs = _parseWatchInterval(action.interval);
|
|
3807
|
-
const watch = watchesMod.createWatch({
|
|
3808
|
-
target: action.target,
|
|
3809
|
-
targetType: action.targetType || 'pr',
|
|
3810
|
-
condition: action.condition || 'build-pass',
|
|
3811
|
-
interval: intervalMs,
|
|
3812
|
-
owner: action.owner || 'human',
|
|
3813
|
-
description: action.description || null,
|
|
3814
|
-
project: action.project || null,
|
|
3815
|
-
notify: 'inbox',
|
|
3816
|
-
stopAfter: Number(action.stopAfter) || 0,
|
|
3817
|
-
onNotMet: action.onNotMet || null,
|
|
3818
|
-
});
|
|
3819
|
-
results.push({ type: 'create-watch', id: watch.id, ok: true });
|
|
3820
|
-
break;
|
|
3821
|
-
}
|
|
3822
|
-
case 'create-meeting': {
|
|
3823
|
-
const { createMeeting } = require('./engine/meeting');
|
|
3824
|
-
const meeting = createMeeting({
|
|
3825
|
-
title: action.title.trim(),
|
|
3826
|
-
agenda: action.agenda.trim(),
|
|
3827
|
-
participants: meetingParticipantsFromAction(action),
|
|
3828
|
-
});
|
|
3829
|
-
invalidateStatusCache();
|
|
3830
|
-
results.push({ type: 'create-meeting', id: meeting.id, ok: true });
|
|
3831
|
-
break;
|
|
3832
|
-
}
|
|
3833
|
-
case 'create-pipeline': {
|
|
3834
|
-
results.push(createPipelineFromAction(action));
|
|
3835
|
-
break;
|
|
3836
|
-
}
|
|
3837
|
-
case 'delete-watch': {
|
|
3838
|
-
const deleted = watchesMod.deleteWatch(action.id);
|
|
3839
|
-
if (deleted) invalidateStatusCache();
|
|
3840
|
-
results.push({ type: 'delete-watch', id: action.id, ok: deleted });
|
|
3841
|
-
break;
|
|
3842
|
-
}
|
|
3843
|
-
case 'pause-watch': {
|
|
3844
|
-
const paused = watchesMod.updateWatch(action.id, { status: shared.WATCH_STATUS.PAUSED });
|
|
3845
|
-
if (paused) invalidateStatusCache();
|
|
3846
|
-
results.push({ type: 'pause-watch', id: action.id, ok: !!paused });
|
|
3847
|
-
break;
|
|
3848
|
-
}
|
|
3849
|
-
case 'resume-watch': {
|
|
3850
|
-
const resumed = watchesMod.updateWatch(action.id, { status: shared.WATCH_STATUS.ACTIVE });
|
|
3851
|
-
if (resumed) invalidateStatusCache();
|
|
3852
|
-
results.push({ type: 'resume-watch', id: action.id, ok: !!resumed });
|
|
3853
|
-
break;
|
|
3854
|
-
}
|
|
3855
|
-
default: {
|
|
3856
|
-
const apiResult = await _ccExecuteLocalApiAction(action);
|
|
3857
|
-
if (apiResult) {
|
|
3858
|
-
results.push(apiResult);
|
|
3859
|
-
} else {
|
|
3860
|
-
// Server didn't handle — frontend must execute.
|
|
3861
|
-
results.push({ type: action.type });
|
|
3862
|
-
}
|
|
3863
|
-
break;
|
|
3864
|
-
}
|
|
3865
|
-
}
|
|
3866
|
-
} catch (e) {
|
|
3867
|
-
results.push({ type: action.type, error: e.message });
|
|
3868
|
-
}
|
|
3869
|
-
}
|
|
3870
|
-
return results;
|
|
3871
|
-
}
|
|
3872
|
-
|
|
3873
|
-
async function executeDocChatActions(actions, { filePath = null } = {}) {
|
|
3874
|
-
if (!Array.isArray(actions) || actions.length === 0) return undefined;
|
|
3875
|
-
return executeCCActions(actions, { source: 'doc-chat', inferredProject: _inferDocChatProject(filePath) });
|
|
3876
|
-
}
|
|
3877
|
-
|
|
3878
|
-
const DOC_CHAT_WORK_ITEM_ACTION_TYPES = new Set(['dispatch', 'fix', 'implement', 'implement:large', 'explore', 'review', 'test', 'ask', 'verify']);
|
|
3879
|
-
|
|
3880
|
-
function _buildDocChatActionFeedback(actions, actionResults) {
|
|
3881
|
-
if (!Array.isArray(actions) || !Array.isArray(actionResults)) return [];
|
|
3882
|
-
const feedback = [];
|
|
3883
|
-
for (let i = 0; i < actions.length && i < actionResults.length; i++) {
|
|
3884
|
-
const action = actions[i] || {};
|
|
3885
|
-
const result = actionResults[i] || {};
|
|
3886
|
-
const type = String(result.type || action.type || '').trim();
|
|
3887
|
-
if (!DOC_CHAT_WORK_ITEM_ACTION_TYPES.has(type)) continue;
|
|
3888
|
-
if (result.error) {
|
|
3889
|
-
feedback.push({ type, error: String(result.error) });
|
|
3890
|
-
continue;
|
|
3891
|
-
}
|
|
3892
|
-
if (result.reusedFromAction !== undefined) continue;
|
|
3893
|
-
const id = result.id || result.duplicateOf;
|
|
3894
|
-
if (!result.ok || !id) continue;
|
|
3895
|
-
const item = { type, id: String(id), ok: true };
|
|
3896
|
-
if (result.duplicate) item.duplicate = true;
|
|
3897
|
-
if (result.duplicateOf) item.duplicateOf = String(result.duplicateOf);
|
|
3898
|
-
if (result.warning) item.warning = String(result.warning);
|
|
3899
|
-
feedback.push(item);
|
|
3900
|
-
}
|
|
3901
|
-
return feedback;
|
|
3902
|
-
}
|
|
3903
2134
|
|
|
3904
2135
|
// ── Shared LLM call core — used by CC panel and doc modals ──────────────────
|
|
3905
2136
|
|
|
@@ -4318,33 +2549,11 @@ function contentFingerprint(str) {
|
|
|
4318
2549
|
return str.length + ':' + str.charCodeAt(0) + ':' + str.charCodeAt(mid) + ':' + str.charCodeAt(str.length - 1);
|
|
4319
2550
|
}
|
|
4320
2551
|
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
const answerPart = text.slice(0, docDelimiter.index).trim();
|
|
4325
|
-
const parsedActions = parseCCActions(answerPart);
|
|
4326
|
-
const { text: answer, actions } = parsedActions;
|
|
4327
|
-
let content = text.slice(docDelimiter.index + docDelimiter.length).trim();
|
|
4328
|
-
content = content.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
|
|
4329
|
-
return {
|
|
4330
|
-
answer,
|
|
4331
|
-
content,
|
|
4332
|
-
actions,
|
|
4333
|
-
...(parsedActions._actionParseError ? { actionParseError: parsedActions._actionParseError } : {}),
|
|
4334
|
-
};
|
|
4335
|
-
}
|
|
4336
|
-
const parsedActions = parseCCActions(text);
|
|
4337
|
-
const { text: stripped, actions } = parsedActions;
|
|
4338
|
-
return {
|
|
4339
|
-
answer: stripped,
|
|
4340
|
-
content: null,
|
|
4341
|
-
actions,
|
|
4342
|
-
...(parsedActions._actionParseError ? { actionParseError: parsedActions._actionParseError } : {}),
|
|
4343
|
-
};
|
|
4344
|
-
}
|
|
4345
|
-
|
|
2552
|
+
// _parseDocChatResultText / _docChatDisplayText were retired together with
|
|
2553
|
+
// the ---DOCUMENT--- and ===ACTIONS=== delimiters. Doc-chat now returns the
|
|
2554
|
+
// raw text answer; file edits flow through the Edit/Write tools.
|
|
4346
2555
|
function _docChatDisplayText(text) {
|
|
4347
|
-
return
|
|
2556
|
+
return String(text || '').trim();
|
|
4348
2557
|
}
|
|
4349
2558
|
|
|
4350
2559
|
function _inferDocChatProject(filePath) {
|
|
@@ -4368,8 +2577,9 @@ function _formatDocChatContext({ document, title, filePath, selection, canEdit,
|
|
|
4368
2577
|
// the file path explicitly so the model knows which file Edit can target.
|
|
4369
2578
|
const editInstructions = canEdit
|
|
4370
2579
|
? `\n\nIf editing is requested:\n` +
|
|
4371
|
-
`-
|
|
4372
|
-
`-
|
|
2580
|
+
`- Use the runtime \`Edit\` tool against \`${filePath}\` for localized changes (typo fixes, single sections, ≲30% of the file).\n` +
|
|
2581
|
+
`- Use the runtime \`Write\` tool against \`${filePath}\` for wholesale rewrites or whenever an Edit would invalidate document structure (JSON, code).\n` +
|
|
2582
|
+
`- After your tool calls return, just describe what you changed in plain text — the server re-reads the file from disk and refreshes the editor view. Do NOT echo the document content back, do NOT emit any delimiter (\`---DOCUMENT---\` and similar are gone).\n` +
|
|
4373
2583
|
`- Never edit any file other than \`${filePath}\`.`
|
|
4374
2584
|
: '\n\nRead-only — answer questions only.';
|
|
4375
2585
|
let context = `## Document Context\n**${safeTitle}**${location}${isJson ? ' (JSON)' : ''}${projectHint}\n\n`;
|
|
@@ -4478,8 +2688,6 @@ function _docChatFailureResponse(label, filePath, result, sessionPreserved = fal
|
|
|
4478
2688
|
console.error(`[${label}] Failed: code=${result.code}, errorClass=${result.errorClass || 'null'}, sessionPreserved=${sessionPreserved}, empty=${!result.text}, tools=${toolUses.length}, filePath=${filePath}, stderr=${envelope.stderr.slice(0, 200)}`);
|
|
4479
2689
|
return {
|
|
4480
2690
|
answer: _docChatErrorMessage(result.errorClass, sessionPreserved, toolUses, result.errorMessage || null),
|
|
4481
|
-
content: null,
|
|
4482
|
-
actions: [],
|
|
4483
2691
|
toolUses,
|
|
4484
2692
|
error: envelope,
|
|
4485
2693
|
};
|
|
@@ -4493,18 +2701,11 @@ function _docChatFailureResponse(label, filePath, result, sessionPreserved = fal
|
|
|
4493
2701
|
// Returns null when there's nothing parseable; caller falls through to failure.
|
|
4494
2702
|
function _recoverPartialDocChatResponse(result, sessionKey) {
|
|
4495
2703
|
if (!result || !result.text || !result.text.trim()) return null;
|
|
4496
|
-
const parsed = _parseDocChatResultText(result.text);
|
|
4497
|
-
const hasActions = Array.isArray(parsed.actions) && parsed.actions.length > 0;
|
|
4498
|
-
const hasAnswer = typeof parsed.answer === 'string' && !!parsed.answer.trim();
|
|
4499
|
-
const hasContent = typeof parsed.content === 'string' && !!parsed.content.trim();
|
|
4500
|
-
if (!hasAnswer && !hasContent && !hasActions) return null;
|
|
4501
2704
|
return {
|
|
4502
|
-
|
|
2705
|
+
answer: result.text,
|
|
4503
2706
|
partial: true,
|
|
4504
2707
|
warning: _docChatPartialWarning(result.errorClass, result.errorMessage || result.stderr || null),
|
|
4505
2708
|
toolUses: Array.isArray(result.toolUses) ? result.toolUses : [],
|
|
4506
|
-
// Recovery path still attaches the raw runtime failure — the answer landed
|
|
4507
|
-
// despite a non-zero exit; users still benefit from seeing why.
|
|
4508
2709
|
error: _buildDocChatErrorEnvelope(result),
|
|
4509
2710
|
};
|
|
4510
2711
|
}
|
|
@@ -4617,45 +2818,23 @@ function _finalizeDocChatEdit({ filePath, fullPath, isJson, canEdit, originalCon
|
|
|
4617
2818
|
return { edited: true, content: diskContent };
|
|
4618
2819
|
}
|
|
4619
2820
|
|
|
4620
|
-
//
|
|
4621
|
-
//
|
|
4622
|
-
//
|
|
4623
|
-
//
|
|
4624
|
-
//
|
|
4625
|
-
//
|
|
4626
|
-
// Also dedups identical post-strip answers: the upstream accumulator dedups
|
|
4627
|
-
// against the raw growing text, but that text keeps changing as the document
|
|
4628
|
-
// body streams in even though the visible answer is locked. Without this
|
|
4629
|
-
// guard the SSE writer fires a duplicate `chunk` event for every doc-body
|
|
4630
|
-
// delta, which triggers a client DOM rerender and localStorage write each time.
|
|
2821
|
+
// Dedupes identical post-strip answers so the SSE writer doesn't fire a
|
|
2822
|
+
// duplicate `chunk` event for every doc-body delta when the model streams
|
|
2823
|
+
// `Edit`/`Write` tool input alongside narration. Since the ===ACTIONS=== /
|
|
2824
|
+
// ---DOCUMENT--- delimiters are gone, we just pass text through untouched
|
|
2825
|
+
// with a last-sent guard.
|
|
4631
2826
|
function _makeDocChatStreamStripper(onChunk) {
|
|
4632
2827
|
if (!onChunk) return undefined;
|
|
4633
|
-
let lockedAnswer = null;
|
|
4634
2828
|
let lastSent;
|
|
4635
2829
|
return (text) => {
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
} else if (text.indexOf('---') < 0) {
|
|
4640
|
-
// Fast path for typical Q&A: the doc delimiter starts with "---", so
|
|
4641
|
-
// when the chunk doesn't contain that substring it can't possibly
|
|
4642
|
-
// contain ---MINIONS-DOC-CHAT-DOCUMENT-…--- or ---DOCUMENT---. Skip the
|
|
4643
|
-
// regex-heavy delimiter scan; we still need the actions stripper to
|
|
4644
|
-
// hide partial ===ACTIONS=== while the model is mid-emission.
|
|
4645
|
-
answer = stripCCActionsForStream(text);
|
|
4646
|
-
} else {
|
|
4647
|
-
const parsed = _parseDocChatResultText(text);
|
|
4648
|
-
if (parsed.content !== null) lockedAnswer = parsed.answer;
|
|
4649
|
-
answer = parsed.answer;
|
|
4650
|
-
}
|
|
4651
|
-
if (answer === lastSent) return;
|
|
4652
|
-
lastSent = answer;
|
|
4653
|
-
onChunk(answer);
|
|
2830
|
+
if (text === lastSent) return;
|
|
2831
|
+
lastSent = text;
|
|
2832
|
+
onChunk(text);
|
|
4654
2833
|
};
|
|
4655
2834
|
}
|
|
4656
2835
|
|
|
4657
2836
|
// Doc-specific wrapper — adds document context, parses ---DOCUMENT---
|
|
4658
|
-
async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, transcript, onAbortReady }) {
|
|
2837
|
+
async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, transcript, onAbortReady, systemPrompt = DOC_CHAT_SYSTEM_PROMPT }) {
|
|
4659
2838
|
const sessionKey = filePath || title;
|
|
4660
2839
|
const docSlice = String(document || '');
|
|
4661
2840
|
|
|
@@ -4675,13 +2854,10 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
|
|
|
4675
2854
|
store: 'doc', sessionKey,
|
|
4676
2855
|
extraContext, label: 'doc-chat',
|
|
4677
2856
|
timeout: DOC_CHAT_TIMEOUT_MS,
|
|
4678
|
-
// Match Command Center's full tool surface
|
|
4679
|
-
//
|
|
4680
|
-
// to Q&A. The doc-chat sysprompt scopes orchestration to human message
|
|
4681
|
-
// intent (explicit dispatch or medium/larger work), and ---DOCUMENT---
|
|
4682
|
-
// remains the only whole-file document edit channel.
|
|
2857
|
+
// Match Command Center's full tool surface so doc-chat can take action
|
|
2858
|
+
// (read/write/edit/dispatch) instead of being limited to Q&A.
|
|
4683
2859
|
allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
|
|
4684
|
-
systemPrompt
|
|
2860
|
+
systemPrompt,
|
|
4685
2861
|
transcript,
|
|
4686
2862
|
...(model ? { model } : {}),
|
|
4687
2863
|
onAbortReady,
|
|
@@ -4717,10 +2893,15 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
|
|
|
4717
2893
|
return _docChatFailureResponse('doc-chat', filePath, result, sessionPreserved);
|
|
4718
2894
|
}
|
|
4719
2895
|
|
|
4720
|
-
|
|
2896
|
+
// No more ===ACTIONS=== / ---DOCUMENT--- parsing — CC's text answer is the
|
|
2897
|
+
// visible reply; file edits land via Edit/Write tools (validated in
|
|
2898
|
+
// _finalizeDocChatEdit by re-reading disk); state mutations land via direct
|
|
2899
|
+
// /api/* calls (correlated by X-CC-Turn-Id, surfaced as synthetic chips
|
|
2900
|
+
// server-side).
|
|
2901
|
+
return { answer: result.text, toolUses: Array.isArray(result.toolUses) ? result.toolUses : [] };
|
|
4721
2902
|
}
|
|
4722
2903
|
|
|
4723
|
-
async function ccDocCallStreaming({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, transcript, onAbortReady, onChunk, onToolUse, onRetry }) {
|
|
2904
|
+
async function ccDocCallStreaming({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, transcript, onAbortReady, onChunk, onToolUse, onRetry, systemPrompt = DOC_CHAT_SYSTEM_PROMPT }) {
|
|
4724
2905
|
const sessionKey = filePath || title;
|
|
4725
2906
|
const docSlice = String(document || '');
|
|
4726
2907
|
const streamStripper = _makeDocChatStreamStripper(onChunk);
|
|
@@ -4737,11 +2918,9 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
|
|
|
4737
2918
|
store: 'doc', sessionKey,
|
|
4738
2919
|
extraContext, label: 'doc-chat',
|
|
4739
2920
|
timeout: DOC_CHAT_TIMEOUT_MS,
|
|
4740
|
-
// Match Command Center's full tool surface — see ccDocCall for
|
|
4741
|
-
// rationale. Both wrappers must share the same policy so the streaming
|
|
4742
|
-
// variant doesn't diverge from the non-streaming one.
|
|
2921
|
+
// Match Command Center's full tool surface — see ccDocCall for rationale.
|
|
4743
2922
|
allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
|
|
4744
|
-
systemPrompt
|
|
2923
|
+
systemPrompt,
|
|
4745
2924
|
transcript,
|
|
4746
2925
|
...(model ? { model } : {}),
|
|
4747
2926
|
onAbortReady,
|
|
@@ -4776,7 +2955,12 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
|
|
|
4776
2955
|
return _docChatFailureResponse('doc-chat-stream', filePath, result, sessionPreserved);
|
|
4777
2956
|
}
|
|
4778
2957
|
|
|
4779
|
-
|
|
2958
|
+
// No more ===ACTIONS=== / ---DOCUMENT--- parsing — CC's text answer is the
|
|
2959
|
+
// visible reply; file edits land via Edit/Write tools (validated in
|
|
2960
|
+
// _finalizeDocChatEdit by re-reading disk); state mutations land via direct
|
|
2961
|
+
// /api/* calls (correlated by X-CC-Turn-Id, surfaced as synthetic chips
|
|
2962
|
+
// server-side).
|
|
2963
|
+
return { answer: result.text, toolUses: Array.isArray(result.toolUses) ? result.toolUses : [] };
|
|
4780
2964
|
}
|
|
4781
2965
|
|
|
4782
2966
|
// -- POST helpers --
|
|
@@ -6843,25 +5027,39 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6843
5027
|
}
|
|
6844
5028
|
}
|
|
6845
5029
|
|
|
6846
|
-
|
|
5030
|
+
// Per-turn correlation: render the doc-chat sysprompt with a fresh
|
|
5031
|
+
// {{cc_turn_id}} so any /api/* calls CC makes during the turn surface
|
|
5032
|
+
// as chips, plus snapshot the target file so we can detect Edit/Write
|
|
5033
|
+
// tool changes and emit a "Document saved" chip.
|
|
5034
|
+
const ccTurnId = 'cct-' + shared.uid();
|
|
5035
|
+
const turnSystemPrompt = renderDocChatSystemPromptForTurn(ccTurnId);
|
|
5036
|
+
const docFileBefore = _snapshotDocFile(fullPath);
|
|
5037
|
+
|
|
5038
|
+
let { answer, partial, warning, toolUses, error: ccError } = await ccDocCall({
|
|
6847
5039
|
message: body.message, document: currentContent, title: body.title,
|
|
6848
5040
|
filePath: body.filePath, selection: body.selection, canEdit, isJson,
|
|
6849
5041
|
model: body.model || undefined,
|
|
6850
5042
|
freshSession: !!body.freshSession,
|
|
6851
5043
|
transcript: body.transcript,
|
|
6852
5044
|
onAbortReady: (abort) => { _docAbort = abort; },
|
|
5045
|
+
systemPrompt: turnSystemPrompt,
|
|
6853
5046
|
});
|
|
6854
|
-
const delegatedActions = _ensureDelegationForIntent(actions, {
|
|
6855
|
-
message: body.message, source: 'doc-chat', filePath: body.filePath, title: body.title, answerText: answer, toolUses,
|
|
6856
|
-
});
|
|
6857
|
-
const actionResults = await executeDocChatActions(delegatedActions, { filePath: body.filePath });
|
|
6858
|
-
const actionFeedback = _buildDocChatActionFeedback(delegatedActions, actionResults);
|
|
6859
5047
|
const finalize = _finalizeDocChatEdit({
|
|
6860
5048
|
filePath: body.filePath, fullPath, isJson, canEdit,
|
|
6861
|
-
originalContent: currentContent, delimiterContent:
|
|
5049
|
+
originalContent: currentContent, delimiterContent: null,
|
|
6862
5050
|
});
|
|
5051
|
+
// Record a document-saved entry under this turn ID if the file mtime/size
|
|
5052
|
+
// changed during the call — surfaces as a "Document saved" chip.
|
|
5053
|
+
if (canEdit && _docFileChanged(docFileBefore, fullPath)) {
|
|
5054
|
+
_recordCcTurnCreation(ccTurnId, { kind: 'document-saved', path: body.filePath });
|
|
5055
|
+
}
|
|
5056
|
+
const _synthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, new Date().toISOString());
|
|
6863
5057
|
const payload = _buildDocChatResponsePayload({
|
|
6864
|
-
answer,
|
|
5058
|
+
answer,
|
|
5059
|
+
actions: _synthetic.actions,
|
|
5060
|
+
actionResults: _synthetic.results,
|
|
5061
|
+
actionFeedback: null, actionParseError: null,
|
|
5062
|
+
ccError, partial, warning, toolUses, finalize,
|
|
6865
5063
|
});
|
|
6866
5064
|
_docDone = true;
|
|
6867
5065
|
return jsonReply(res, 200, payload);
|
|
@@ -6938,7 +5136,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6938
5136
|
|
|
6939
5137
|
try {
|
|
6940
5138
|
|
|
6941
|
-
|
|
5139
|
+
// Per-turn correlation: see handleDocChat for the matching pattern.
|
|
5140
|
+
const ccTurnId = 'cct-' + shared.uid();
|
|
5141
|
+
const turnSystemPrompt = renderDocChatSystemPromptForTurn(ccTurnId);
|
|
5142
|
+
const docFileBefore = _snapshotDocFile(fullPath);
|
|
5143
|
+
|
|
5144
|
+
let { answer, partial, warning, toolUses, error: ccError } = await ccDocCallStreaming({
|
|
6942
5145
|
message: body.message, document: currentContent, title: body.title,
|
|
6943
5146
|
filePath: body.filePath, selection: body.selection, canEdit, isJson,
|
|
6944
5147
|
model: body.model || undefined,
|
|
@@ -6948,18 +5151,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6948
5151
|
onChunk: (text) => { writeDocEvent({ type: 'chunk', text }); },
|
|
6949
5152
|
onToolUse: (name, input) => { writeDocEvent({ type: 'tool', name, input: _lightToolInput(input) }); },
|
|
6950
5153
|
onRetry: (attempt) => { writeDocEvent({ type: 'progress', attempt }); },
|
|
5154
|
+
systemPrompt: turnSystemPrompt,
|
|
6951
5155
|
});
|
|
6952
|
-
const delegatedActions = _ensureDelegationForIntent(actions, {
|
|
6953
|
-
message: body.message, source: 'doc-chat', filePath: body.filePath, title: body.title, answerText: answer, toolUses,
|
|
6954
|
-
});
|
|
6955
|
-
const actionResults = await executeDocChatActions(delegatedActions, { filePath: body.filePath });
|
|
6956
|
-
const actionFeedback = _buildDocChatActionFeedback(delegatedActions, actionResults);
|
|
6957
5156
|
const finalize = _finalizeDocChatEdit({
|
|
6958
5157
|
filePath: body.filePath, fullPath, isJson, canEdit,
|
|
6959
|
-
originalContent: currentContent, delimiterContent:
|
|
5158
|
+
originalContent: currentContent, delimiterContent: null,
|
|
6960
5159
|
});
|
|
5160
|
+
if (canEdit && _docFileChanged(docFileBefore, fullPath)) {
|
|
5161
|
+
_recordCcTurnCreation(ccTurnId, { kind: 'document-saved', path: body.filePath });
|
|
5162
|
+
}
|
|
5163
|
+
const _streamSynthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, new Date().toISOString());
|
|
6961
5164
|
const payload = _buildDocChatResponsePayload({
|
|
6962
|
-
answer,
|
|
5165
|
+
answer,
|
|
5166
|
+
actions: _streamSynthetic.actions,
|
|
5167
|
+
actionResults: _streamSynthetic.results,
|
|
5168
|
+
actionFeedback: null, actionParseError: null,
|
|
5169
|
+
ccError, partial, warning, toolUses, finalize,
|
|
6963
5170
|
});
|
|
6964
5171
|
const { answer: finalAnswer, ...donePayload } = payload;
|
|
6965
5172
|
writeDocEvent({
|
|
@@ -7486,45 +5693,24 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7486
5693
|
});
|
|
7487
5694
|
}
|
|
7488
5695
|
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
//
|
|
7492
|
-
const
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
7500
|
-
|
|
7501
|
-
)
|
|
7502
|
-
if (parsed.actions.length > 0) {
|
|
7503
|
-
parsed.actionResults = await executeCCActions(parsed.actions);
|
|
7504
|
-
parsed.actionResultsAt = new Date().toISOString();
|
|
7505
|
-
}
|
|
7506
|
-
// Merge synthetic action results from CC's direct /api/* tool calls
|
|
7507
|
-
// (correlated via the X-CC-Turn-Id header). Replaces the ===ACTIONS===
|
|
7508
|
-
// executor for the canonical "CC calls APIs directly" path while
|
|
7509
|
-
// leaving the legacy parser available as a fallback during transition.
|
|
7510
|
-
const _synthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, parsed.actionResultsAt || new Date().toISOString());
|
|
7511
|
-
if (_synthetic.actions.length > 0) {
|
|
7512
|
-
parsed.actions = (parsed.actions || []).concat(_synthetic.actions);
|
|
7513
|
-
parsed.actionResults = (parsed.actionResults || []).concat(_synthetic.results);
|
|
7514
|
-
parsed.actionResultsAt = parsed.actionResultsAt || new Date().toISOString();
|
|
7515
|
-
}
|
|
7516
|
-
// Mirror only user-facing text to Teams; never send the internal action block.
|
|
5696
|
+
// CC mutates state via direct /api/* calls (correlated via X-CC-Turn-Id);
|
|
5697
|
+
// any creations during this turn surface as synthetic action results
|
|
5698
|
+
// for the existing chip renderer.
|
|
5699
|
+
const _synthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, new Date().toISOString());
|
|
5700
|
+
const replyBody = {
|
|
5701
|
+
text: result.text,
|
|
5702
|
+
actions: _synthetic.actions,
|
|
5703
|
+
actionResults: _synthetic.results,
|
|
5704
|
+
...(_synthetic.actions.length > 0 ? { actionResultsAt: new Date().toISOString() } : {}),
|
|
5705
|
+
sessionId: ccSession.sessionId,
|
|
5706
|
+
newSession: !wasResume,
|
|
5707
|
+
};
|
|
5708
|
+
// Mirror user-facing text to Teams (skip Teams-originated turns).
|
|
7517
5709
|
if (!tabId.startsWith('teams-')) {
|
|
7518
|
-
teams.teamsPostCCResponse(body.message,
|
|
5710
|
+
teams.teamsPostCCResponse(body.message, result.text).catch(() => {});
|
|
7519
5711
|
}
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
// but the JSON couldn't be recovered.
|
|
7523
|
-
const { _actionParseError, ...parsedReply } = parsed;
|
|
7524
|
-
const reply = { ...parsedReply, sessionId: ccSession.sessionId, newSession: !wasResume };
|
|
7525
|
-
if (_actionParseError) reply.actionParseError = _actionParseError;
|
|
7526
|
-
if (sessionReset) reply.sessionReset = true;
|
|
7527
|
-
return jsonReply(res, 200, reply);
|
|
5712
|
+
if (sessionReset) replyBody.sessionReset = true;
|
|
5713
|
+
return jsonReply(res, 200, replyBody);
|
|
7528
5714
|
} finally {
|
|
7529
5715
|
_releaseCCTab(tabId);
|
|
7530
5716
|
}
|
|
@@ -7559,9 +5745,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7559
5745
|
engineConfig,
|
|
7560
5746
|
onChunk: (text) => {
|
|
7561
5747
|
_touchCcLiveStream(liveState);
|
|
7562
|
-
|
|
7563
|
-
liveState.text
|
|
7564
|
-
if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
|
|
5748
|
+
liveState.text = text;
|
|
5749
|
+
if (liveState.writer) liveState.writer({ type: 'chunk', text });
|
|
7565
5750
|
},
|
|
7566
5751
|
onToolUse: (name, input) => {
|
|
7567
5752
|
_touchCcLiveStream(liveState);
|
|
@@ -7835,36 +6020,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7835
6020
|
} catch { /* non-critical */ }
|
|
7836
6021
|
}
|
|
7837
6022
|
|
|
7838
|
-
//
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
const
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
console.warn('[CC] /loop invocation detected — converted to create-watch');
|
|
7845
|
-
try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
|
|
7846
|
-
}
|
|
7847
|
-
actions = _actionsWithIntentFallback(
|
|
7848
|
-
_filterImplicitPostDispatchActions(actions, body.message),
|
|
7849
|
-
{ message: body.message, intentMetadata: body.intentMetadata, source: 'command-center', answerText: displayText, toolUses }
|
|
7850
|
-
);
|
|
7851
|
-
let actionResults;
|
|
7852
|
-
let actionResultsAt;
|
|
7853
|
-
if (actions.length > 0) {
|
|
7854
|
-
actionResults = await executeCCActions(actions);
|
|
7855
|
-
actionResultsAt = new Date().toISOString();
|
|
7856
|
-
}
|
|
7857
|
-
// Merge synthetic action results from CC's direct /api/* tool calls.
|
|
7858
|
-
const _streamSynthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, actionResultsAt || new Date().toISOString());
|
|
7859
|
-
if (_streamSynthetic.actions.length > 0) {
|
|
7860
|
-
actions = (actions || []).concat(_streamSynthetic.actions);
|
|
7861
|
-
actionResults = (actionResults || []).concat(_streamSynthetic.results);
|
|
7862
|
-
actionResultsAt = actionResultsAt || new Date().toISOString();
|
|
7863
|
-
}
|
|
6023
|
+
// CC mutates state via direct /api/* tool calls; surface those as chips.
|
|
6024
|
+
const displayText = result.text;
|
|
6025
|
+
const _streamSynthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, new Date().toISOString());
|
|
6026
|
+
const actions = _streamSynthetic.actions;
|
|
6027
|
+
const actionResults = _streamSynthetic.results;
|
|
6028
|
+
const actionResultsAt = actions.length > 0 ? new Date().toISOString() : undefined;
|
|
7864
6029
|
const donePayload = { type: 'done', text: displayText, actions, actionResults, actionResultsAt, sessionId: responseSessionId, newSession: !wasResume };
|
|
7865
|
-
// Issue #1834: surface action JSON parse failures so the UI can warn
|
|
7866
|
-
// instead of silently dropping. Client renders this as a small notice.
|
|
7867
|
-
if (_actionParseError) donePayload.actionParseError = _actionParseError;
|
|
7868
6030
|
if (sessionReset) {
|
|
7869
6031
|
donePayload.sessionReset = true;
|
|
7870
6032
|
if (sessionResetReason) donePayload.sessionResetReason = sessionResetReason;
|
|
@@ -9492,12 +7654,8 @@ module.exports = {
|
|
|
9492
7654
|
readBody,
|
|
9493
7655
|
_filterCcTabSessions,
|
|
9494
7656
|
_getVersionCheckInterval,
|
|
9495
|
-
_parseWatchInterval,
|
|
9496
7657
|
_normalizeMeetingParticipants: normalizeMeetingParticipants,
|
|
9497
|
-
_meetingParticipantsFromAction: meetingParticipantsFromAction,
|
|
9498
7658
|
parsePinnedEntries,
|
|
9499
|
-
_parseDocChatResultText,
|
|
9500
|
-
_buildDocChatActionFeedback,
|
|
9501
7659
|
_formatDocChatContext,
|
|
9502
7660
|
_isCompletedMeetingJson,
|
|
9503
7661
|
_finalizeDocChatEdit,
|
|
@@ -9511,18 +7669,15 @@ module.exports = {
|
|
|
9511
7669
|
_shouldSuppressDocChatPostPatchError,
|
|
9512
7670
|
_buildDocChatResponsePayload,
|
|
9513
7671
|
_inferDocChatProject,
|
|
9514
|
-
_messageHasDelegationIntent,
|
|
9515
|
-
_messageRequestsDirectHandling,
|
|
9516
|
-
_messageHasMediumLargeWorkIntent,
|
|
9517
|
-
_inferDelegationActionFromMessage,
|
|
9518
|
-
_ensureDelegationForIntent,
|
|
9519
7672
|
_linkPullRequestForTracking: linkPullRequestForTracking,
|
|
9520
7673
|
_resolveSkillReadPath,
|
|
9521
|
-
|
|
9522
|
-
|
|
9523
|
-
|
|
9524
|
-
|
|
9525
|
-
|
|
7674
|
+
// Per-CC-turn correlation surface (replaces ===ACTIONS=== protocol)
|
|
7675
|
+
_ccTurnCreations,
|
|
7676
|
+
_recordCcTurnCreation,
|
|
7677
|
+
_consumeCcTurnCreations,
|
|
7678
|
+
_readCcTurnIdHeader,
|
|
7679
|
+
_buildSyntheticActionResultsForTurn,
|
|
7680
|
+
CC_TURN_CREATION_TTL_MS,
|
|
9526
7681
|
_findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
|
|
9527
7682
|
_createWorkItemWithDedup: createWorkItemWithDedup,
|
|
9528
7683
|
_resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
|
|
@@ -9530,12 +7685,6 @@ module.exports = {
|
|
|
9530
7685
|
_buildManualPrdItemPlan: buildManualPrdItemPlan,
|
|
9531
7686
|
_resolveScheduleProjectValue: resolveScheduleProjectValue,
|
|
9532
7687
|
_collectArchivedWorkItems: collectArchivedWorkItems,
|
|
9533
|
-
_createPipelineFromAction: createPipelineFromAction,
|
|
9534
|
-
_setCcLocalApiInvokerForTest,
|
|
9535
|
-
_resetCcApiRoutesMetaForTest,
|
|
9536
|
-
_ccValidateLocalApiFallback,
|
|
9537
|
-
executeCCActions,
|
|
9538
|
-
executeDocChatActions,
|
|
9539
7688
|
buildCCStatePreamble,
|
|
9540
7689
|
_routesAsMeta,
|
|
9541
7690
|
_buildTranscriptCarryover,
|