@dadado/agent-kit-cli 4.8.0 → 4.8.2
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/dashboard/dashboard-data.mjs +813 -0
- package/dashboard/dashboard.html +6882 -0
- package/dashboard/lib/guards.mjs +917 -0
- package/dashboard/lib/live-refresh.mjs +147 -0
- package/dashboard/lib/semantic-model.d.mts +34 -0
- package/dashboard/lib/semantic-model.mjs +3581 -0
- package/dashboard/logo-cursor.svg +10 -0
- package/dashboard/logo.svg +1 -0
- package/dashboard/serve.mjs +550 -0
- package/dashboard/start-broadcast.mjs +231 -0
- package/dashboard/start.mjs +292 -0
- package/dist/index.js +1281 -168
- package/package.json +5 -3
|
@@ -0,0 +1,3581 @@
|
|
|
1
|
+
// dashboard/lib/semantic-model.mjs
|
|
2
|
+
// Pure Mission Control view-model helpers (testable; no fs/git I/O).
|
|
3
|
+
|
|
4
|
+
import { truncateStr } from "./guards.mjs";
|
|
5
|
+
|
|
6
|
+
export const MAX_ACTIVITY = 28;
|
|
7
|
+
export const MAX_ATTENTION = 15;
|
|
8
|
+
export const MAX_SEMANTIC_LABEL = 200;
|
|
9
|
+
/** First-line chat identifying snippet shown on unanswered-prompt rows. */
|
|
10
|
+
export const MAX_CHAT_SNIPPET = 80;
|
|
11
|
+
export const MAX_GIT_ACTIVITY = 15;
|
|
12
|
+
/** Cap for agents/skills/commands/memory inventory delta events per snapshot. */
|
|
13
|
+
export const MAX_INVENTORY_ACTIVITY = 12;
|
|
14
|
+
/** Bound inventory names in labels (escapeHtml still applied at render). */
|
|
15
|
+
export const MAX_INVENTORY_NAME = 60;
|
|
16
|
+
/** Crew Monitor hero feed display cap (denser than the prior ~8–9 sparse rows).
|
|
17
|
+
* Exposed on buildMissionControlView as monitorFeedCap for dashboard.html
|
|
18
|
+
* (HTML cannot import this ESM module). */
|
|
19
|
+
export const MONITOR_FEED_CAP = 20;
|
|
20
|
+
/** Cap agent_step rows emitted per active plan for the denser Crew feed. */
|
|
21
|
+
export const MONITOR_AGENT_STEP_EMIT_CAP = 12;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Monitor hero curated subset over the semantic activity stream.
|
|
25
|
+
* Live agent steps: run_plan / handoff / delivery plus agent_step (Task/orchestrator
|
|
26
|
+
* to-do steps). plan_progress milestones stay on Activity / Checklist.
|
|
27
|
+
* Activity (Phase 2) is the superset; inventory kinds are excluded here.
|
|
28
|
+
*/
|
|
29
|
+
export const MONITOR_ACTIVITY_KINDS = Object.freeze([
|
|
30
|
+
"run_plan",
|
|
31
|
+
"handoff",
|
|
32
|
+
"delivery",
|
|
33
|
+
"agent_step",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Canonical resume guidance for unanswered agent-prompt Copy chat id controls
|
|
38
|
+
* (tooltip / aria). Not rendered in the prompt card body.
|
|
39
|
+
* Must not claim the panel opens a chat, file, or editor.
|
|
40
|
+
* dashboard.html cannot import this ESM module; it mirrors the same string as
|
|
41
|
+
* `PROMPT_RESUME_GUIDANCE` for `copyActionTitle('…', 'pastChatPicker')`.
|
|
42
|
+
* field-report-prompts.test.ts asserts the HTML mirror matches this export.
|
|
43
|
+
* See .cursor/memory/decisions/2026-07-25_mission-control-field-report-source-contract.md.
|
|
44
|
+
*/
|
|
45
|
+
export const PROMPT_RESUME_GUIDANCE =
|
|
46
|
+
"Copy the chat id, paste it into the past-chat picker to resume, then answer the pending question.";
|
|
47
|
+
|
|
48
|
+
// Cap for agent prompts merged into the Field Report attention stack.
|
|
49
|
+
export const MAX_AGENT_PROMPTS = 8;
|
|
50
|
+
|
|
51
|
+
// Max external review reports surfaced in Field Report. Aligned with
|
|
52
|
+
// MAX_REPORT_FILES in dashboard-data.mjs so the surfacing cap never truncates
|
|
53
|
+
// below what the snapshot already read. External review is post-hoc, so most
|
|
54
|
+
// rows land as review debt; a low cap silently drops that owed triage.
|
|
55
|
+
export const MAX_EXTERNAL_REPORTS = 20;
|
|
56
|
+
|
|
57
|
+
// Max readiness advisories merged into the Field Report attention stack.
|
|
58
|
+
// Historical name kept; plan-state NOTES no longer emit here (Checklist cards).
|
|
59
|
+
export const MAX_CHECKLIST_NOTES = 15;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* External review reports are `.cursor/memory/plan-monitor-<slug>.md`. */
|
|
63
|
+
export const EXTERNAL_REPORT_FILE_RE = /^plan-monitor-(.+)\.md$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A heading the triage step leaves behind in the report itself. Confirmed
|
|
67
|
+
* against the local reports: `## Triage note - residual (A) verified` and
|
|
68
|
+
* `## Follow-up plan - hitl_ask_questions_residuals_2026_07_20.plan.md`.
|
|
69
|
+
* `/plan-review-triage` must write one of these for every outcome, including
|
|
70
|
+
* Ack and stop, so Field Report can clear the untriaged row.
|
|
71
|
+
*/
|
|
72
|
+
export const TRIAGE_HEADING_RE = /^#{2,6}\s+.*\b(triage|follow-?up plan|residuals plan)\b/im;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Local Field Report dismissals store (IDs only). Valid attention ids that
|
|
76
|
+
* `/field-report-resolve` may append: External reviews, agent prompts, and
|
|
77
|
+
* activity cadence warnings.
|
|
78
|
+
* Plan-state and HANDOFF ids are not dismiss targets on this surface.
|
|
79
|
+
*/
|
|
80
|
+
export const FIELD_REPORT_ATTENTION_ID_RE =
|
|
81
|
+
/^attention:(?:prompt:[A-Za-z0-9._-]+|report:[A-Za-z0-9._-]+|cadence:[A-Za-z0-9._-]+)$/;
|
|
82
|
+
|
|
83
|
+
/** `**Plan:** [`name.plan.md`](../plans/name.plan.md)` in the report header. */
|
|
84
|
+
const REPORT_REVIEWED_PLAN_RE = /^\*\*Plan:\*\*\s*\[`([^`]+)`\]/m;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Exact `*.plan.md` basename tokens. Used for Field Report lifecycle clear
|
|
88
|
+
* from pending-question text; no fuzzy product-area matching.
|
|
89
|
+
*/
|
|
90
|
+
const PLAN_FILE_REF_RE = /\b([A-Za-z0-9._-]+\.plan\.md)\b/gi;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lifecycles that clear a prompt or demote an External-review row from the
|
|
94
|
+
* blocking Field Report stack. `backlog` and live states never qualify.
|
|
95
|
+
* `archived` covers plan files moved to `.cursor/plans/archive/`.
|
|
96
|
+
*/
|
|
97
|
+
const TERMINAL_FIELD_REPORT_LIFECYCLES = new Set(["completed", "parked", "archived"]);
|
|
98
|
+
|
|
99
|
+
/** True when `id` is a dismissable Field Report attention id. */
|
|
100
|
+
export function isFieldReportAttentionId(id) {
|
|
101
|
+
return typeof id === "string" && FIELD_REPORT_ATTENTION_ID_RE.test(id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract exact `*.plan.md` basenames from pending-question (or similar) text.
|
|
106
|
+
* Prefer false negatives: unmatched or missing refs yield an empty list.
|
|
107
|
+
* @param {unknown} text
|
|
108
|
+
* @returns {string[]}
|
|
109
|
+
*/
|
|
110
|
+
export function extractPlanFileRefs(text) {
|
|
111
|
+
if (typeof text !== "string" || !text) return [];
|
|
112
|
+
const ids = [];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
for (const match of text.matchAll(PLAN_FILE_REF_RE)) {
|
|
115
|
+
const base = String(match[1] || "")
|
|
116
|
+
.split("/")
|
|
117
|
+
.pop()
|
|
118
|
+
.trim();
|
|
119
|
+
if (!base || seen.has(base)) continue;
|
|
120
|
+
seen.add(base);
|
|
121
|
+
ids.push(base);
|
|
122
|
+
}
|
|
123
|
+
return ids;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a plan file's lifecycle from inventory + HANDOFF.
|
|
128
|
+
* Missing plan records are `unknown` (never treated as terminal), except when
|
|
129
|
+
* the file is present under `.cursor/plans/archive/`: archiving is routine
|
|
130
|
+
* hygiene, not a missing plan, so it resolves as the terminal `archived`.
|
|
131
|
+
* @param {string} planFile
|
|
132
|
+
* @param {object[]} plans
|
|
133
|
+
* @param {object|null} handoff
|
|
134
|
+
* @param {string[]} [archivedPlanFiles] - `*.plan.md` names found in the archive
|
|
135
|
+
* @returns {'executing'|'awaiting_user'|'parked'|'backlog'|'incomplete'|'completed'|'archived'|'unknown'}
|
|
136
|
+
*/
|
|
137
|
+
export function resolvePlanLifecycle(planFile, plans, handoff, archivedPlanFiles = []) {
|
|
138
|
+
const key = String(planFile || "")
|
|
139
|
+
.split("/")
|
|
140
|
+
.pop()
|
|
141
|
+
.trim();
|
|
142
|
+
if (!key || !/\.plan\.md$/i.test(key)) return "unknown";
|
|
143
|
+
const plan = (plans || []).find((p) => {
|
|
144
|
+
const pf = planFileKey(p);
|
|
145
|
+
return pf === key || pf.toLowerCase() === key.toLowerCase();
|
|
146
|
+
});
|
|
147
|
+
if (!plan) {
|
|
148
|
+
const archived = (archivedPlanFiles || []).some((f) => {
|
|
149
|
+
const base = String(f || "")
|
|
150
|
+
.split("/")
|
|
151
|
+
.pop()
|
|
152
|
+
.trim();
|
|
153
|
+
return base === key || base.toLowerCase() === key.toLowerCase();
|
|
154
|
+
});
|
|
155
|
+
return archived ? "archived" : "unknown";
|
|
156
|
+
}
|
|
157
|
+
// FR-SAC-02: a record with zero parsed to-dos is not reliable terminal
|
|
158
|
+
// evidence for attention clearing (malformed or unsupported frontmatter can
|
|
159
|
+
// yield an empty inventory). Treat it as unknown so a live prompt or report
|
|
160
|
+
// is never hidden on a parse failure. Attention clear needs positive terminal
|
|
161
|
+
// proof, which requires at least one parsed to-do.
|
|
162
|
+
if (todoStats(plan).total === 0) return "unknown";
|
|
163
|
+
return classifyPlan(plan, handoff);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** True when lifecycle clears/demotes Field Report rows (completed, parked, or archived). */
|
|
167
|
+
export function isPlanLifecycleTerminal(lifecycle) {
|
|
168
|
+
return TERMINAL_FIELD_REPORT_LIFECYCLES.has(lifecycle);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Prompt lifecycle clear: every exact `*.plan.md` ref in the pending question
|
|
173
|
+
* is terminal. No refs, or any active/backlog/unknown ref, keeps the row.
|
|
174
|
+
* Does not replace the strong user-answer clear in `detectAwaitingPrompt`.
|
|
175
|
+
* @param {unknown} pendingLabel
|
|
176
|
+
* @param {object[]} plans
|
|
177
|
+
* @param {object|null} handoff
|
|
178
|
+
*/
|
|
179
|
+
/**
|
|
180
|
+
* Snapshot auto-clear for prompts: true when every exact `*.plan.md` ref in the
|
|
181
|
+
* pending label is terminal in plan+HANDOFF. Intentionally narrower than
|
|
182
|
+
* `/field-report-resolve` subject_resolved (HANDOFF/backlog/parked named
|
|
183
|
+
* evidence). Prefer false negatives here; broader dismiss stays on the resolve
|
|
184
|
+
* claim-check path. See source-contract ADR "Prompt subject-resolved".
|
|
185
|
+
* @param {string} pendingLabel
|
|
186
|
+
* @param {object[]} plans
|
|
187
|
+
* @param {object|null} handoff
|
|
188
|
+
*/
|
|
189
|
+
export function isPromptClearedByPlanLifecycle(pendingLabel, plans, handoff) {
|
|
190
|
+
const refs = extractPlanFileRefs(pendingLabel);
|
|
191
|
+
if (refs.length === 0) return false;
|
|
192
|
+
return refs.every((ref) => isPlanLifecycleTerminal(resolvePlanLifecycle(ref, plans, handoff)));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Report demotion classifier (not triage): true when the reviewed plan is
|
|
197
|
+
* terminal in plan+HANDOFF. A missing reviewed-plan header or unknown lifecycle
|
|
198
|
+
* returns false. This is a classify signal, not an exclude flag:
|
|
199
|
+
* `buildExternalReportItems` uses it to put a row in the `debt` group (true) or
|
|
200
|
+
* the `blocking` group (false); it never removes the row. Must not set or imply
|
|
201
|
+
* `isReportTriaged` (triage remains the hard hide).
|
|
202
|
+
* @param {{ reviewedPlanFile?: string|null }} report
|
|
203
|
+
* @param {object[]} plans
|
|
204
|
+
* @param {object|null} handoff
|
|
205
|
+
* @param {string[]} [archivedPlanFiles] - `*.plan.md` names found in the archive
|
|
206
|
+
*/
|
|
207
|
+
export function isReportDemotedByPlanLifecycle(report, plans, handoff, archivedPlanFiles = []) {
|
|
208
|
+
const reviewed = report?.reviewedPlanFile;
|
|
209
|
+
if (typeof reviewed !== "string" || !reviewed.trim()) return false;
|
|
210
|
+
return isPlanLifecycleTerminal(resolvePlanLifecycle(reviewed, plans, handoff, archivedPlanFiles));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Copy-only resolve action for one or more Field Report attention ids. Pastes
|
|
215
|
+
* into chat; the agent turn writes the dismissals store. Never mutates the
|
|
216
|
+
* repo from the panel. Accepts `attention:report:<slug>`,
|
|
217
|
+
* `attention:prompt:<chatId>`, and `attention:cadence:<windowId>`.
|
|
218
|
+
* @param {string|string[]} attentionIdOrIds
|
|
219
|
+
*/
|
|
220
|
+
export function fieldReportResolveAction(attentionIdOrIds) {
|
|
221
|
+
const raw = Array.isArray(attentionIdOrIds) ? attentionIdOrIds : [attentionIdOrIds];
|
|
222
|
+
const ids = [];
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
for (const id of raw) {
|
|
225
|
+
if (!isFieldReportAttentionId(id) || seen.has(id)) continue;
|
|
226
|
+
seen.add(id);
|
|
227
|
+
ids.push(id);
|
|
228
|
+
}
|
|
229
|
+
if (ids.length === 0) return null;
|
|
230
|
+
const bulk = ids.length > 1;
|
|
231
|
+
return {
|
|
232
|
+
type: "copy",
|
|
233
|
+
target: `/field-report-resolve ${ids.join(" ")}`,
|
|
234
|
+
label: bulk ? "Copy resolve command for all" : "Copy resolve command",
|
|
235
|
+
subject: bulk ? "bulk resolve command" : "resolve command",
|
|
236
|
+
pasteDestination: "chatInput",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Copy-only Review-all action for Field Report attention items with open gaps.
|
|
242
|
+
* Produces `/plan-review-triage` with gap-filtered report paths in the same
|
|
243
|
+
* order as buildExternalReportItems (blocking first, then debt). Skips items
|
|
244
|
+
* with `hasOpenReviewGaps === false`. Returns null when empty or no paths.
|
|
245
|
+
* @param {object[]} items - rendered attention items (buildExternalReportItems output)
|
|
246
|
+
*/
|
|
247
|
+
export function fieldReportTriageAllAction(items) {
|
|
248
|
+
if (!Array.isArray(items) || items.length === 0) return null;
|
|
249
|
+
const paths = [];
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
for (const item of items) {
|
|
252
|
+
if (!item || typeof item.sourcePath !== "string") continue;
|
|
253
|
+
// Gap-aware: omit clean / no-open-residual rows (Review all, not every path).
|
|
254
|
+
if (item.hasOpenReviewGaps === false) continue;
|
|
255
|
+
const path = item.sourcePath.trim();
|
|
256
|
+
if (!path || seen.has(path)) continue;
|
|
257
|
+
seen.add(path);
|
|
258
|
+
paths.push(path);
|
|
259
|
+
}
|
|
260
|
+
if (paths.length === 0) return null;
|
|
261
|
+
return {
|
|
262
|
+
type: "copy",
|
|
263
|
+
target: `/plan-review-triage ${paths.join(" ")}`,
|
|
264
|
+
label: "Copy review command for all",
|
|
265
|
+
subject: "gap-aware review command for all",
|
|
266
|
+
pasteDestination: "chatInput",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Alias: Review all is the product name for the gap-filtered bulk CTA. */
|
|
271
|
+
export const fieldReportReviewAllAction = fieldReportTriageAllAction;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Parse `.cursor/context/field-report-dismissals.json`. Missing or malformed
|
|
275
|
+
* input yields an empty list (never throws). Keeps IDs only; drops unknown
|
|
276
|
+
* fields so conversation content cannot ride along.
|
|
277
|
+
* @param {unknown} raw
|
|
278
|
+
* @returns {{ dismissals: { id: string, at?: string, reason?: string }[] }}
|
|
279
|
+
*/
|
|
280
|
+
export function parseFieldReportDismissals(raw) {
|
|
281
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
282
|
+
return { dismissals: [] };
|
|
283
|
+
}
|
|
284
|
+
const list = Array.isArray(/** @type {{ dismissals?: unknown }} */ (raw).dismissals)
|
|
285
|
+
? /** @type {{ dismissals: unknown[] }} */ (raw).dismissals
|
|
286
|
+
: [];
|
|
287
|
+
const dismissals = [];
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
for (const entry of list) {
|
|
290
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
291
|
+
const id = typeof entry.id === "string" ? entry.id.trim() : "";
|
|
292
|
+
if (!isFieldReportAttentionId(id) || seen.has(id)) continue;
|
|
293
|
+
seen.add(id);
|
|
294
|
+
/** @type {{ id: string, at?: string, reason?: string }} */
|
|
295
|
+
const row = { id };
|
|
296
|
+
if (typeof entry.at === "string" && entry.at.trim()) {
|
|
297
|
+
row.at = entry.at.trim();
|
|
298
|
+
}
|
|
299
|
+
if (typeof entry.reason === "string" && entry.reason.trim()) {
|
|
300
|
+
row.reason = truncateStr(entry.reason.trim(), 120);
|
|
301
|
+
}
|
|
302
|
+
dismissals.push(row);
|
|
303
|
+
}
|
|
304
|
+
return { dismissals };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Attention ids from a parsed dismissals document. */
|
|
308
|
+
export function dismissedAttentionIds(parsed) {
|
|
309
|
+
return (parsed?.dismissals || []).map((d) => d.id).filter(Boolean);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Relative path of the gitignored mission timing ledger (local observation). */
|
|
313
|
+
export const MISSION_TIMING_LEDGER_REL = ".cursor/context/mission-timing.json";
|
|
314
|
+
|
|
315
|
+
/** Empty v1 ledger. */
|
|
316
|
+
export function emptyMissionTimingLedger() {
|
|
317
|
+
return { version: 1, missions: {} };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Parse `.cursor/context/mission-timing.json`. Missing or malformed input
|
|
322
|
+
* yields an empty ledger (never throws).
|
|
323
|
+
* @param {unknown} raw
|
|
324
|
+
*/
|
|
325
|
+
export function parseMissionTimingLedger(raw) {
|
|
326
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
327
|
+
return emptyMissionTimingLedger();
|
|
328
|
+
}
|
|
329
|
+
const missionsIn = /** @type {{ missions?: unknown }} */ (raw).missions;
|
|
330
|
+
if (!missionsIn || typeof missionsIn !== "object" || Array.isArray(missionsIn)) {
|
|
331
|
+
return emptyMissionTimingLedger();
|
|
332
|
+
}
|
|
333
|
+
/** @type {Record<string, { startedAt: string, frozenAt: string | null, stages: Record<string, { startedAt: string, endedAt: string | null }> }>} */
|
|
334
|
+
const missions = {};
|
|
335
|
+
for (const [planFile, entry] of Object.entries(missionsIn)) {
|
|
336
|
+
if (typeof planFile !== "string" || !planFile.endsWith(".plan.md")) continue;
|
|
337
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
338
|
+
const startedAt =
|
|
339
|
+
typeof entry.startedAt === "string" && entry.startedAt.trim() ? entry.startedAt.trim() : null;
|
|
340
|
+
if (!startedAt) continue;
|
|
341
|
+
const frozenAt =
|
|
342
|
+
typeof entry.frozenAt === "string" && entry.frozenAt.trim() ? entry.frozenAt.trim() : null;
|
|
343
|
+
/** @type {Record<string, { startedAt: string, endedAt: string | null }>} */
|
|
344
|
+
const stages = {};
|
|
345
|
+
const stagesIn = entry.stages;
|
|
346
|
+
if (stagesIn && typeof stagesIn === "object" && !Array.isArray(stagesIn)) {
|
|
347
|
+
for (const [todoId, stage] of Object.entries(stagesIn)) {
|
|
348
|
+
if (typeof todoId !== "string" || !todoId.trim()) continue;
|
|
349
|
+
if (!stage || typeof stage !== "object" || Array.isArray(stage)) continue;
|
|
350
|
+
const stageStart =
|
|
351
|
+
typeof stage.startedAt === "string" && stage.startedAt.trim()
|
|
352
|
+
? stage.startedAt.trim()
|
|
353
|
+
: null;
|
|
354
|
+
if (!stageStart) continue;
|
|
355
|
+
const endedAt =
|
|
356
|
+
typeof stage.endedAt === "string" && stage.endedAt.trim() ? stage.endedAt.trim() : null;
|
|
357
|
+
stages[todoId.trim()] = { startedAt: stageStart, endedAt };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
missions[planFile] = { startedAt, frozenAt, stages };
|
|
361
|
+
}
|
|
362
|
+
return { version: 1, missions };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Stable JSON for ledger write-on-change (avoid SSE refresh loops).
|
|
367
|
+
* @param {{ version?: number, missions?: Record<string, unknown> }} ledger
|
|
368
|
+
*/
|
|
369
|
+
export function serializeMissionTimingLedger(ledger) {
|
|
370
|
+
const parsed = parseMissionTimingLedger(ledger);
|
|
371
|
+
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Relative path of the gitignored Flight Log history ledger (past Gaps). */
|
|
375
|
+
export const FLIGHT_LOG_LEDGER_REL = ".cursor/context/flight-log.json";
|
|
376
|
+
|
|
377
|
+
/** Max past Gaps entries retained in the Flight Log ledger. */
|
|
378
|
+
export const FLIGHT_LOG_PAST_CAP = 15;
|
|
379
|
+
|
|
380
|
+
/** Empty v1 Flight Log ledger. */
|
|
381
|
+
export function emptyFlightLogLedger() {
|
|
382
|
+
return { version: 1, lastCurrent: null, past: [], flightKey: null };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Stable flight identity for wipe-on-new-flight (ADR flight boundary).
|
|
387
|
+
* Plan basename (or `none`); under `run-plan-all`, prefix with ordered queue id
|
|
388
|
+
* so a fresh queue start is a boundary even if the first plan matches.
|
|
389
|
+
* @param {object|null|undefined} handoff - parseHandoffMarkdown-shaped object
|
|
390
|
+
* @returns {string}
|
|
391
|
+
*/
|
|
392
|
+
export function buildFlightLogFlightKey(handoff) {
|
|
393
|
+
const planRaw = handoff?.plan != null ? String(handoff.plan).split("/").pop().trim() : "";
|
|
394
|
+
const planPart = planRaw && /\.plan\.md$/i.test(planRaw) ? planRaw.toLowerCase() : "none";
|
|
395
|
+
const mode = typeof handoff?.mode === "string" ? handoff.mode : "";
|
|
396
|
+
if (/\brun-plan-all\b/i.test(mode)) {
|
|
397
|
+
const queue = Array.isArray(handoff?.runQueue) ? handoff.runQueue : [];
|
|
398
|
+
const queueId = queue
|
|
399
|
+
.map((p) => String(p).split("/").pop().trim().toLowerCase())
|
|
400
|
+
.filter((base) => /\.plan\.md$/i.test(base))
|
|
401
|
+
.join(",");
|
|
402
|
+
return `queue:${queueId}#${planPart}`;
|
|
403
|
+
}
|
|
404
|
+
return `plan:${planPart}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Parse `.cursor/context/flight-log.json`. Missing or malformed → empty ledger.
|
|
409
|
+
* @param {unknown} raw
|
|
410
|
+
*/
|
|
411
|
+
export function parseFlightLogLedger(raw) {
|
|
412
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
413
|
+
return emptyFlightLogLedger();
|
|
414
|
+
}
|
|
415
|
+
const lastRaw = /** @type {{ lastCurrent?: unknown }} */ (raw).lastCurrent;
|
|
416
|
+
const lastCurrent = typeof lastRaw === "string" && lastRaw.trim() ? lastRaw.trim() : null;
|
|
417
|
+
const keyRaw = /** @type {{ flightKey?: unknown }} */ (raw).flightKey;
|
|
418
|
+
const flightKey = typeof keyRaw === "string" && keyRaw.trim() ? keyRaw.trim() : null;
|
|
419
|
+
const pastIn = /** @type {{ past?: unknown }} */ (raw).past;
|
|
420
|
+
/** @type {{ text: string, at: string, sourcePath: string }[]} */
|
|
421
|
+
const past = [];
|
|
422
|
+
if (Array.isArray(pastIn)) {
|
|
423
|
+
for (const entry of pastIn) {
|
|
424
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
425
|
+
const text = typeof entry.text === "string" && entry.text.trim() ? entry.text.trim() : null;
|
|
426
|
+
if (!text) continue;
|
|
427
|
+
const at =
|
|
428
|
+
typeof entry.at === "string" && entry.at.trim()
|
|
429
|
+
? entry.at.trim()
|
|
430
|
+
: new Date(0).toISOString();
|
|
431
|
+
const sourcePath =
|
|
432
|
+
typeof entry.sourcePath === "string" && entry.sourcePath.trim()
|
|
433
|
+
? entry.sourcePath.trim()
|
|
434
|
+
: ".cursor/HANDOFF.md";
|
|
435
|
+
past.push({ text, at, sourcePath });
|
|
436
|
+
if (past.length >= FLIGHT_LOG_PAST_CAP) break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return { version: 1, lastCurrent, past, flightKey };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Stable JSON for Flight Log write-on-change (avoid SSE refresh loops).
|
|
444
|
+
* @param {{ version?: number, lastCurrent?: string | null, past?: unknown[], flightKey?: string | null }} ledger
|
|
445
|
+
*/
|
|
446
|
+
export function serializeFlightLogLedger(ledger) {
|
|
447
|
+
const parsed = parseFlightLogLedger(ledger);
|
|
448
|
+
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Observe live Gaps vs the history ledger. When Gaps text changes **within the
|
|
453
|
+
* same flight**, append the previous non-empty value to past (dedupe identical
|
|
454
|
+
* consecutive; cap N). On flight-key change (new plan, queue start, or Plan
|
|
455
|
+
* none): wipe past and reset lastCurrent seed so prior-flight rows do not carry.
|
|
456
|
+
* @param {{ version?: number, lastCurrent?: string | null, past?: unknown[], flightKey?: string | null }} ledger
|
|
457
|
+
* @param {string | null | undefined} liveGaps
|
|
458
|
+
* @param {{ nowMs?: number, sourcePath?: string, pastCap?: number, flightKey?: string | null }} [opts]
|
|
459
|
+
*/
|
|
460
|
+
export function observeFlightLog(ledger, liveGaps, opts = {}) {
|
|
461
|
+
const nowMs =
|
|
462
|
+
typeof opts.nowMs === "number" && Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now();
|
|
463
|
+
const sourcePath =
|
|
464
|
+
typeof opts.sourcePath === "string" && opts.sourcePath.trim()
|
|
465
|
+
? opts.sourcePath.trim()
|
|
466
|
+
: ".cursor/HANDOFF.md";
|
|
467
|
+
const pastCap =
|
|
468
|
+
typeof opts.pastCap === "number" && opts.pastCap > 0
|
|
469
|
+
? Math.floor(opts.pastCap)
|
|
470
|
+
: FLIGHT_LOG_PAST_CAP;
|
|
471
|
+
const nextFlightKey =
|
|
472
|
+
typeof opts.flightKey === "string" && opts.flightKey.trim() ? opts.flightKey.trim() : null;
|
|
473
|
+
const prev = parseFlightLogLedger(ledger);
|
|
474
|
+
const current = typeof liveGaps === "string" && liveGaps.trim() ? liveGaps.trim() : null;
|
|
475
|
+
const prevKey = prev.flightKey;
|
|
476
|
+
const flightKey = nextFlightKey ?? prevKey;
|
|
477
|
+
const boundary = Boolean(nextFlightKey) && prevKey !== nextFlightKey;
|
|
478
|
+
|
|
479
|
+
/** @type {{ text: string, at: string, sourcePath: string }[]} */
|
|
480
|
+
let past = boundary ? [] : [...prev.past];
|
|
481
|
+
const lastCurrent = boundary ? null : prev.lastCurrent;
|
|
482
|
+
if (!boundary && lastCurrent && lastCurrent !== current) {
|
|
483
|
+
const head = past[0];
|
|
484
|
+
if (!head || head.text !== lastCurrent) {
|
|
485
|
+
past = [{ text: lastCurrent, at: new Date(nowMs).toISOString(), sourcePath }, ...past].slice(
|
|
486
|
+
0,
|
|
487
|
+
pastCap,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const nextLedger = { version: 1, lastCurrent: current, past, flightKey };
|
|
492
|
+
const pastWithKind = past.map((entry) => ({
|
|
493
|
+
...entry,
|
|
494
|
+
kind: classifyFlightLogMessageKind(entry.text),
|
|
495
|
+
}));
|
|
496
|
+
return {
|
|
497
|
+
ledger: nextLedger,
|
|
498
|
+
flightLog: {
|
|
499
|
+
current,
|
|
500
|
+
currentKind: classifyFlightLogMessageKind(current),
|
|
501
|
+
past: pastWithKind,
|
|
502
|
+
sourcePath,
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Match HANDOFF Mode/Gaps/Instruction API/usage hard-stop (quota). */
|
|
508
|
+
const FLIGHT_LOG_API_LIMIT_RE =
|
|
509
|
+
/\bAPI\s*\/\s*usage\s+limit\b|\bAPI\s+usage\s+limit\b|\bSTOPPED:\s*API\b/i;
|
|
510
|
+
|
|
511
|
+
/** Match orchestrator heads-up prose in Gaps/Instruction (bounded). */
|
|
512
|
+
const FLIGHT_LOG_HEADS_UP_RE = /\bheads?\s*-?\s*up\b/i;
|
|
513
|
+
|
|
514
|
+
/** Cap operator Warnings on Flight Log (scannable lane). */
|
|
515
|
+
export const FLIGHT_LOG_WARNINGS_CAP = 5;
|
|
516
|
+
/** Cap untriaged external-review rows on Flight Log quiet-state surface. */
|
|
517
|
+
export const FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP = 5;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Operator-useful Warnings for Flight Log (read-only projection from HANDOFF).
|
|
521
|
+
* Includes API/usage hard-stop and orchestrator heads-up. Excludes cadence
|
|
522
|
+
* WARNING cards, Review/Resolve CTAs, and Field Report attention kinds.
|
|
523
|
+
* @param {object|null|undefined} handoff - parseHandoffMarkdown result
|
|
524
|
+
* @param {{ cap?: number }} [opts]
|
|
525
|
+
* @returns {{ id: string, kind: 'api_limit'|'orchestrator_heads_up', severity: 'warning', title: string, text: string, sourcePath: string }[]}
|
|
526
|
+
*/
|
|
527
|
+
export function buildFlightLogWarnings(handoff, opts = {}) {
|
|
528
|
+
const cap =
|
|
529
|
+
typeof opts.cap === "number" && opts.cap > 0 ? Math.floor(opts.cap) : FLIGHT_LOG_WARNINGS_CAP;
|
|
530
|
+
if (!handoff || typeof handoff !== "object") return [];
|
|
531
|
+
|
|
532
|
+
const mode = typeof handoff.mode === "string" ? handoff.mode : "";
|
|
533
|
+
const gaps = typeof handoff.gaps === "string" ? handoff.gaps : "";
|
|
534
|
+
const instruction = typeof handoff.instruction === "string" ? handoff.instruction : "";
|
|
535
|
+
const blob = `${mode}\n${gaps}\n${instruction}`;
|
|
536
|
+
const sourcePath = ".cursor/HANDOFF.md";
|
|
537
|
+
/** @type {{ id: string, kind: 'api_limit'|'orchestrator_heads_up', severity: 'warning', title: string, text: string, sourcePath: string }[]} */
|
|
538
|
+
const out = [];
|
|
539
|
+
|
|
540
|
+
if (FLIGHT_LOG_API_LIMIT_RE.test(blob)) {
|
|
541
|
+
const text =
|
|
542
|
+
(gaps && FLIGHT_LOG_API_LIMIT_RE.test(gaps) ? gaps : null) ||
|
|
543
|
+
(instruction && FLIGHT_LOG_API_LIMIT_RE.test(instruction) ? instruction : null) ||
|
|
544
|
+
(mode && FLIGHT_LOG_API_LIMIT_RE.test(mode) ? mode : null) ||
|
|
545
|
+
"Quota pause. Switch to a named model or wait for reset, then resume.";
|
|
546
|
+
out.push({
|
|
547
|
+
id: "flight-log-warning:api_limit",
|
|
548
|
+
kind: "api_limit",
|
|
549
|
+
severity: "warning",
|
|
550
|
+
title: "Quota pause",
|
|
551
|
+
text: truncateStr(text.trim(), MAX_SEMANTIC_LABEL),
|
|
552
|
+
sourcePath,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (FLIGHT_LOG_HEADS_UP_RE.test(blob) && out.length < cap) {
|
|
557
|
+
const text =
|
|
558
|
+
(gaps && FLIGHT_LOG_HEADS_UP_RE.test(gaps) ? gaps : null) ||
|
|
559
|
+
(instruction && FLIGHT_LOG_HEADS_UP_RE.test(instruction) ? instruction : null) ||
|
|
560
|
+
"Something needs a look before the next step.";
|
|
561
|
+
// Avoid duplicating the same body as the API/usage card.
|
|
562
|
+
if (!out.some((w) => w.text === truncateStr(text.trim(), MAX_SEMANTIC_LABEL))) {
|
|
563
|
+
out.push({
|
|
564
|
+
id: "flight-log-warning:orchestrator_heads_up",
|
|
565
|
+
kind: "orchestrator_heads_up",
|
|
566
|
+
severity: "warning",
|
|
567
|
+
title: "Heads up",
|
|
568
|
+
text: truncateStr(text.trim(), MAX_SEMANTIC_LABEL),
|
|
569
|
+
sourcePath,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return out.slice(0, cap);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Bounded untriaged external-review rows for Flight Log quiet state.
|
|
579
|
+
* Filters attention to `kind === "report"` only (no cadence, prompts, readiness,
|
|
580
|
+
* or bulk FR CTAs). Used when Gaps + Warnings are empty; callers must not mix
|
|
581
|
+
* these rows into a non-quiet Gaps/Warnings stack.
|
|
582
|
+
* @param {object[]|null|undefined} attention - buildAttentionItems output
|
|
583
|
+
* @param {{ limit?: number }} [opts]
|
|
584
|
+
* @returns {object[]}
|
|
585
|
+
*/
|
|
586
|
+
export function listFlightLogQuietOpenTriages(attention, opts = {}) {
|
|
587
|
+
const limit =
|
|
588
|
+
typeof opts.limit === "number" && opts.limit > 0
|
|
589
|
+
? Math.floor(opts.limit)
|
|
590
|
+
: FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP;
|
|
591
|
+
if (!Array.isArray(attention) || attention.length === 0) return [];
|
|
592
|
+
const out = [];
|
|
593
|
+
for (const item of attention) {
|
|
594
|
+
if (!item || item.kind !== "report") continue;
|
|
595
|
+
if (typeof item.sourcePath !== "string" || !item.sourcePath.trim()) continue;
|
|
596
|
+
out.push(item);
|
|
597
|
+
if (out.length >= limit) break;
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Relative path of the gitignored Field Report activity cadence ledger. */
|
|
603
|
+
export const FIELD_REPORT_CADENCE_LEDGER_REL = ".cursor/context/field-report-cadence.json";
|
|
604
|
+
|
|
605
|
+
/** Default cadence config when the key is missing. */
|
|
606
|
+
export const DEFAULT_FIELD_REPORT_REVIEW_CADENCE = Object.freeze({
|
|
607
|
+
enabled: true,
|
|
608
|
+
tickThreshold: 3,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
/** Empty v1 cadence ledger. */
|
|
612
|
+
export function emptyCadenceLedger() {
|
|
613
|
+
return {
|
|
614
|
+
version: 1,
|
|
615
|
+
ticksSinceClear: 0,
|
|
616
|
+
lastBatchCompleteAt: null,
|
|
617
|
+
activeWarningId: null,
|
|
618
|
+
windowId: null,
|
|
619
|
+
pendingPlanFiles: [],
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Parse `.cursor/context/field-report-cadence.json`. Missing or malformed
|
|
625
|
+
* input yields an empty ledger (never throws).
|
|
626
|
+
* @param {unknown} raw
|
|
627
|
+
*/
|
|
628
|
+
export function parseCadenceLedger(raw) {
|
|
629
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
630
|
+
return emptyCadenceLedger();
|
|
631
|
+
}
|
|
632
|
+
const ticksRaw = /** @type {{ ticksSinceClear?: unknown }} */ (raw).ticksSinceClear;
|
|
633
|
+
const ticksSinceClear =
|
|
634
|
+
typeof ticksRaw === "number" && Number.isInteger(ticksRaw) && ticksRaw >= 0
|
|
635
|
+
? Math.min(ticksRaw, 10_000)
|
|
636
|
+
: 0;
|
|
637
|
+
const lastBatchCompleteAt =
|
|
638
|
+
typeof raw.lastBatchCompleteAt === "string" && raw.lastBatchCompleteAt.trim()
|
|
639
|
+
? raw.lastBatchCompleteAt.trim()
|
|
640
|
+
: null;
|
|
641
|
+
const windowId =
|
|
642
|
+
typeof raw.windowId === "string" && /^[A-Za-z0-9._-]+$/.test(raw.windowId.trim())
|
|
643
|
+
? raw.windowId.trim()
|
|
644
|
+
: null;
|
|
645
|
+
let activeWarningId =
|
|
646
|
+
typeof raw.activeWarningId === "string" && raw.activeWarningId.trim()
|
|
647
|
+
? raw.activeWarningId.trim()
|
|
648
|
+
: null;
|
|
649
|
+
if (activeWarningId && !isFieldReportAttentionId(activeWarningId)) {
|
|
650
|
+
activeWarningId = null;
|
|
651
|
+
}
|
|
652
|
+
if (windowId && !activeWarningId) {
|
|
653
|
+
activeWarningId = `attention:cadence:${windowId}`;
|
|
654
|
+
}
|
|
655
|
+
if (activeWarningId && !windowId) {
|
|
656
|
+
const m = /^attention:cadence:([A-Za-z0-9._-]+)$/.exec(activeWarningId);
|
|
657
|
+
if (m) {
|
|
658
|
+
// keep activeWarningId; window derived below via pending only
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const pendingIn = Array.isArray(raw.pendingPlanFiles) ? raw.pendingPlanFiles : [];
|
|
662
|
+
const pendingPlanFiles = [];
|
|
663
|
+
const seen = new Set();
|
|
664
|
+
for (const entry of pendingIn) {
|
|
665
|
+
if (typeof entry !== "string") continue;
|
|
666
|
+
const base = entry.split("/").pop().trim();
|
|
667
|
+
if (!base || !/\.plan\.md$/i.test(base) || seen.has(base)) continue;
|
|
668
|
+
seen.add(base);
|
|
669
|
+
pendingPlanFiles.push(base);
|
|
670
|
+
}
|
|
671
|
+
const derivedWindow =
|
|
672
|
+
windowId ||
|
|
673
|
+
(activeWarningId
|
|
674
|
+
? (() => {
|
|
675
|
+
const m = /^attention:cadence:([A-Za-z0-9._-]+)$/.exec(activeWarningId);
|
|
676
|
+
return m ? m[1] : null;
|
|
677
|
+
})()
|
|
678
|
+
: null);
|
|
679
|
+
return {
|
|
680
|
+
version: 1,
|
|
681
|
+
ticksSinceClear,
|
|
682
|
+
lastBatchCompleteAt,
|
|
683
|
+
activeWarningId: derivedWindow ? `attention:cadence:${derivedWindow}` : null,
|
|
684
|
+
windowId: derivedWindow,
|
|
685
|
+
pendingPlanFiles,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Stable JSON for cadence ledger write-on-change.
|
|
691
|
+
* @param {ReturnType<typeof emptyCadenceLedger>} ledger
|
|
692
|
+
*/
|
|
693
|
+
export function serializeCadenceLedger(ledger) {
|
|
694
|
+
const parsed = parseCadenceLedger(ledger);
|
|
695
|
+
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Parse `fieldReportReviewCadence` from config. Missing → defaults.
|
|
700
|
+
* @param {unknown} rawConfig
|
|
701
|
+
*/
|
|
702
|
+
export function parseFieldReportReviewCadenceConfig(rawConfig) {
|
|
703
|
+
const defaults = { ...DEFAULT_FIELD_REPORT_REVIEW_CADENCE };
|
|
704
|
+
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
|
|
705
|
+
return defaults;
|
|
706
|
+
}
|
|
707
|
+
const block = /** @type {{ fieldReportReviewCadence?: unknown }} */ (rawConfig)
|
|
708
|
+
.fieldReportReviewCadence;
|
|
709
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
|
710
|
+
return defaults;
|
|
711
|
+
}
|
|
712
|
+
const enabled = typeof block.enabled === "boolean" ? block.enabled : defaults.enabled;
|
|
713
|
+
const thr = block.tickThreshold;
|
|
714
|
+
const tickThreshold =
|
|
715
|
+
typeof thr === "number" && Number.isInteger(thr) && thr >= 1 && thr <= 100
|
|
716
|
+
? thr
|
|
717
|
+
: defaults.tickThreshold;
|
|
718
|
+
return { enabled, tickThreshold };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Compact UTC window id: `w-YYYYMMDDHHmmss`.
|
|
723
|
+
* @param {Date|number|string} [now]
|
|
724
|
+
*/
|
|
725
|
+
export function cadenceWindowIdFromNow(now = Date.now()) {
|
|
726
|
+
const d = now instanceof Date ? now : new Date(now);
|
|
727
|
+
if (Number.isNaN(d.getTime())) {
|
|
728
|
+
return `w-${Date.now()}`;
|
|
729
|
+
}
|
|
730
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
731
|
+
return `w-${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* List still-unreviewed work for cadence emit and batch CTAs.
|
|
736
|
+
* Includes untriaged monitors and terminal plans without a matching monitor
|
|
737
|
+
* file (existence). Every report slug enters `monitorSlugs`; triage only gates
|
|
738
|
+
* first-loop inclusion. Does not emit owed attention rows.
|
|
739
|
+
* @returns {{ kind: 'report'|'owed', planFile: string, slug: string, path: string }[]}
|
|
740
|
+
*/
|
|
741
|
+
export function listUnreviewedReviewTargets(
|
|
742
|
+
plans,
|
|
743
|
+
handoff,
|
|
744
|
+
externalReports = [],
|
|
745
|
+
archivedPlanFiles = [],
|
|
746
|
+
) {
|
|
747
|
+
const targets = [];
|
|
748
|
+
const seenPlans = new Set();
|
|
749
|
+
const monitorSlugs = new Set();
|
|
750
|
+
|
|
751
|
+
for (const report of externalReports || []) {
|
|
752
|
+
if (!report || !report.file) continue;
|
|
753
|
+
const slug = normalizeSlug(report.slug) || normalizeSlug(report.file);
|
|
754
|
+
if (!slug) continue;
|
|
755
|
+
// Existence for owed exclusion: record every report slug before triage gate.
|
|
756
|
+
monitorSlugs.add(slug);
|
|
757
|
+
if (isReportTriaged(report, plans)) continue;
|
|
758
|
+
const planFile =
|
|
759
|
+
typeof report.reviewedPlanFile === "string" && report.reviewedPlanFile.trim()
|
|
760
|
+
? report.reviewedPlanFile.trim()
|
|
761
|
+
: `${slug}.plan.md`;
|
|
762
|
+
if (seenPlans.has(planFile)) continue;
|
|
763
|
+
seenPlans.add(planFile);
|
|
764
|
+
targets.push({
|
|
765
|
+
kind: "report",
|
|
766
|
+
planFile,
|
|
767
|
+
slug,
|
|
768
|
+
path: report.path || `.cursor/memory/plan-monitor-${slug}.md`,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
for (const plan of plans || []) {
|
|
773
|
+
const planFile = planFileKey(plan);
|
|
774
|
+
if (!planFile || seenPlans.has(planFile)) continue;
|
|
775
|
+
const lifecycle = resolvePlanLifecycle(planFile, plans, handoff, archivedPlanFiles);
|
|
776
|
+
if (!isPlanLifecycleTerminal(lifecycle)) continue;
|
|
777
|
+
const slug = planFile.replace(/\.plan\.md$/i, "");
|
|
778
|
+
if (!slug || monitorSlugs.has(normalizeSlug(slug))) continue;
|
|
779
|
+
seenPlans.add(planFile);
|
|
780
|
+
targets.push({
|
|
781
|
+
kind: "owed",
|
|
782
|
+
planFile,
|
|
783
|
+
slug,
|
|
784
|
+
path: `.cursor/plans/${planFile}`,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return targets;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Batch paste-only launcher covering all still-unreviewed plan basenames.
|
|
793
|
+
* @param {{ planFile: string }[]} targets
|
|
794
|
+
*/
|
|
795
|
+
export function buildBatchExternalReviewPasteCommand(targets) {
|
|
796
|
+
const files = [];
|
|
797
|
+
const seen = new Set();
|
|
798
|
+
for (const t of targets || []) {
|
|
799
|
+
const base = String(t?.planFile || "")
|
|
800
|
+
.split("/")
|
|
801
|
+
.pop()
|
|
802
|
+
.trim();
|
|
803
|
+
if (!base || !/\.plan\.md$/i.test(base) || seen.has(base)) continue;
|
|
804
|
+
seen.add(base);
|
|
805
|
+
files.push(base);
|
|
806
|
+
}
|
|
807
|
+
if (files.length === 0) return null;
|
|
808
|
+
return `.cursor/scripts/plan-external-review.sh --force --paste-only --batch ${files.join(" ")}`;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Per-plan interactive paste command (terminal).
|
|
813
|
+
* @param {string} planFile
|
|
814
|
+
*/
|
|
815
|
+
export function buildPerPlanExternalReviewPasteCommand(planFile) {
|
|
816
|
+
const base = String(planFile || "")
|
|
817
|
+
.split("/")
|
|
818
|
+
.pop()
|
|
819
|
+
.trim();
|
|
820
|
+
if (!base || !/\.plan\.md$/i.test(base)) return null;
|
|
821
|
+
return `.cursor/scripts/plan-external-review.sh --force --interactive ${base}`;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Apply a completed `/run-plan` tick to the cadence ledger.
|
|
826
|
+
* @param {ReturnType<typeof emptyCadenceLedger>} ledger
|
|
827
|
+
* @param {{ nowIso?: string, unreviewedTargets?: { planFile: string }[], tickThreshold?: number, enabled?: boolean }} opts
|
|
828
|
+
*/
|
|
829
|
+
export function recordCadenceTickClose(ledger, opts = {}) {
|
|
830
|
+
const next = parseCadenceLedger(ledger);
|
|
831
|
+
if (opts.enabled === false) return next;
|
|
832
|
+
const threshold =
|
|
833
|
+
typeof opts.tickThreshold === "number" && opts.tickThreshold >= 1
|
|
834
|
+
? opts.tickThreshold
|
|
835
|
+
: DEFAULT_FIELD_REPORT_REVIEW_CADENCE.tickThreshold;
|
|
836
|
+
next.ticksSinceClear += 1;
|
|
837
|
+
const targets = Array.isArray(opts.unreviewedTargets) ? opts.unreviewedTargets : [];
|
|
838
|
+
if (targets.length === 0) return next;
|
|
839
|
+
if (next.ticksSinceClear < threshold && next.activeWarningId) return next;
|
|
840
|
+
if (next.ticksSinceClear < threshold) return next;
|
|
841
|
+
const nowIso = opts.nowIso || new Date().toISOString();
|
|
842
|
+
const windowId = next.windowId || cadenceWindowIdFromNow(nowIso);
|
|
843
|
+
next.windowId = windowId;
|
|
844
|
+
next.activeWarningId = `attention:cadence:${windowId}`;
|
|
845
|
+
next.pendingPlanFiles = targets
|
|
846
|
+
.map((t) =>
|
|
847
|
+
String(t?.planFile || "")
|
|
848
|
+
.split("/")
|
|
849
|
+
.pop()
|
|
850
|
+
.trim(),
|
|
851
|
+
)
|
|
852
|
+
.filter((f) => f && /\.plan\.md$/i.test(f));
|
|
853
|
+
return next;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Mark `/run-plan-all` queue complete on the cadence ledger.
|
|
858
|
+
* @param {ReturnType<typeof emptyCadenceLedger>} ledger
|
|
859
|
+
* @param {{ nowIso?: string, unreviewedTargets?: { planFile: string }[], enabled?: boolean }} opts
|
|
860
|
+
*/
|
|
861
|
+
export function recordCadenceBatchComplete(ledger, opts = {}) {
|
|
862
|
+
const next = parseCadenceLedger(ledger);
|
|
863
|
+
if (opts.enabled === false) return next;
|
|
864
|
+
const nowIso = opts.nowIso || new Date().toISOString();
|
|
865
|
+
next.lastBatchCompleteAt = nowIso;
|
|
866
|
+
const targets = Array.isArray(opts.unreviewedTargets) ? opts.unreviewedTargets : [];
|
|
867
|
+
if (targets.length === 0) return next;
|
|
868
|
+
const windowId = cadenceWindowIdFromNow(nowIso);
|
|
869
|
+
next.windowId = windowId;
|
|
870
|
+
next.activeWarningId = `attention:cadence:${windowId}`;
|
|
871
|
+
next.pendingPlanFiles = targets
|
|
872
|
+
.map((t) =>
|
|
873
|
+
String(t?.planFile || "")
|
|
874
|
+
.split("/")
|
|
875
|
+
.pop()
|
|
876
|
+
.trim(),
|
|
877
|
+
)
|
|
878
|
+
.filter((f) => f && /\.plan\.md$/i.test(f));
|
|
879
|
+
return next;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Clear the active cadence window after subject-resolved or operator hide.
|
|
884
|
+
* @param {ReturnType<typeof emptyCadenceLedger>} ledger
|
|
885
|
+
*/
|
|
886
|
+
export function clearCadenceWarning(ledger) {
|
|
887
|
+
const next = parseCadenceLedger(ledger);
|
|
888
|
+
next.ticksSinceClear = 0;
|
|
889
|
+
next.activeWarningId = null;
|
|
890
|
+
next.windowId = null;
|
|
891
|
+
next.pendingPlanFiles = [];
|
|
892
|
+
return next;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Shape one cadence warning attention item, or null when not eligible.
|
|
897
|
+
* @param {ReturnType<typeof emptyCadenceLedger>} ledger
|
|
898
|
+
* @param {{ planFile: string, slug?: string, path?: string, kind?: string }[]} targets
|
|
899
|
+
* @param {{ enabled?: boolean, dismissed?: Set<string> }} [opts]
|
|
900
|
+
*/
|
|
901
|
+
export function buildCadenceAttentionItem(ledger, targets, opts = {}) {
|
|
902
|
+
if (opts.enabled === false) return null;
|
|
903
|
+
const parsed = parseCadenceLedger(ledger);
|
|
904
|
+
if (!parsed.activeWarningId || !parsed.windowId) return null;
|
|
905
|
+
if (opts.dismissed?.has(parsed.activeWarningId)) return null;
|
|
906
|
+
const liveTargets = Array.isArray(targets) ? targets : [];
|
|
907
|
+
if (liveTargets.length === 0) return null;
|
|
908
|
+
|
|
909
|
+
const batchTarget = buildBatchExternalReviewPasteCommand(liveTargets);
|
|
910
|
+
if (!batchTarget) return null;
|
|
911
|
+
|
|
912
|
+
const perPlanActions = [];
|
|
913
|
+
for (const t of liveTargets.slice(0, 8)) {
|
|
914
|
+
const cmd = buildPerPlanExternalReviewPasteCommand(t.planFile);
|
|
915
|
+
if (!cmd) continue;
|
|
916
|
+
perPlanActions.push({
|
|
917
|
+
type: "copy",
|
|
918
|
+
target: cmd,
|
|
919
|
+
label: `Copy review: ${String(t.planFile).replace(/\.plan\.md$/i, "")}`,
|
|
920
|
+
subject: `external review ${t.planFile}`,
|
|
921
|
+
pasteDestination: "terminal",
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const count = liveTargets.length;
|
|
926
|
+
return withResolveAction({
|
|
927
|
+
id: parsed.activeWarningId,
|
|
928
|
+
kind: "cadence",
|
|
929
|
+
group: "cadence",
|
|
930
|
+
severity: "warning",
|
|
931
|
+
label: truncateStr(
|
|
932
|
+
`Review cadence: ${count} unreviewed plan${count === 1 ? "" : "s"} after recent run activity`,
|
|
933
|
+
MAX_SEMANTIC_LABEL,
|
|
934
|
+
),
|
|
935
|
+
sourcePath: FIELD_REPORT_CADENCE_LEDGER_REL,
|
|
936
|
+
modifiedAt: parsed.lastBatchCompleteAt || null,
|
|
937
|
+
progress: null,
|
|
938
|
+
pendingPlanFiles: parsed.pendingPlanFiles.slice(),
|
|
939
|
+
action: {
|
|
940
|
+
type: "copy",
|
|
941
|
+
target: batchTarget,
|
|
942
|
+
label: "Copy batch external review",
|
|
943
|
+
subject: "batch external review",
|
|
944
|
+
pasteDestination: "terminal",
|
|
945
|
+
},
|
|
946
|
+
secondaryActions: perPlanActions,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function cloneMissionTimingLedger(ledger) {
|
|
951
|
+
return parseMissionTimingLedger(
|
|
952
|
+
JSON.parse(serializeMissionTimingLedger(ledger || emptyMissionTimingLedger())),
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Attach timing fields to a Current mission (`now`) slice. Idle / missing
|
|
958
|
+
* timing → nulls so the UI can omit chrome.
|
|
959
|
+
* @param {object} now
|
|
960
|
+
* @param {object | null} timing
|
|
961
|
+
*/
|
|
962
|
+
export function withMissionTiming(now, timing) {
|
|
963
|
+
const base = now && typeof now === "object" ? now : {};
|
|
964
|
+
if (!timing || typeof timing !== "object") {
|
|
965
|
+
return {
|
|
966
|
+
...base,
|
|
967
|
+
totalElapsedMs: null,
|
|
968
|
+
currentStageElapsedMs: null,
|
|
969
|
+
currentStageId: null,
|
|
970
|
+
stages: null,
|
|
971
|
+
timingStartedAt: null,
|
|
972
|
+
timingFrozenAt: null,
|
|
973
|
+
currentStageStartedAt: null,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
...base,
|
|
978
|
+
totalElapsedMs:
|
|
979
|
+
typeof timing.totalElapsedMs === "number" && Number.isFinite(timing.totalElapsedMs)
|
|
980
|
+
? Math.max(0, Math.floor(timing.totalElapsedMs))
|
|
981
|
+
: null,
|
|
982
|
+
currentStageElapsedMs:
|
|
983
|
+
typeof timing.currentStageElapsedMs === "number" &&
|
|
984
|
+
Number.isFinite(timing.currentStageElapsedMs)
|
|
985
|
+
? Math.max(0, Math.floor(timing.currentStageElapsedMs))
|
|
986
|
+
: null,
|
|
987
|
+
currentStageId:
|
|
988
|
+
typeof timing.currentStageId === "string" && timing.currentStageId.trim()
|
|
989
|
+
? timing.currentStageId.trim()
|
|
990
|
+
: null,
|
|
991
|
+
stages: Array.isArray(timing.stages) ? timing.stages : null,
|
|
992
|
+
timingStartedAt:
|
|
993
|
+
typeof timing.startedAt === "string" && timing.startedAt.trim()
|
|
994
|
+
? timing.startedAt.trim()
|
|
995
|
+
: null,
|
|
996
|
+
timingFrozenAt:
|
|
997
|
+
typeof timing.frozenAt === "string" && timing.frozenAt.trim() ? timing.frozenAt.trim() : null,
|
|
998
|
+
currentStageStartedAt:
|
|
999
|
+
typeof timing.currentStageStartedAt === "string" && timing.currentStageStartedAt.trim()
|
|
1000
|
+
? timing.currentStageStartedAt.trim()
|
|
1001
|
+
: null,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Observe plan/todo transitions into a local timing ledger and emit elapsed
|
|
1007
|
+
* fields for Current mission. Idle omits timing. Completed freezes totals.
|
|
1008
|
+
* Honesty: durations are approximate when the dashboard missed transitions.
|
|
1009
|
+
*
|
|
1010
|
+
* @param {object} ledger - parsed ledger
|
|
1011
|
+
* @param {object} now - from buildCurrentExecution
|
|
1012
|
+
* @param {object} [opts]
|
|
1013
|
+
* @param {number} [opts.nowMs]
|
|
1014
|
+
* @param {{ id: string, status?: string }[]} [opts.todoItems]
|
|
1015
|
+
* @returns {{ ledger: object, timing: object | null }}
|
|
1016
|
+
*/
|
|
1017
|
+
export function observeMissionTiming(ledger, now, { nowMs = Date.now(), todoItems = [] } = {}) {
|
|
1018
|
+
const next = cloneMissionTimingLedger(ledger);
|
|
1019
|
+
if (!now || now.status === "idle" || !now.planFile) {
|
|
1020
|
+
return { ledger: next, timing: null };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const planFile = String(now.planFile);
|
|
1024
|
+
const iso = new Date(nowMs).toISOString();
|
|
1025
|
+
let mission = next.missions[planFile];
|
|
1026
|
+
if (!mission) {
|
|
1027
|
+
mission = { startedAt: iso, frozenAt: null, stages: {} };
|
|
1028
|
+
next.missions[planFile] = mission;
|
|
1029
|
+
} else if (!mission.startedAt) {
|
|
1030
|
+
mission.startedAt = iso;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const currentId =
|
|
1034
|
+
now.currentTodo?.id && typeof now.currentTodo.id === "string" ? now.currentTodo.id : null;
|
|
1035
|
+
|
|
1036
|
+
// Close stages that are no longer current.
|
|
1037
|
+
for (const [id, stage] of Object.entries(mission.stages)) {
|
|
1038
|
+
if (!stage.endedAt && id !== currentId) {
|
|
1039
|
+
stage.endedAt = iso;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (currentId) {
|
|
1044
|
+
if (!mission.stages[currentId]) {
|
|
1045
|
+
mission.stages[currentId] = { startedAt: iso, endedAt: null };
|
|
1046
|
+
} else if (mission.stages[currentId].endedAt && now.status !== "completed") {
|
|
1047
|
+
// Re-opened current step: clear end so live elapsed continues from original start.
|
|
1048
|
+
mission.stages[currentId].endedAt = null;
|
|
1049
|
+
}
|
|
1050
|
+
} else if (now.status === "completed" && now.previousTodo?.id) {
|
|
1051
|
+
// Seed terminal step if we never observed it live.
|
|
1052
|
+
const lastId = String(now.previousTodo.id);
|
|
1053
|
+
if (!mission.stages[lastId]) {
|
|
1054
|
+
mission.stages[lastId] = { startedAt: mission.startedAt, endedAt: null };
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (now.status === "completed") {
|
|
1059
|
+
if (!mission.frozenAt) mission.frozenAt = iso;
|
|
1060
|
+
for (const stage of Object.values(mission.stages)) {
|
|
1061
|
+
if (!stage.endedAt) stage.endedAt = mission.frozenAt;
|
|
1062
|
+
}
|
|
1063
|
+
} else {
|
|
1064
|
+
mission.frozenAt = null;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const startMs = Date.parse(mission.startedAt);
|
|
1068
|
+
const endMs = mission.frozenAt ? Date.parse(mission.frozenAt) : nowMs;
|
|
1069
|
+
const totalElapsedMs =
|
|
1070
|
+
Number.isFinite(startMs) && Number.isFinite(endMs) ? Math.max(0, endMs - startMs) : 0;
|
|
1071
|
+
|
|
1072
|
+
let currentStageId = currentId;
|
|
1073
|
+
if (!currentStageId && now.status === "completed" && now.previousTodo?.id) {
|
|
1074
|
+
currentStageId = String(now.previousTodo.id);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
let currentStageElapsedMs = null;
|
|
1078
|
+
let currentStageStartedAt = null;
|
|
1079
|
+
if (currentStageId && mission.stages[currentStageId]) {
|
|
1080
|
+
const st = mission.stages[currentStageId];
|
|
1081
|
+
currentStageStartedAt = st.startedAt;
|
|
1082
|
+
const stageStart = Date.parse(st.startedAt);
|
|
1083
|
+
const stageEnd = st.endedAt ? Date.parse(st.endedAt) : endMs;
|
|
1084
|
+
if (Number.isFinite(stageStart) && Number.isFinite(stageEnd)) {
|
|
1085
|
+
currentStageElapsedMs = Math.max(0, stageEnd - stageStart);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const items = Array.isArray(todoItems) ? todoItems : [];
|
|
1090
|
+
const stages = [];
|
|
1091
|
+
for (const item of items) {
|
|
1092
|
+
if (!item?.id || typeof item.id !== "string") continue;
|
|
1093
|
+
const st = mission.stages[item.id];
|
|
1094
|
+
if (!st) continue;
|
|
1095
|
+
const stageStart = Date.parse(st.startedAt);
|
|
1096
|
+
const stageEnd = st.endedAt
|
|
1097
|
+
? Date.parse(st.endedAt)
|
|
1098
|
+
: mission.frozenAt
|
|
1099
|
+
? Date.parse(mission.frozenAt)
|
|
1100
|
+
: nowMs;
|
|
1101
|
+
if (!Number.isFinite(stageStart) || !Number.isFinite(stageEnd)) continue;
|
|
1102
|
+
stages.push({
|
|
1103
|
+
id: item.id,
|
|
1104
|
+
elapsedMs: Math.max(0, Math.floor(stageEnd - stageStart)),
|
|
1105
|
+
status: item.status || null,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return {
|
|
1110
|
+
ledger: next,
|
|
1111
|
+
timing: {
|
|
1112
|
+
totalElapsedMs: Math.floor(totalElapsedMs),
|
|
1113
|
+
currentStageElapsedMs:
|
|
1114
|
+
currentStageElapsedMs == null ? null : Math.floor(currentStageElapsedMs),
|
|
1115
|
+
currentStageId,
|
|
1116
|
+
stages,
|
|
1117
|
+
startedAt: mission.startedAt,
|
|
1118
|
+
frozenAt: mission.frozenAt,
|
|
1119
|
+
currentStageStartedAt,
|
|
1120
|
+
},
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Attach the copy-only resolve CTA when the id is dismissable. */
|
|
1125
|
+
function withResolveAction(item) {
|
|
1126
|
+
if (!item?.id) return item;
|
|
1127
|
+
const resolveAction = fieldReportResolveAction(item.id);
|
|
1128
|
+
if (!resolveAction) return item;
|
|
1129
|
+
return { ...item, resolveAction };
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Names an agent question tool_use inside a transcript entry. Confirmed from
|
|
1134
|
+
* real local transcripts: the call is `AskQuestion`. The `ask_question` and
|
|
1135
|
+
* `cursor/ask_question` spellings are accepted so the same rule survives the
|
|
1136
|
+
* ACP and snake_case surfaces documented for the tool.
|
|
1137
|
+
*/
|
|
1138
|
+
export const AGENT_QUESTION_TOOL_RE = /^(ask[_-]?question|cursor\/ask_question)$/i;
|
|
1139
|
+
|
|
1140
|
+
const AWAITING_MODE_RE =
|
|
1141
|
+
/\b(awaiting|waiting|gate\s*[ab]|gate\s+b|start-project\s+gate|user\s+approval|hitl)\b/i;
|
|
1142
|
+
const EXECUTING_MODE_RE = /\b(run-plan|in_progress|orchestrated|in-session|tick)\b/i;
|
|
1143
|
+
/** HANDOFF mode signals that the run stopped / plan is exhausted (not live). */
|
|
1144
|
+
const STOPPED_EXHAUSTED_MODE_RE = /\b(STOPPED|exhausted|plan exhausted)\b/i;
|
|
1145
|
+
const MERGE_PR_RE = /^([0-9a-f]{7,40})\s+Merge pull request #(\d+)\b(.*)$/i;
|
|
1146
|
+
const STAGING_COMMIT_RE = /\b(git staging|\/git-staging|merge.*staging|to staging)\b/i;
|
|
1147
|
+
/** Conventional branch type prefix (one segment) used when mapping merge branches to plan basenames. */
|
|
1148
|
+
const BRANCH_TYPE_PREFIX_RE =
|
|
1149
|
+
/^(?:feat|fix|docs|test|chore|refactor|update|perf|style|ci|build|revert)\//;
|
|
1150
|
+
const MERGE_FROM_BRANCH_RE = /\bfrom\s+(\S+)/i;
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Kit agent identities (`.cursor/agents/<id>.md` basenames). Plan `agent:` must
|
|
1154
|
+
* match one of these; plan slugs and Task worker_types are not agent ids.
|
|
1155
|
+
*/
|
|
1156
|
+
export const KIT_AGENT_IDS = Object.freeze([
|
|
1157
|
+
"cleancode-refactor",
|
|
1158
|
+
"clickup-tasks",
|
|
1159
|
+
"context-librarian",
|
|
1160
|
+
"docs-repo",
|
|
1161
|
+
"git-autogit",
|
|
1162
|
+
"json-guardian",
|
|
1163
|
+
"memory-extractor",
|
|
1164
|
+
"n8n-workflows",
|
|
1165
|
+
"prompts-agents",
|
|
1166
|
+
"security-reviewer",
|
|
1167
|
+
"sql-schema",
|
|
1168
|
+
"tech-lead",
|
|
1169
|
+
"test-suites",
|
|
1170
|
+
]);
|
|
1171
|
+
|
|
1172
|
+
const KIT_AGENT_ID_SET = new Set(KIT_AGENT_IDS);
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Normalize a plan `agent:` value to a kit agent id, or null when absent/invalid.
|
|
1176
|
+
* @param {unknown} raw
|
|
1177
|
+
* @returns {string|null}
|
|
1178
|
+
*/
|
|
1179
|
+
export function normalizeKitAgentId(raw) {
|
|
1180
|
+
if (typeof raw !== "string") return null;
|
|
1181
|
+
const id = raw.trim();
|
|
1182
|
+
return KIT_AGENT_ID_SET.has(id) ? id : null;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Extract the feature branch from a merge-PR trailing segment (` from org/branch`).
|
|
1187
|
+
* Drops the remote/org segment before the first `/`.
|
|
1188
|
+
* @param {string} trailing
|
|
1189
|
+
* @returns {string|null}
|
|
1190
|
+
*/
|
|
1191
|
+
export function extractMergeBranch(trailing) {
|
|
1192
|
+
const m = String(trailing || "").match(MERGE_FROM_BRANCH_RE);
|
|
1193
|
+
if (!m) return null;
|
|
1194
|
+
const remoteBranch = m[1];
|
|
1195
|
+
const slash = remoteBranch.indexOf("/");
|
|
1196
|
+
if (slash < 0) return remoteBranch || null;
|
|
1197
|
+
const branch = remoteBranch.slice(slash + 1);
|
|
1198
|
+
return branch || null;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Resolve a delivery plan (and its agent) from a merge branch via exact basename match.
|
|
1203
|
+
* Never falls back to the active plan. Ambiguity or no match → nulls.
|
|
1204
|
+
* @param {string|null|undefined} branch
|
|
1205
|
+
* @param {Array<{ file?: string, agent?: string|null }>} [plans]
|
|
1206
|
+
* @returns {{ plan: string|null, agent: string|null }}
|
|
1207
|
+
*/
|
|
1208
|
+
export function resolveDeliveryAttribution(branch, plans = []) {
|
|
1209
|
+
if (!branch) return { plan: null, agent: null };
|
|
1210
|
+
|
|
1211
|
+
const candidates = new Set();
|
|
1212
|
+
if (BRANCH_TYPE_PREFIX_RE.test(branch)) {
|
|
1213
|
+
candidates.add(branch.replace(BRANCH_TYPE_PREFIX_RE, ""));
|
|
1214
|
+
}
|
|
1215
|
+
candidates.add(branch.replace(/\//g, "-"));
|
|
1216
|
+
|
|
1217
|
+
/** @type {Map<string, { file: string, agent: string|null }>} */
|
|
1218
|
+
const byBasename = new Map();
|
|
1219
|
+
for (const p of plans || []) {
|
|
1220
|
+
const file = p?.file;
|
|
1221
|
+
if (!file || typeof file !== "string") continue;
|
|
1222
|
+
const base = file.replace(/\.plan\.md$/i, "");
|
|
1223
|
+
if (!base || byBasename.has(base)) continue;
|
|
1224
|
+
byBasename.set(base, { file, agent: p.agent || null });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** @type {Map<string, { file: string, agent: string|null }>} */
|
|
1228
|
+
const matched = new Map();
|
|
1229
|
+
for (const c of candidates) {
|
|
1230
|
+
const hit = byBasename.get(c);
|
|
1231
|
+
if (hit) matched.set(hit.file, hit);
|
|
1232
|
+
}
|
|
1233
|
+
if (matched.size !== 1) return { plan: null, agent: null };
|
|
1234
|
+
const only = matched.values().next().value;
|
|
1235
|
+
return { plan: only.file, agent: only.agent };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Parse Agent Kit HANDOFF.md into structured fields used by Mission Control.
|
|
1240
|
+
* @param {string} content
|
|
1241
|
+
*/
|
|
1242
|
+
export function parseHandoffMarkdown(content) {
|
|
1243
|
+
if (!content || typeof content !== "string") {
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const handoff = {};
|
|
1248
|
+
|
|
1249
|
+
// Prefer backticked Plan refs; also accept plain `*.plan.md` (agents often omit
|
|
1250
|
+
// backticks). Reject none/n/a and non-plan prose (false-negative policy).
|
|
1251
|
+
const planRaw = extractHandoffPlanRef(content);
|
|
1252
|
+
if (planRaw) {
|
|
1253
|
+
handoff.plan = planRaw;
|
|
1254
|
+
handoff.planPath = planRaw.startsWith(".cursor/")
|
|
1255
|
+
? planRaw
|
|
1256
|
+
: `.cursor/plans/${planRaw.replace(/^plans\//, "")}`;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const lastUpdated = content.match(/^- \*\*Last updated:\*\*\s*(.+)$/m);
|
|
1260
|
+
if (lastUpdated) handoff.lastUpdated = lastUpdated[1].trim();
|
|
1261
|
+
|
|
1262
|
+
const modeMatch = content.match(/^- \*\*Mode:\*\*\s*(.+)$/m);
|
|
1263
|
+
if (modeMatch) handoff.mode = truncateStr(modeMatch[1].trim(), MAX_SEMANTIC_LABEL);
|
|
1264
|
+
|
|
1265
|
+
const phaseMatch = content.match(/^- \*\*Phase completed:\*\*\s*(.+)$/m);
|
|
1266
|
+
if (phaseMatch) handoff.phaseCompleted = phaseMatch[1].trim();
|
|
1267
|
+
|
|
1268
|
+
const nextPhaseMatch = content.match(/^- \*\*Next phase:\*\*\s*(.+)$/m);
|
|
1269
|
+
if (nextPhaseMatch) handoff.nextPhase = nextPhaseMatch[1].trim();
|
|
1270
|
+
|
|
1271
|
+
const completedMatch = content.match(/^- \*\*Completed to-dos:\*\*\s*(.+)$/m);
|
|
1272
|
+
if (completedMatch) handoff.completedTodos = completedMatch[1].trim();
|
|
1273
|
+
|
|
1274
|
+
const nextTodosMatch = content.match(/^- \*\*Next to-dos:\*\*\s*(.+)$/m);
|
|
1275
|
+
if (nextTodosMatch) handoff.nextTodos = nextTodosMatch[1].trim();
|
|
1276
|
+
|
|
1277
|
+
const parkedRaw = extractHandoffFieldBlock(content, "Parked plans");
|
|
1278
|
+
if (parkedRaw) {
|
|
1279
|
+
handoff.parkedPlansRaw = parkedRaw;
|
|
1280
|
+
handoff.parkedPlans = parseParkedPlans(parkedRaw);
|
|
1281
|
+
} else {
|
|
1282
|
+
handoff.parkedPlans = [];
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
let backlogRaw = extractHandoffFieldBlock(content, "Backlog plans");
|
|
1286
|
+
if (!backlogRaw) backlogRaw = extractHandoffFieldBlock(content, "Backlog");
|
|
1287
|
+
if (backlogRaw) {
|
|
1288
|
+
handoff.backlogPlansRaw = backlogRaw;
|
|
1289
|
+
handoff.backlogPlans = parseParkedPlans(backlogRaw);
|
|
1290
|
+
} else {
|
|
1291
|
+
handoff.backlogPlans = [];
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// /run-plan-all queue slice (see .cursor/context/templates/handoff.md and
|
|
1295
|
+
// 2026-07-26_cockpit-run-plan-all-queue-awareness.md). Presence-based: the
|
|
1296
|
+
// parser records what the HANDOFF says; Mode gating happens in the semantic
|
|
1297
|
+
// layer. Same false-negative policy as parked/backlog plan refs.
|
|
1298
|
+
const runQueueRaw = extractHandoffFieldBlock(content, "Run queue");
|
|
1299
|
+
if (runQueueRaw) {
|
|
1300
|
+
handoff.runQueueRaw = runQueueRaw;
|
|
1301
|
+
handoff.runQueue = parseRunQueue(runQueueRaw);
|
|
1302
|
+
} else {
|
|
1303
|
+
handoff.runQueue = [];
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const queueCursorMatch = content.match(/^- \*\*Queue cursor:\*\*\s*(.+)$/m);
|
|
1307
|
+
if (queueCursorMatch) {
|
|
1308
|
+
const cursor = parseQueueCursor(queueCursorMatch[1]);
|
|
1309
|
+
handoff.queueCursor = cursor.index;
|
|
1310
|
+
handoff.queueCursorPlan = cursor.plan;
|
|
1311
|
+
} else {
|
|
1312
|
+
handoff.queueCursor = null;
|
|
1313
|
+
handoff.queueCursorPlan = null;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const queueStatusMatch = content.match(/^- \*\*Queue status:\*\*\s*(.+)$/m);
|
|
1317
|
+
if (queueStatusMatch) {
|
|
1318
|
+
handoff.queueStatus = truncateStr(queueStatusMatch[1].trim(), MAX_SEMANTIC_LABEL);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const queueOutcomesRaw = extractHandoffFieldBlock(content, "Queue outcomes");
|
|
1322
|
+
if (queueOutcomesRaw) {
|
|
1323
|
+
handoff.queueOutcomesRaw = queueOutcomesRaw;
|
|
1324
|
+
handoff.queueOutcomes = parseQueueOutcomes(queueOutcomesRaw);
|
|
1325
|
+
} else {
|
|
1326
|
+
handoff.queueOutcomes = {};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Gaps: same-line or nested block (`- **Gaps:**` machine field). Empty /
|
|
1330
|
+
// none / n/a normalize to null so the cockpit can hide the surface.
|
|
1331
|
+
const gapsRaw = extractHandoffFieldBlock(content, "Gaps");
|
|
1332
|
+
if (gapsRaw) {
|
|
1333
|
+
const gaps = normalizeHandoffGaps(gapsRaw);
|
|
1334
|
+
if (gaps) handoff.gaps = gaps;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const instructionMatch = content.match(/^- \*\*Instruction for the next agent:\*\*\s*(.+)$/m);
|
|
1338
|
+
if (instructionMatch) {
|
|
1339
|
+
handoff.instruction = truncateStr(instructionMatch[1].trim(), MAX_SEMANTIC_LABEL);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
return Object.keys(handoff).length > 0 ? handoff : null;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Normalize HANDOFF Gaps text for Mission Control. Treats empty, `none`,
|
|
1347
|
+
* `n/a`, `none.` / `none:`-prefixed OK notes, and empty-residual placeholders
|
|
1348
|
+
* as absent so OK status does not surface as a yellow Live Gaps debit.
|
|
1349
|
+
* @param {string} raw
|
|
1350
|
+
* @returns {string|null}
|
|
1351
|
+
*/
|
|
1352
|
+
export function normalizeHandoffGaps(raw) {
|
|
1353
|
+
if (!raw || typeof raw !== "string") return null;
|
|
1354
|
+
const text = raw.replace(/\s+/g, " ").trim();
|
|
1355
|
+
if (!text) return null;
|
|
1356
|
+
if (/^(none|n\/a)$/i.test(text)) return null;
|
|
1357
|
+
// OK + pointer anti-pattern: "none. Residuals…", "None: …", "N/A - …", "none (…)"
|
|
1358
|
+
if (/^(none|n\/a)\s*[.:,;\/(\-–—…]/i.test(text)) return null;
|
|
1359
|
+
// Empty residual placeholders
|
|
1360
|
+
if (/^([-–—.…]|empty|no gaps?|cleared|all clear|ok)$/i.test(text)) return null;
|
|
1361
|
+
return truncateStr(text, MAX_SEMANTIC_LABEL);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Flight Log typed notification kinds (ADR 2026-07-27_mc-flight-log-panel).
|
|
1366
|
+
* Distinct from Crew Monitor step kinds; shared palette tokens only.
|
|
1367
|
+
* @typedef {'ok'|'advice'|'prompt'|'warning'|'residual'} FlightLogMessageKind
|
|
1368
|
+
*/
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Classify a Flight Log Gaps/Warning body for palette chrome.
|
|
1372
|
+
* @param {string | null | undefined} text
|
|
1373
|
+
* @param {{ lane?: 'gaps' | 'warning' }} [opts]
|
|
1374
|
+
* @returns {FlightLogMessageKind}
|
|
1375
|
+
*/
|
|
1376
|
+
export function classifyFlightLogMessageKind(text, opts = {}) {
|
|
1377
|
+
if (opts.lane === "warning") return "warning";
|
|
1378
|
+
const normalized = typeof text === "string" ? normalizeHandoffGaps(text) : null;
|
|
1379
|
+
if (normalized == null) return "ok";
|
|
1380
|
+
if (
|
|
1381
|
+
/\bAPI\s*\/\s*usage\s+limit\b|\bAPI\s+usage\s+limit\b|\bSTOPPED:\s*API\b/i.test(normalized) ||
|
|
1382
|
+
/\b(hard.?stop|quota\s+pause)\b/i.test(normalized)
|
|
1383
|
+
) {
|
|
1384
|
+
return "warning";
|
|
1385
|
+
}
|
|
1386
|
+
if (
|
|
1387
|
+
/\b(confirm|ask questions|hitl|\bpaste\b|choose\b|approve\b|operator yes)\b/i.test(normalized)
|
|
1388
|
+
) {
|
|
1389
|
+
return "prompt";
|
|
1390
|
+
}
|
|
1391
|
+
if (/\b(tip:|advice:|consider\b|recommends?\b|recommended\b|prefer\b)/i.test(normalized)) {
|
|
1392
|
+
return "advice";
|
|
1393
|
+
}
|
|
1394
|
+
return "residual";
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* CSS modifier class for Flight Log kind chrome.
|
|
1399
|
+
* @param {FlightLogMessageKind | string | null | undefined} kind
|
|
1400
|
+
* @returns {string}
|
|
1401
|
+
*/
|
|
1402
|
+
export function flightLogKindClass(kind) {
|
|
1403
|
+
switch (kind) {
|
|
1404
|
+
case "ok":
|
|
1405
|
+
return "flight-log-kind-ok";
|
|
1406
|
+
case "advice":
|
|
1407
|
+
case "prompt":
|
|
1408
|
+
return "flight-log-kind-advice";
|
|
1409
|
+
case "warning":
|
|
1410
|
+
return "flight-log-kind-warning";
|
|
1411
|
+
default:
|
|
1412
|
+
return "flight-log-kind-residual";
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Parse the ordered `Run queue` block into `*.plan.md` basenames.
|
|
1418
|
+
* Accepts the template's bracketed comma list (`[a.plan.md, b.plan.md]`),
|
|
1419
|
+
* backticked variants, and nested bullets. Order is preserved; duplicates and
|
|
1420
|
+
* anything that is not an exact `*.plan.md` basename are dropped (prefer
|
|
1421
|
+
* false negatives over inventing plan refs).
|
|
1422
|
+
* @param {string} raw
|
|
1423
|
+
* @returns {string[]}
|
|
1424
|
+
*/
|
|
1425
|
+
export function parseRunQueue(raw) {
|
|
1426
|
+
if (!raw || typeof raw !== "string") return [];
|
|
1427
|
+
const text = raw
|
|
1428
|
+
.trim()
|
|
1429
|
+
.replace(/^\[/, "")
|
|
1430
|
+
.replace(/\]\s*$/, "");
|
|
1431
|
+
const ids = [];
|
|
1432
|
+
const seen = new Set();
|
|
1433
|
+
for (const part of text.split(/[,;\n]/)) {
|
|
1434
|
+
const cleaned = String(part)
|
|
1435
|
+
.replace(/`/g, "")
|
|
1436
|
+
.replace(/\(.*?\)/g, "")
|
|
1437
|
+
.trim()
|
|
1438
|
+
.replace(/^-\s*/, "")
|
|
1439
|
+
.replace(/^plans\//, "");
|
|
1440
|
+
if (!cleaned || /^none$/i.test(cleaned)) continue;
|
|
1441
|
+
const base = cleaned.split("/").pop();
|
|
1442
|
+
if (!base || !/\.plan\.md$/i.test(base) || seen.has(base)) continue;
|
|
1443
|
+
seen.add(base);
|
|
1444
|
+
ids.push(base);
|
|
1445
|
+
}
|
|
1446
|
+
return ids;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Parse `Queue cursor` (`N (current: plan-x.plan.md)`). A missing or
|
|
1451
|
+
* non-numeric index yields `{ index: null, plan: null }`; the optional
|
|
1452
|
+
* `current:` plan ref must be an exact `*.plan.md` basename.
|
|
1453
|
+
* @param {string} raw
|
|
1454
|
+
* @returns {{ index: number|null, plan: string|null }}
|
|
1455
|
+
*/
|
|
1456
|
+
export function parseQueueCursor(raw) {
|
|
1457
|
+
const text = String(raw || "").trim();
|
|
1458
|
+
const indexMatch = text.match(/^(\d+)\b/);
|
|
1459
|
+
const index = indexMatch ? Number(indexMatch[1]) : null;
|
|
1460
|
+
let plan = null;
|
|
1461
|
+
const currentMatch = text.match(/current:\s*`?([^`()]+?)`?\s*\)/i);
|
|
1462
|
+
if (currentMatch) {
|
|
1463
|
+
const base = currentMatch[1].trim().split("/").pop();
|
|
1464
|
+
if (base && /\.plan\.md$/i.test(base)) plan = base;
|
|
1465
|
+
}
|
|
1466
|
+
return { index, plan };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Parse `Queue outcomes` lines (`plan-x.plan.md: completed (notes)`) into a
|
|
1471
|
+
* basename → outcome-token map. The outcome is the first word after the colon,
|
|
1472
|
+
* lowercased; notes stay in `queueOutcomesRaw`. Lines without an exact
|
|
1473
|
+
* `*.plan.md` basename are skipped.
|
|
1474
|
+
* @param {string} raw
|
|
1475
|
+
* @returns {Record<string, string>}
|
|
1476
|
+
*/
|
|
1477
|
+
export function parseQueueOutcomes(raw) {
|
|
1478
|
+
if (!raw || typeof raw !== "string") return {};
|
|
1479
|
+
/** @type {Record<string, string>} */
|
|
1480
|
+
const outcomes = {};
|
|
1481
|
+
for (const line of raw.split("\n")) {
|
|
1482
|
+
const cleaned = line.replace(/^-\s*/, "").trim();
|
|
1483
|
+
if (!cleaned) continue;
|
|
1484
|
+
const m = cleaned.match(
|
|
1485
|
+
/^`?([A-Za-z0-9._/-]*?[A-Za-z0-9._-]+\.plan\.md)`?\s*:\s*([A-Za-z_-]+)/i,
|
|
1486
|
+
);
|
|
1487
|
+
if (!m) continue;
|
|
1488
|
+
const base = m[1].split("/").pop();
|
|
1489
|
+
if (!base || outcomes[base]) continue;
|
|
1490
|
+
outcomes[base] = m[2].toLowerCase();
|
|
1491
|
+
}
|
|
1492
|
+
return outcomes;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Extract the active plan basename from `- **Plan:** …`.
|
|
1497
|
+
* Accepts `- **Plan:** \`file.plan.md\`` or plain `- **Plan:** file.plan.md`.
|
|
1498
|
+
* Returns null for missing lines, `none` / `n/a`, or non-`*.plan.md` values.
|
|
1499
|
+
* @param {string} content
|
|
1500
|
+
* @returns {string|null}
|
|
1501
|
+
*/
|
|
1502
|
+
export function extractHandoffPlanRef(content) {
|
|
1503
|
+
if (!content || typeof content !== "string") return null;
|
|
1504
|
+
const match = content.match(/^- \*\*Plan:\*\*\s*(.+)$/m);
|
|
1505
|
+
if (!match) return null;
|
|
1506
|
+
let raw = match[1].trim();
|
|
1507
|
+
const tick = raw.match(/^`([^`]+)`/);
|
|
1508
|
+
if (tick) raw = tick[1].trim();
|
|
1509
|
+
if (!raw || /^(none|n\/a)\b/i.test(raw)) return null;
|
|
1510
|
+
const baseMatch = raw.match(/([A-Za-z0-9._-]+\.plan\.md)/i);
|
|
1511
|
+
if (!baseMatch) return null;
|
|
1512
|
+
return baseMatch[1];
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* Capture a HANDOFF field as a multi-line block: same-line remainder plus
|
|
1517
|
+
* nested bullets, stopping at the next top-level `- **Field:**`.
|
|
1518
|
+
* Fallback: `## FieldLabel` heading + following bullets (agents often invent
|
|
1519
|
+
* section headings instead of `- **Field:**` machine bullets). Prefer false
|
|
1520
|
+
* negatives over inventing plan refs from prose.
|
|
1521
|
+
* @param {string} content
|
|
1522
|
+
* @param {string} fieldLabel
|
|
1523
|
+
* @returns {string|null}
|
|
1524
|
+
*/
|
|
1525
|
+
export function extractHandoffFieldBlock(content, fieldLabel) {
|
|
1526
|
+
if (!content || typeof content !== "string" || !fieldLabel) return null;
|
|
1527
|
+
const escaped = String(fieldLabel).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1528
|
+
const re = new RegExp(`^- \\*\\*${escaped}:\\*\\*\\s*(.*)$`, "m");
|
|
1529
|
+
const match = content.match(re);
|
|
1530
|
+
if (match) {
|
|
1531
|
+
const chunks = [];
|
|
1532
|
+
const sameLine = match[1].trim();
|
|
1533
|
+
if (sameLine) chunks.push(sameLine);
|
|
1534
|
+
|
|
1535
|
+
const after = content.slice(match.index + match[0].length);
|
|
1536
|
+
for (const line of after.split("\n")) {
|
|
1537
|
+
if (/^- \*\*[^*:\n]+:\*\*/.test(line)) break;
|
|
1538
|
+
if (!line.trim()) continue;
|
|
1539
|
+
if (/^\s+\S/.test(line)) {
|
|
1540
|
+
chunks.push(line.trim());
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
break;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const raw = chunks.join("\n").trim();
|
|
1547
|
+
if (raw) return raw;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Heading antipattern fallback: ## Backlog plans / ## Parked plans / ## Run queue
|
|
1551
|
+
const headingRe = new RegExp(`^##\\s+${escaped}\\s*$`, "m");
|
|
1552
|
+
const heading = content.match(headingRe);
|
|
1553
|
+
if (!heading) return null;
|
|
1554
|
+
|
|
1555
|
+
const chunks = [];
|
|
1556
|
+
const after = content.slice(heading.index + heading[0].length);
|
|
1557
|
+
for (const line of after.split("\n")) {
|
|
1558
|
+
if (/^##\s+/.test(line)) break;
|
|
1559
|
+
if (/^- \*\*[^*:\n]+:\*\*/.test(line)) {
|
|
1560
|
+
// Nested machine field under the heading (e.g. ## Run queue then - **Run queue:**)
|
|
1561
|
+
// Prefer the field parser path when present; do not double-consume here.
|
|
1562
|
+
if (new RegExp(`^- \\*\\*${escaped}:\\*\\*`, "m").test(line)) break;
|
|
1563
|
+
break;
|
|
1564
|
+
}
|
|
1565
|
+
if (!line.trim()) {
|
|
1566
|
+
if (chunks.length > 0) break;
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
if (/^[-*]\s+/.test(line) || /^\s+\S/.test(line)) {
|
|
1570
|
+
chunks.push(line.trim());
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const raw = chunks.join("\n").trim();
|
|
1577
|
+
return raw || null;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Extract `*.plan.md` basenames from a HANDOFF plan-reference block.
|
|
1582
|
+
* Rejects branch names, memory/monitor paths, and other inline backtick noise.
|
|
1583
|
+
*/
|
|
1584
|
+
export function parseParkedPlans(raw) {
|
|
1585
|
+
if (!raw || typeof raw !== "string") return [];
|
|
1586
|
+
const ids = [];
|
|
1587
|
+
const backtick = [...raw.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
|
1588
|
+
const sources = backtick.length > 0 ? backtick : raw.split(/[,;]/);
|
|
1589
|
+
for (const part of sources) {
|
|
1590
|
+
const cleaned = String(part)
|
|
1591
|
+
.replace(/\(.*?\)/g, "")
|
|
1592
|
+
.trim()
|
|
1593
|
+
.replace(/^plans\//, "");
|
|
1594
|
+
if (!cleaned || /^none$/i.test(cleaned)) continue;
|
|
1595
|
+
const base = cleaned.split("/").pop();
|
|
1596
|
+
if (!base || !/\.plan\.md$/i.test(base)) continue;
|
|
1597
|
+
ids.push(base);
|
|
1598
|
+
}
|
|
1599
|
+
return [...new Set(ids)];
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/** Queue outcomes that mean the plan's run is over and cannot be next up. */
|
|
1603
|
+
const TERMINAL_QUEUE_OUTCOMES = new Set(["completed", "cancelled", "skipped"]);
|
|
1604
|
+
|
|
1605
|
+
/** True when HANDOFF Mode names the multi-plan queue mode. */
|
|
1606
|
+
function modeIsRunPlanAll(mode) {
|
|
1607
|
+
return typeof mode === "string" && /\brun-plan-all\b/i.test(mode);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* Build the /run-plan-all queue view from a parsed HANDOFF, or null when the
|
|
1612
|
+
* snapshot is not in queue mode (Mode is not run-plan-all, or Run queue is
|
|
1613
|
+
* empty). Cursor resolution prefers the explicit in-range index, then the
|
|
1614
|
+
* `current:` basename, then the active plan's queue position; unresolvable
|
|
1615
|
+
* cursors stay null and yield no next-up plan (false negative over guessing).
|
|
1616
|
+
* @param {object|null} handoff
|
|
1617
|
+
* @returns {{ queue: string[], cursor: number|null, status: string|null,
|
|
1618
|
+
* outcomes: Record<string, string>, nextUpPlan: string|null }|null}
|
|
1619
|
+
*/
|
|
1620
|
+
export function buildRunQueueView(handoff) {
|
|
1621
|
+
const queue = Array.isArray(handoff?.runQueue) ? handoff.runQueue : [];
|
|
1622
|
+
if (!modeIsRunPlanAll(handoff?.mode) || queue.length === 0) return null;
|
|
1623
|
+
|
|
1624
|
+
let cursor = null;
|
|
1625
|
+
if (
|
|
1626
|
+
Number.isInteger(handoff.queueCursor) &&
|
|
1627
|
+
handoff.queueCursor >= 0 &&
|
|
1628
|
+
handoff.queueCursor < queue.length
|
|
1629
|
+
) {
|
|
1630
|
+
cursor = handoff.queueCursor;
|
|
1631
|
+
}
|
|
1632
|
+
if (cursor === null && handoff.queueCursorPlan) {
|
|
1633
|
+
const idx = queue.indexOf(handoff.queueCursorPlan);
|
|
1634
|
+
if (idx >= 0) cursor = idx;
|
|
1635
|
+
}
|
|
1636
|
+
if (cursor === null) {
|
|
1637
|
+
const idx = queue.indexOf(handoffPlanKey(handoff));
|
|
1638
|
+
if (idx >= 0) cursor = idx;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const outcomes =
|
|
1642
|
+
handoff.queueOutcomes && typeof handoff.queueOutcomes === "object" ? handoff.queueOutcomes : {};
|
|
1643
|
+
|
|
1644
|
+
let nextUpPlan = null;
|
|
1645
|
+
if (cursor !== null) {
|
|
1646
|
+
for (let i = cursor + 1; i < queue.length; i++) {
|
|
1647
|
+
if (TERMINAL_QUEUE_OUTCOMES.has(outcomes[queue[i]])) continue;
|
|
1648
|
+
nextUpPlan = queue[i];
|
|
1649
|
+
break;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
return {
|
|
1654
|
+
queue,
|
|
1655
|
+
cursor,
|
|
1656
|
+
status: handoff.queueStatus || null,
|
|
1657
|
+
outcomes,
|
|
1658
|
+
nextUpPlan,
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Queue role for one plan, layered on top of lifecycle when the snapshot is in
|
|
1664
|
+
* /run-plan-all queue mode. Roles never replace lifecycle (the executing
|
|
1665
|
+
* shimmer keys on lifecycle, not on this field).
|
|
1666
|
+
* @param {object} plan
|
|
1667
|
+
* @param {ReturnType<typeof buildRunQueueView>} runQueueView
|
|
1668
|
+
* @returns {'executing'|'next_up'|'queued'|'completed_in_queue'|'none'}
|
|
1669
|
+
*/
|
|
1670
|
+
export function planQueueRole(plan, runQueueView) {
|
|
1671
|
+
if (!runQueueView) return "none";
|
|
1672
|
+
const key = planFileKey(plan);
|
|
1673
|
+
const idx = runQueueView.queue.indexOf(key);
|
|
1674
|
+
if (idx < 0) return "none";
|
|
1675
|
+
if (TERMINAL_QUEUE_OUTCOMES.has(runQueueView.outcomes[key])) return "completed_in_queue";
|
|
1676
|
+
const { cursor, nextUpPlan } = runQueueView;
|
|
1677
|
+
if (cursor === null) return "queued";
|
|
1678
|
+
if (idx === cursor) return "executing";
|
|
1679
|
+
if (key === nextUpPlan) return "next_up";
|
|
1680
|
+
if (idx > cursor) return "queued";
|
|
1681
|
+
// Before the cursor without a terminal outcome: the queue moved past it
|
|
1682
|
+
// (blocked/partial); fall back to lifecycle-only presentation.
|
|
1683
|
+
return "none";
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function planFileKey(plan) {
|
|
1687
|
+
if (!plan) return "";
|
|
1688
|
+
return String(plan.file || plan.path || plan.id || "")
|
|
1689
|
+
.split("/")
|
|
1690
|
+
.pop()
|
|
1691
|
+
.trim();
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function handoffPlanKey(handoff) {
|
|
1695
|
+
if (!handoff?.plan) return "";
|
|
1696
|
+
return String(handoff.plan).split("/").pop().trim();
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function isActivePlan(plan, handoff) {
|
|
1700
|
+
const active = handoffPlanKey(handoff);
|
|
1701
|
+
if (!active) return false;
|
|
1702
|
+
const key = planFileKey(plan);
|
|
1703
|
+
if (!key) return false;
|
|
1704
|
+
return (
|
|
1705
|
+
key === active ||
|
|
1706
|
+
key === `${active}.plan.md` ||
|
|
1707
|
+
active === key.replace(/\.plan\.md$/, "") ||
|
|
1708
|
+
active.includes(key) ||
|
|
1709
|
+
key.includes(active.replace(/\.plan\.md$/, ""))
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function planListedIn(plan, list) {
|
|
1714
|
+
if (!Array.isArray(list) || list.length === 0) return false;
|
|
1715
|
+
const key = planFileKey(plan);
|
|
1716
|
+
const id = String(plan?.id || "");
|
|
1717
|
+
return list.some((p) => {
|
|
1718
|
+
const base = String(p).split("/").pop();
|
|
1719
|
+
return (
|
|
1720
|
+
base === key ||
|
|
1721
|
+
base === `${id}.plan.md` ||
|
|
1722
|
+
base.replace(/\.plan\.md$/, "") === id ||
|
|
1723
|
+
key.includes(base.replace(/\.plan\.md$/, ""))
|
|
1724
|
+
);
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function isParkedPlan(plan, handoff) {
|
|
1729
|
+
return planListedIn(plan, handoff?.parkedPlans);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function isBacklogPlan(plan, handoff) {
|
|
1733
|
+
return planListedIn(plan, handoff?.backlogPlans);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/** Terminal to-do statuses: work that can no longer be current or next. */
|
|
1737
|
+
const TERMINAL_TODO_STATUSES = new Set(["completed", "cancelled"]);
|
|
1738
|
+
|
|
1739
|
+
function todoStats(plan) {
|
|
1740
|
+
const items = plan?.todos?.items || [];
|
|
1741
|
+
// When items exist, treat their statuses as SoT for open/in_progress so a
|
|
1742
|
+
// stale summary cannot collapse a mission during HANDOFF transitions.
|
|
1743
|
+
const fromItems = items.length > 0;
|
|
1744
|
+
const completed = fromItems
|
|
1745
|
+
? items.filter((t) => t.status === "completed").length
|
|
1746
|
+
: (plan?.todos?.completed ?? 0);
|
|
1747
|
+
const inProgress = fromItems
|
|
1748
|
+
? items.filter((t) => t.status === "in_progress").length
|
|
1749
|
+
: (plan?.todos?.inProgress ?? 0);
|
|
1750
|
+
const pending = fromItems
|
|
1751
|
+
? items.filter((t) => t.status === "pending").length
|
|
1752
|
+
: (plan?.todos?.pending ?? 0);
|
|
1753
|
+
const cancelled = fromItems ? items.filter((t) => t.status === "cancelled").length : 0;
|
|
1754
|
+
const total = fromItems ? items.length : (plan?.todos?.total ?? 0);
|
|
1755
|
+
const open = fromItems
|
|
1756
|
+
? items.filter((t) => !TERMINAL_TODO_STATUSES.has(t.status)).length
|
|
1757
|
+
: Math.max(0, total - completed - cancelled);
|
|
1758
|
+
return { items, total, completed, inProgress, pending, cancelled, open };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function modeImpliesAwaiting(mode) {
|
|
1762
|
+
return typeof mode === "string" && AWAITING_MODE_RE.test(mode);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function modeImpliesStoppedExhausted(mode) {
|
|
1766
|
+
return typeof mode === "string" && STOPPED_EXHAUSTED_MODE_RE.test(mode);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Live run signals. STOPPED/exhausted modes often still mention run-plan or
|
|
1771
|
+
* orchestrated; those must not count as executing once the run has stopped.
|
|
1772
|
+
*/
|
|
1773
|
+
function modeImpliesExecuting(mode) {
|
|
1774
|
+
return (
|
|
1775
|
+
typeof mode === "string" && EXECUTING_MODE_RE.test(mode) && !modeImpliesStoppedExhausted(mode)
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Classify a plan lifecycle from HANDOFF + todo evidence.
|
|
1781
|
+
* Parked plans with zero open todos present as completed (not PARKED at N/N).
|
|
1782
|
+
* Backlog plans (queued by /start-project) are a first-class info lifecycle;
|
|
1783
|
+
* zero open todos also present as completed (mirror parked).
|
|
1784
|
+
* Active plan: terminal todos (open === 0) are always completed, even when Mode
|
|
1785
|
+
* still says run-plan before Final HANDOFF writes STOPPED/exhausted. Open work
|
|
1786
|
+
* never classifies as completed (pending/in_progress hold the mission live).
|
|
1787
|
+
* @returns {'executing'|'awaiting_user'|'parked'|'backlog'|'incomplete'|'completed'}
|
|
1788
|
+
*/
|
|
1789
|
+
export function classifyPlan(plan, handoff) {
|
|
1790
|
+
const stats = todoStats(plan);
|
|
1791
|
+
|
|
1792
|
+
if (isParkedPlan(plan, handoff)) {
|
|
1793
|
+
if (stats.open === 0) return "completed";
|
|
1794
|
+
return "parked";
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
if (isBacklogPlan(plan, handoff)) {
|
|
1798
|
+
if (stats.open === 0) return "completed";
|
|
1799
|
+
return "backlog";
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
const active = isActivePlan(plan, handoff);
|
|
1803
|
+
const mode = handoff?.mode || "";
|
|
1804
|
+
|
|
1805
|
+
if (active) {
|
|
1806
|
+
// Terminal work: hold completed (not idle, not a false executing tick).
|
|
1807
|
+
if (stats.open === 0) return "completed";
|
|
1808
|
+
// Open work: never completed. Premature STOPPED/exhausted still holds live.
|
|
1809
|
+
if (modeImpliesAwaiting(mode) && stats.inProgress === 0) return "awaiting_user";
|
|
1810
|
+
if (stats.inProgress > 0 || modeImpliesExecuting(mode)) return "executing";
|
|
1811
|
+
return "awaiting_user";
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
if (stats.total > 0 && stats.open === 0) return "completed";
|
|
1815
|
+
if (stats.open > 0) {
|
|
1816
|
+
if (stats.completed > 0 || stats.inProgress > 0) return "incomplete";
|
|
1817
|
+
return "backlog";
|
|
1818
|
+
}
|
|
1819
|
+
return "completed";
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function pickCurrentTodo(plan, handoff) {
|
|
1823
|
+
const items = plan?.todos?.items || [];
|
|
1824
|
+
const inProg = items.find((t) => t.status === "in_progress");
|
|
1825
|
+
if (inProg) return inProg;
|
|
1826
|
+
|
|
1827
|
+
const nextRaw = handoff?.nextTodos || "";
|
|
1828
|
+
const nextId = nextRaw.match(/`?([a-z0-9][\w-]*)`?/i)?.[1];
|
|
1829
|
+
if (nextId) {
|
|
1830
|
+
// An exhausted HANDOFF often still names the last id it ran. A terminal
|
|
1831
|
+
// to-do is not current work, so it must not resurface as the current step.
|
|
1832
|
+
const matched = items.find((t) => t.id === nextId);
|
|
1833
|
+
if (matched && !TERMINAL_TODO_STATUSES.has(matched.status)) return matched;
|
|
1834
|
+
}
|
|
1835
|
+
return items.find((t) => t.status === "pending") || null;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function pickPreviousTodo(plan, current) {
|
|
1839
|
+
const items = plan?.todos?.items || [];
|
|
1840
|
+
let end = items.length;
|
|
1841
|
+
if (current) {
|
|
1842
|
+
const idx = items.findIndex((t) => t.id === current.id);
|
|
1843
|
+
if (idx >= 0) end = idx;
|
|
1844
|
+
}
|
|
1845
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
1846
|
+
if (items[i].status === "completed") return items[i];
|
|
1847
|
+
}
|
|
1848
|
+
return null;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function pickNextTodo(plan, current) {
|
|
1852
|
+
const items = plan?.todos?.items || [];
|
|
1853
|
+
if (!current) {
|
|
1854
|
+
return items.find((t) => t.status === "pending" || t.status === "in_progress") || null;
|
|
1855
|
+
}
|
|
1856
|
+
const idx = items.findIndex((t) => t.id === current.id);
|
|
1857
|
+
if (idx >= 0) {
|
|
1858
|
+
for (let i = idx + 1; i < items.length; i++) {
|
|
1859
|
+
if (items[i].status === "pending" || items[i].status === "in_progress") {
|
|
1860
|
+
return items[i];
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
return items.find((t) => t.id !== current.id && t.status === "pending") || null;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function compactTodo(todo) {
|
|
1868
|
+
if (!todo) return null;
|
|
1869
|
+
return {
|
|
1870
|
+
id: todo.id,
|
|
1871
|
+
content: truncateStr(todo.content || "", MAX_SEMANTIC_LABEL),
|
|
1872
|
+
status: todo.status,
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* Build the "what is happening now" slice.
|
|
1878
|
+
*
|
|
1879
|
+
* Presentation contract (completed vs idle):
|
|
1880
|
+
* - When HANDOFF still names a plan and that plan's to-dos are terminal,
|
|
1881
|
+
* status is `"completed"` and the mission keeps plan identity + N/N progress.
|
|
1882
|
+
* - True idle (`status: "idle"`, null plan refs, 0/0 progress) only when
|
|
1883
|
+
* HANDOFF has no plan reference. Completed is never collapsed to idle here.
|
|
1884
|
+
*
|
|
1885
|
+
* Lifecycle hold (HANDOFF transitions):
|
|
1886
|
+
* - Pending or in_progress to-dos never render as completed or idle, even when
|
|
1887
|
+
* Mode already says STOPPED/exhausted (premature Final HANDOFF).
|
|
1888
|
+
* - All-terminal to-dos render completed even when Mode still says run-plan
|
|
1889
|
+
* (tick closed to-do status before Final HANDOFF marks exhausted).
|
|
1890
|
+
*
|
|
1891
|
+
* Terminal step contract (completed missions): no to-do is open, so
|
|
1892
|
+
* `currentTodo` and `nextTodo` are null and `previousTodo` carries the last
|
|
1893
|
+
* completed to-do. That is the terminal step the panel renders as done; there
|
|
1894
|
+
* is no pending step left to announce.
|
|
1895
|
+
*/
|
|
1896
|
+
export function buildCurrentExecution(plans, handoff) {
|
|
1897
|
+
if (!handoff?.plan) {
|
|
1898
|
+
return {
|
|
1899
|
+
status: "idle",
|
|
1900
|
+
planId: null,
|
|
1901
|
+
planFile: null,
|
|
1902
|
+
planPath: null,
|
|
1903
|
+
mode: null,
|
|
1904
|
+
progress: { completed: 0, total: 0 },
|
|
1905
|
+
previousTodo: null,
|
|
1906
|
+
currentTodo: null,
|
|
1907
|
+
nextTodo: null,
|
|
1908
|
+
modifiedAt: null,
|
|
1909
|
+
sourcePath: ".cursor/HANDOFF.md",
|
|
1910
|
+
lifecycle: null,
|
|
1911
|
+
nextUpPlan: null,
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const active = (plans || []).find((p) => isActivePlan(p, handoff)) || null;
|
|
1916
|
+
const lifecycle = active ? classifyPlan(active, handoff) : null;
|
|
1917
|
+
const stats = active ? todoStats(active) : { completed: 0, total: 0, open: 0, inProgress: 0 };
|
|
1918
|
+
const currentTodo = active ? pickCurrentTodo(active, handoff) : null;
|
|
1919
|
+
const previousTodo = active ? pickPreviousTodo(active, currentTodo) : null;
|
|
1920
|
+
const nextTodo = active ? pickNextTodo(active, currentTodo) : null;
|
|
1921
|
+
|
|
1922
|
+
let status = "idle";
|
|
1923
|
+
if (lifecycle === "executing") status = "executing";
|
|
1924
|
+
else if (lifecycle === "awaiting_user") status = "awaiting_user";
|
|
1925
|
+
else if (lifecycle === "completed") status = "completed";
|
|
1926
|
+
else if (active && stats.open > 0) status = "awaiting_user";
|
|
1927
|
+
|
|
1928
|
+
// Defense in depth: open work must never collapse to completed or idle.
|
|
1929
|
+
if (active && stats.open > 0 && (status === "completed" || status === "idle")) {
|
|
1930
|
+
status = stats.inProgress > 0 ? "executing" : "awaiting_user";
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
return {
|
|
1934
|
+
status,
|
|
1935
|
+
planId: active?.id || handoff.plan.replace(/\.plan\.md$/, ""),
|
|
1936
|
+
planFile: active?.file || handoffPlanKey(handoff),
|
|
1937
|
+
planPath: active?.path || handoff.planPath || null,
|
|
1938
|
+
mode: handoff.mode || null,
|
|
1939
|
+
// HANDOFF Gaps (stop reasons / residuals). Null when absent or "none".
|
|
1940
|
+
gaps: handoff.gaps || null,
|
|
1941
|
+
progress: { completed: stats.completed, total: stats.total },
|
|
1942
|
+
previousTodo: compactTodo(previousTodo),
|
|
1943
|
+
currentTodo: compactTodo(currentTodo),
|
|
1944
|
+
nextTodo: compactTodo(nextTodo),
|
|
1945
|
+
modifiedAt: active?.modifiedAt || handoff.lastUpdated || null,
|
|
1946
|
+
sourcePath: ".cursor/HANDOFF.md",
|
|
1947
|
+
lifecycle,
|
|
1948
|
+
// Next queue item after the cursor in /run-plan-all mode (basename), or
|
|
1949
|
+
// null. The panel uses it only on terminal Next paths; live in-plan Next
|
|
1950
|
+
// stays the in-plan to-do.
|
|
1951
|
+
nextUpPlan: buildRunQueueView(handoff)?.nextUpPlan ?? null,
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
function activityId(kind, parts) {
|
|
1956
|
+
return truncateStr(`${kind}:${parts.filter(Boolean).join(":")}`, 120);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* Format durable git log lines into semantic activity events.
|
|
1961
|
+
* @param {string[]} logLines - `git log --oneline` style lines (newest first)
|
|
1962
|
+
* @param {object} [opts]
|
|
1963
|
+
* @param {number} [opts.limit]
|
|
1964
|
+
* @param {Iterable<string>|Set<string>|null} [opts.excludeShas] - SHAs already
|
|
1965
|
+
* covered by a delivery event (merge + absorbed commits); skip those rows
|
|
1966
|
+
*/
|
|
1967
|
+
export function formatGitActivity(logLines, { limit = MAX_GIT_ACTIVITY, excludeShas = null } = {}) {
|
|
1968
|
+
const exclude =
|
|
1969
|
+
excludeShas instanceof Set ? excludeShas : new Set(excludeShas ? [...excludeShas] : []);
|
|
1970
|
+
const events = [];
|
|
1971
|
+
for (const line of logLines || []) {
|
|
1972
|
+
if (events.length >= limit) break;
|
|
1973
|
+
const trimmed = String(line || "").trim();
|
|
1974
|
+
if (!trimmed) continue;
|
|
1975
|
+
|
|
1976
|
+
const merge = trimmed.match(MERGE_PR_RE);
|
|
1977
|
+
if (merge) {
|
|
1978
|
+
const sha = merge[1].slice(0, 7);
|
|
1979
|
+
if (exclude.has(sha)) continue;
|
|
1980
|
+
const pr = merge[2];
|
|
1981
|
+
events.push({
|
|
1982
|
+
id: activityId("merge", [pr, sha]),
|
|
1983
|
+
kind: "merge",
|
|
1984
|
+
at: null,
|
|
1985
|
+
label: truncateStr(`Merged PR #${pr} → ${sha}.`, MAX_SEMANTIC_LABEL),
|
|
1986
|
+
refs: { pr: Number(pr), sha },
|
|
1987
|
+
});
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
const m = trimmed.match(/^([0-9a-f]{7,40})\s+(.+)$/i);
|
|
1992
|
+
if (!m) continue;
|
|
1993
|
+
const sha = m[1].slice(0, 7);
|
|
1994
|
+
if (exclude.has(sha)) continue;
|
|
1995
|
+
const message = m[2].trim();
|
|
1996
|
+
const kind = STAGING_COMMIT_RE.test(message) ? "staging" : "commit";
|
|
1997
|
+
events.push({
|
|
1998
|
+
id: activityId(kind, [sha]),
|
|
1999
|
+
kind,
|
|
2000
|
+
at: null,
|
|
2001
|
+
label: truncateStr(
|
|
2002
|
+
kind === "staging" ? `Staging ${sha}: ${message}` : `Commit ${sha}: ${message}`,
|
|
2003
|
+
MAX_SEMANTIC_LABEL,
|
|
2004
|
+
),
|
|
2005
|
+
refs: { sha },
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
return events;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/** GitHub squash-merge subject suffix (`title (#N)`): one shipped PR in one commit. */
|
|
2012
|
+
const SQUASH_PR_SUFFIX_RE = /\(#(\d+)\)$/;
|
|
2013
|
+
|
|
2014
|
+
/** Conventional Commit type at subject start (scope + breaking `!` optional). */
|
|
2015
|
+
const CONVENTIONAL_COMMIT_TYPE_RE =
|
|
2016
|
+
/^(feat|fix|docs|chore|refactor|style|perf|test|ci|build)(\([^)]*\))?(!)?:\s*/i;
|
|
2017
|
+
|
|
2018
|
+
/** Types folded into the Monitor delivery `chore` chip subclass. */
|
|
2019
|
+
const DELIVERY_CHORE_TYPES = new Set(["chore", "refactor", "style", "perf", "test", "ci", "build"]);
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* Map a delivery brief subject to a Monitor chip subtype.
|
|
2023
|
+
* Keep `kind: delivery`; subtypes only drive icon/color.
|
|
2024
|
+
* @param {string|null|undefined} subject
|
|
2025
|
+
* @param {{ hasPr?: boolean }} [opts]
|
|
2026
|
+
* @returns {'feat'|'fix'|'docs'|'chore'|'pr'|'ship'}
|
|
2027
|
+
*/
|
|
2028
|
+
export function parseDeliveryCommitType(subject, { hasPr = false } = {}) {
|
|
2029
|
+
const text = String(subject || "").trim();
|
|
2030
|
+
if (text) {
|
|
2031
|
+
const m = text.match(CONVENTIONAL_COMMIT_TYPE_RE);
|
|
2032
|
+
if (m) {
|
|
2033
|
+
const raw = m[1].toLowerCase();
|
|
2034
|
+
if (raw === "feat") return "feat";
|
|
2035
|
+
if (raw === "fix") return "fix";
|
|
2036
|
+
if (raw === "docs") return "docs";
|
|
2037
|
+
if (DELIVERY_CHORE_TYPES.has(raw)) return "chore";
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
if (hasPr) return "pr";
|
|
2041
|
+
return "ship";
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/** Strip trailing `(#N)` from a squash subject for brief delivery labels. */
|
|
2045
|
+
function stripSquashPrSuffix(subject) {
|
|
2046
|
+
return String(subject || "")
|
|
2047
|
+
.trim()
|
|
2048
|
+
.replace(/\s*\(#\d+\)$/, "")
|
|
2049
|
+
.trim();
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* Coalesce `git log --oneline` lines into delivery events anchored on merges.
|
|
2054
|
+
* A `Merge pull request #N` line absorbs the non-merge commits beneath it (the
|
|
2055
|
+
* feature commits it merged) until the next delivery anchor, so one shipped
|
|
2056
|
+
* unit renders as one delivery event. A squash-merged commit (`title (#N)`) is
|
|
2057
|
+
* its own single-commit anchor: it stops absorption so its commit is never
|
|
2058
|
+
* attributed to a neighboring merge. Commits above the newest anchor are
|
|
2059
|
+
* unshipped and emit nothing here (they stay `commit` rows on the activity
|
|
2060
|
+
* stream). Coalescing is purely positional: the oneline format carries no
|
|
2061
|
+
* timestamps, so there is no time-window heuristic.
|
|
2062
|
+
*
|
|
2063
|
+
* Labels include a brief ship subject (squash title without `(#N)`, or the first
|
|
2064
|
+
* absorbed feature commit subject) plus PR # and sha. Attribution is resolved
|
|
2065
|
+
* per merge branch against plan basenames (never from the active plan); squash
|
|
2066
|
+
* lines carry no branch, so they resolve to nulls.
|
|
2067
|
+
* @param {string[]} logLines - `git log --oneline` lines (newest first)
|
|
2068
|
+
* @param {object} [opts]
|
|
2069
|
+
* @param {Array<{ file?: string, agent?: string|null }>} [opts.plans] - snapshot plans for attribution
|
|
2070
|
+
* @param {number} [opts.limit] - max delivery events (mirrors other producers)
|
|
2071
|
+
*/
|
|
2072
|
+
export function formatDeliveryActivity(logLines, { plans = [], limit = MAX_GIT_ACTIVITY } = {}) {
|
|
2073
|
+
const entries = [];
|
|
2074
|
+
for (const line of logLines || []) {
|
|
2075
|
+
const trimmed = String(line || "").trim();
|
|
2076
|
+
if (!trimmed) continue;
|
|
2077
|
+
const merge = trimmed.match(MERGE_PR_RE);
|
|
2078
|
+
if (merge) {
|
|
2079
|
+
entries.push({
|
|
2080
|
+
sha: merge[1].slice(0, 7),
|
|
2081
|
+
pr: Number(merge[2]),
|
|
2082
|
+
kind: "merge",
|
|
2083
|
+
branch: extractMergeBranch(merge[3]),
|
|
2084
|
+
subject: null,
|
|
2085
|
+
});
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
const m = trimmed.match(/^([0-9a-f]{7,40})\s+(.+)$/i);
|
|
2089
|
+
if (!m) continue;
|
|
2090
|
+
const fullSubject = m[2].trim();
|
|
2091
|
+
const squash = fullSubject.match(SQUASH_PR_SUFFIX_RE);
|
|
2092
|
+
entries.push({
|
|
2093
|
+
sha: m[1].slice(0, 7),
|
|
2094
|
+
pr: squash ? Number(squash[1]) : null,
|
|
2095
|
+
kind: squash ? "squash" : "commit",
|
|
2096
|
+
branch: null,
|
|
2097
|
+
subject: stripSquashPrSuffix(fullSubject),
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
// Entries stay newest-first. In this repository's history a merge line is
|
|
2102
|
+
// followed by the feature commits it merged, then the next merge or squash.
|
|
2103
|
+
const events = [];
|
|
2104
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2105
|
+
if (events.length >= limit) break;
|
|
2106
|
+
const entry = entries[i];
|
|
2107
|
+
if (entry.kind === "commit") continue;
|
|
2108
|
+
const shas = [entry.sha];
|
|
2109
|
+
let brief = entry.kind === "squash" ? entry.subject : null;
|
|
2110
|
+
if (entry.kind === "merge") {
|
|
2111
|
+
for (let j = i + 1; j < entries.length && entries[j].kind === "commit"; j++) {
|
|
2112
|
+
shas.push(entries[j].sha);
|
|
2113
|
+
if (!brief && entries[j].subject) brief = entries[j].subject;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
const { plan: planName, agent } = resolveDeliveryAttribution(entry.branch, plans);
|
|
2117
|
+
const kitAgent = normalizeKitAgentId(agent);
|
|
2118
|
+
const actor = briefActivityActor(kitAgent, { kind: "delivery", plan: planName });
|
|
2119
|
+
const prBit = `PR #${entry.pr}`;
|
|
2120
|
+
const label = brief
|
|
2121
|
+
? `${actor} \u00b7 shipped \u00b7 ${brief} \u00b7 ${prBit} \u00b7 ${entry.sha}`
|
|
2122
|
+
: `${actor} \u00b7 shipped \u00b7 ${prBit} \u00b7 ${entry.sha}`;
|
|
2123
|
+
const commitType = parseDeliveryCommitType(brief, { hasPr: entry.pr != null });
|
|
2124
|
+
events.push({
|
|
2125
|
+
id: activityId("delivery", ["merge", String(entry.pr)]),
|
|
2126
|
+
kind: "delivery",
|
|
2127
|
+
at: null,
|
|
2128
|
+
agent: kitAgent,
|
|
2129
|
+
label: truncateStr(label, MAX_SEMANTIC_LABEL),
|
|
2130
|
+
// `sha` is the merge/squash commit: copy-only target for Monitor rows.
|
|
2131
|
+
// `commitType` drives solid delivery chip subclasses (feat/fix/docs/chore/pr/ship).
|
|
2132
|
+
refs: { sha: entry.sha, commits: shas, pr: entry.pr, plan: planName, commitType },
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
return events;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/**
|
|
2140
|
+
* Actor segment for Monitor return-brief labels.
|
|
2141
|
+
* Kit agent id, else orchestrator for delivery, else plan ref, else system.
|
|
2142
|
+
* @param {string|null|undefined} agent
|
|
2143
|
+
* @param {{ kind?: string, plan?: string|null }} [opts]
|
|
2144
|
+
*/
|
|
2145
|
+
export function briefActivityActor(agent, { kind, plan } = {}) {
|
|
2146
|
+
const kit = normalizeKitAgentId(agent);
|
|
2147
|
+
if (kit) return kit;
|
|
2148
|
+
if (kind === "delivery") return "orchestrator";
|
|
2149
|
+
if (plan) return String(plan);
|
|
2150
|
+
return "system";
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/**
|
|
2154
|
+
* Collect SHAs a delivery event already represents (merge/squash + absorbed commits).
|
|
2155
|
+
* Used to drop superseded raw git rows from the unified Activity stream.
|
|
2156
|
+
* @param {Array<{ refs?: { sha?: string, commits?: string[] } }>} deliveryEvents
|
|
2157
|
+
* @returns {Set<string>}
|
|
2158
|
+
*/
|
|
2159
|
+
export function deliverySupersededShas(deliveryEvents) {
|
|
2160
|
+
const shas = new Set();
|
|
2161
|
+
for (const ev of deliveryEvents || []) {
|
|
2162
|
+
if (ev?.refs?.sha) shas.add(ev.refs.sha);
|
|
2163
|
+
for (const c of ev?.refs?.commits || []) {
|
|
2164
|
+
if (c) shas.add(c);
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
return shas;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Plan / HANDOFF milestone events (not refresh noise).
|
|
2172
|
+
*/
|
|
2173
|
+
export function formatPlanHandoffActivity({ now, handoff, plans }) {
|
|
2174
|
+
const events = [];
|
|
2175
|
+
|
|
2176
|
+
// Derive agent from the plan frontmatter when available (kit agent id only).
|
|
2177
|
+
const activePlan = (plans || []).find((p) => now && isActivePlan(p, handoff));
|
|
2178
|
+
const agentFromPlan = normalizeKitAgentId(activePlan?.agent);
|
|
2179
|
+
|
|
2180
|
+
if (now?.status === "executing" && now.currentTodo) {
|
|
2181
|
+
const planRef = now.planFile || handoff?.plan || "plan";
|
|
2182
|
+
const actor = briefActivityActor(agentFromPlan, { kind: "run_plan", plan: planRef });
|
|
2183
|
+
events.push({
|
|
2184
|
+
id: activityId("run_plan", [now.planFile, now.currentTodo.id]),
|
|
2185
|
+
kind: "run_plan",
|
|
2186
|
+
at: now.modifiedAt || null,
|
|
2187
|
+
agent: agentFromPlan,
|
|
2188
|
+
label: truncateStr(
|
|
2189
|
+
`${actor} \u00b7 tick \u00b7 ${planRef} \u00b7 ${now.currentTodo.id}`,
|
|
2190
|
+
MAX_SEMANTIC_LABEL,
|
|
2191
|
+
),
|
|
2192
|
+
sourcePath: now.planPath || null,
|
|
2193
|
+
refs: { plan: now.planFile, todo: now.currentTodo.id },
|
|
2194
|
+
});
|
|
2195
|
+
} else if (now?.status === "awaiting_user") {
|
|
2196
|
+
const planRef = now.planFile || handoff?.plan || "plan";
|
|
2197
|
+
const actor = briefActivityActor(agentFromPlan, { kind: "handoff", plan: planRef });
|
|
2198
|
+
const gate = now.nextTodo?.id ? `next ${now.nextTodo.id}` : "awaiting user";
|
|
2199
|
+
events.push({
|
|
2200
|
+
id: activityId("handoff", [now.planFile, "awaiting"]),
|
|
2201
|
+
kind: "handoff",
|
|
2202
|
+
at: now.modifiedAt || null,
|
|
2203
|
+
agent: agentFromPlan,
|
|
2204
|
+
label: truncateStr(`${actor} \u00b7 handoff \u00b7 ${gate}`, MAX_SEMANTIC_LABEL),
|
|
2205
|
+
sourcePath: ".cursor/HANDOFF.md",
|
|
2206
|
+
refs: { plan: now.planFile },
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// Denser Crew Monitor: step-by-step for orchestrator/Task to-dos on the active plan.
|
|
2211
|
+
if (activePlan && now && (now.status === "executing" || now.status === "awaiting_user")) {
|
|
2212
|
+
const planRef = now.planFile || activePlan.file || handoff?.plan || "plan";
|
|
2213
|
+
const todos = Array.isArray(activePlan?.todos?.items) ? activePlan.todos.items : [];
|
|
2214
|
+
const stepRows = [];
|
|
2215
|
+
for (const todo of todos) {
|
|
2216
|
+
if (!todo?.id) continue;
|
|
2217
|
+
const status = String(todo.status || "").toLowerCase();
|
|
2218
|
+
if (status === "completed") {
|
|
2219
|
+
stepRows.push({ todo, phase: "done" });
|
|
2220
|
+
} else if (status === "in_progress") {
|
|
2221
|
+
stepRows.push({ todo, phase: "running" });
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
// Prefer the most recent completed/running steps (tail), not the earliest.
|
|
2225
|
+
const sliced = stepRows.slice(-MONITOR_AGENT_STEP_EMIT_CAP);
|
|
2226
|
+
for (const row of sliced) {
|
|
2227
|
+
const actor = briefActivityActor(agentFromPlan, {
|
|
2228
|
+
kind: "agent_step",
|
|
2229
|
+
plan: planRef,
|
|
2230
|
+
});
|
|
2231
|
+
events.push({
|
|
2232
|
+
id: activityId("agent_step", [planRef, row.todo.id, row.phase]),
|
|
2233
|
+
kind: "agent_step",
|
|
2234
|
+
at: now.modifiedAt || null,
|
|
2235
|
+
agent: agentFromPlan,
|
|
2236
|
+
label: truncateStr(
|
|
2237
|
+
`${actor} \u00b7 step \u00b7 ${row.todo.id} \u00b7 ${row.phase}`,
|
|
2238
|
+
MAX_SEMANTIC_LABEL,
|
|
2239
|
+
),
|
|
2240
|
+
sourcePath: now.planPath || activePlan.path || null,
|
|
2241
|
+
refs: { plan: now.planFile || activePlan.file, todo: row.todo.id, phase: row.phase },
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// Only recent completed or still-open parked plans: avoid flooding activity.
|
|
2247
|
+
const completed = (plans || [])
|
|
2248
|
+
.filter((plan) => {
|
|
2249
|
+
const lifecycle = classifyPlan(plan, handoff);
|
|
2250
|
+
return lifecycle === "completed" || lifecycle === "parked";
|
|
2251
|
+
})
|
|
2252
|
+
.filter((plan) => todoStats(plan).total > 0)
|
|
2253
|
+
.sort((a, b) => String(b.modifiedAt || "").localeCompare(String(a.modifiedAt || "")))
|
|
2254
|
+
.slice(0, 3);
|
|
2255
|
+
|
|
2256
|
+
for (const plan of completed) {
|
|
2257
|
+
const stats = todoStats(plan);
|
|
2258
|
+
const lifecycle = classifyPlan(plan, handoff);
|
|
2259
|
+
const stillParked = lifecycle === "parked";
|
|
2260
|
+
const kitAgent = normalizeKitAgentId(plan.agent);
|
|
2261
|
+
const planRef = plan.file || plan.id || "plan";
|
|
2262
|
+
const actor = briefActivityActor(kitAgent, { kind: "plan_progress", plan: planRef });
|
|
2263
|
+
const verb = stillParked ? "parked" : "plan";
|
|
2264
|
+
events.push({
|
|
2265
|
+
id: activityId("plan_progress", [plan.file, stillParked ? "parked" : "done"]),
|
|
2266
|
+
kind: "plan_progress",
|
|
2267
|
+
at: plan.modifiedAt || null,
|
|
2268
|
+
agent: kitAgent,
|
|
2269
|
+
label: truncateStr(
|
|
2270
|
+
`${actor} \u00b7 ${verb} \u00b7 ${planRef} \u00b7 ${stats.completed}/${stats.total}`,
|
|
2271
|
+
MAX_SEMANTIC_LABEL,
|
|
2272
|
+
),
|
|
2273
|
+
sourcePath: plan.path || null,
|
|
2274
|
+
refs: { plan: plan.file },
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
return events;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* Narrow execution evidence from terminal lastOutput (explicit run-plan lines only).
|
|
2283
|
+
*/
|
|
2284
|
+
export function formatTerminalRunEvidence(terminals, { limit = 3 } = {}) {
|
|
2285
|
+
const events = [];
|
|
2286
|
+
for (const t of terminals || []) {
|
|
2287
|
+
if (events.length >= limit) break;
|
|
2288
|
+
const out = t?.lastOutput || "";
|
|
2289
|
+
if (!out || !/\/run-plan|LOOP_TICK_RESULT|Night shift:.*run-plan/i.test(out)) {
|
|
2290
|
+
continue;
|
|
2291
|
+
}
|
|
2292
|
+
const line =
|
|
2293
|
+
out
|
|
2294
|
+
.split("\n")
|
|
2295
|
+
.map((l) => l.trim())
|
|
2296
|
+
.find((l) => /run-plan|LOOP_TICK_RESULT|Tick →|Tick ->/i.test(l)) || null;
|
|
2297
|
+
if (!line) continue;
|
|
2298
|
+
events.push({
|
|
2299
|
+
id: activityId("run_plan", ["term", t.id, line.slice(0, 40)]),
|
|
2300
|
+
kind: "run_plan",
|
|
2301
|
+
at: null,
|
|
2302
|
+
label: truncateStr(`system \u00b7 tick \u00b7 ${line}`, MAX_SEMANTIC_LABEL),
|
|
2303
|
+
sourcePath: null,
|
|
2304
|
+
refs: { terminal: t.id },
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
return events;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* Merge activity streams newest-first, dedupe by id, bound length.
|
|
2312
|
+
*/
|
|
2313
|
+
export function mergeActivity(streams, { limit = MAX_ACTIVITY } = {}) {
|
|
2314
|
+
const seen = new Set();
|
|
2315
|
+
const out = [];
|
|
2316
|
+
for (const stream of streams) {
|
|
2317
|
+
for (const ev of stream || []) {
|
|
2318
|
+
if (!ev?.id || seen.has(ev.id)) continue;
|
|
2319
|
+
seen.add(ev.id);
|
|
2320
|
+
out.push({
|
|
2321
|
+
...ev,
|
|
2322
|
+
label: truncateStr(ev.label || "", MAX_SEMANTIC_LABEL),
|
|
2323
|
+
});
|
|
2324
|
+
if (out.length >= limit) return out;
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
return out;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
/**
|
|
2331
|
+
* Normalize an inventory key. Keeps skill path segments (`core/foo`) so
|
|
2332
|
+
* categories do not collide; strips a trailing `.md` only.
|
|
2333
|
+
*/
|
|
2334
|
+
function inventoryKey(raw) {
|
|
2335
|
+
return String(raw || "")
|
|
2336
|
+
.replace(/\\/g, "/")
|
|
2337
|
+
.replace(/^\.\//, "")
|
|
2338
|
+
.replace(/\.md$/i, "")
|
|
2339
|
+
.trim();
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
function inventoryLabelName(name) {
|
|
2343
|
+
return truncateStr(inventoryKey(name) || "item", MAX_INVENTORY_NAME);
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
/**
|
|
2347
|
+
* Fingerprint map for set-diff. Values are comparable strings; empty string
|
|
2348
|
+
* means identity-only (added/removed, never changed).
|
|
2349
|
+
* @param {iterable} items
|
|
2350
|
+
* @param {(item: object) => { id: string, fingerprint?: string }|null} project
|
|
2351
|
+
*/
|
|
2352
|
+
function inventoryFingerprintMap(items, project) {
|
|
2353
|
+
const map = new Map();
|
|
2354
|
+
for (const item of items || []) {
|
|
2355
|
+
const row = project(item);
|
|
2356
|
+
if (!row?.id) continue;
|
|
2357
|
+
const id = inventoryKey(row.id);
|
|
2358
|
+
if (!id || map.has(id)) continue;
|
|
2359
|
+
map.set(id, typeof row.fingerprint === "string" ? row.fingerprint : "");
|
|
2360
|
+
}
|
|
2361
|
+
return map;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function pushInventoryDelta(events, { kind, source, action, name, idParts, at = null, limit }) {
|
|
2365
|
+
if (events.length >= limit) return false;
|
|
2366
|
+
const safeName = inventoryLabelName(name);
|
|
2367
|
+
const verb = action === "added" ? "added" : action === "removed" ? "removed" : "changed";
|
|
2368
|
+
const section =
|
|
2369
|
+
kind === "agent"
|
|
2370
|
+
? "Agent"
|
|
2371
|
+
: kind === "skill"
|
|
2372
|
+
? "Skill"
|
|
2373
|
+
: kind === "command"
|
|
2374
|
+
? "Command"
|
|
2375
|
+
: "Memory";
|
|
2376
|
+
const parts = Array.isArray(idParts) && idParts.length > 0 ? idParts : [action, safeName];
|
|
2377
|
+
events.push({
|
|
2378
|
+
id: activityId(kind, parts),
|
|
2379
|
+
kind,
|
|
2380
|
+
source,
|
|
2381
|
+
at,
|
|
2382
|
+
label: truncateStr(`${section} ${verb}: ${safeName}`, MAX_SEMANTIC_LABEL),
|
|
2383
|
+
refs: { name: safeName, action },
|
|
2384
|
+
});
|
|
2385
|
+
return true;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
/**
|
|
2389
|
+
* Diff two fingerprint maps into added / removed / changed events.
|
|
2390
|
+
* @returns {boolean} false when the event cap is reached
|
|
2391
|
+
*/
|
|
2392
|
+
function diffInventoryMaps(events, { kind, source, previous, current, limit, idNamespace = "" }) {
|
|
2393
|
+
const ns = idNamespace ? [idNamespace] : [];
|
|
2394
|
+
for (const [id, fp] of current) {
|
|
2395
|
+
if (!previous.has(id)) {
|
|
2396
|
+
if (
|
|
2397
|
+
!pushInventoryDelta(events, {
|
|
2398
|
+
kind,
|
|
2399
|
+
source,
|
|
2400
|
+
action: "added",
|
|
2401
|
+
name: id,
|
|
2402
|
+
idParts: [...ns, "added", id],
|
|
2403
|
+
limit,
|
|
2404
|
+
})
|
|
2405
|
+
) {
|
|
2406
|
+
return false;
|
|
2407
|
+
}
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
const prevFp = previous.get(id) ?? "";
|
|
2411
|
+
if (fp !== prevFp) {
|
|
2412
|
+
if (
|
|
2413
|
+
!pushInventoryDelta(events, {
|
|
2414
|
+
kind,
|
|
2415
|
+
source,
|
|
2416
|
+
action: "changed",
|
|
2417
|
+
name: id,
|
|
2418
|
+
idParts: [...ns, "changed", id],
|
|
2419
|
+
limit,
|
|
2420
|
+
})
|
|
2421
|
+
) {
|
|
2422
|
+
return false;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
for (const id of previous.keys()) {
|
|
2427
|
+
if (current.has(id)) continue;
|
|
2428
|
+
if (
|
|
2429
|
+
!pushInventoryDelta(events, {
|
|
2430
|
+
kind,
|
|
2431
|
+
source,
|
|
2432
|
+
action: "removed",
|
|
2433
|
+
name: id,
|
|
2434
|
+
idParts: [...ns, "removed", id],
|
|
2435
|
+
limit,
|
|
2436
|
+
})
|
|
2437
|
+
) {
|
|
2438
|
+
return false;
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
return true;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
/**
|
|
2445
|
+
* Build a comparable inventory baseline from a dashboard snapshot slice.
|
|
2446
|
+
* Used as `previousInventory` on the next refresh (server-side dedupe keys).
|
|
2447
|
+
* @param {{ agents?: object[], skills?: object[], commands?: object[], memory?: object|null }} snap
|
|
2448
|
+
*/
|
|
2449
|
+
export function buildInventoryBaseline({
|
|
2450
|
+
agents = [],
|
|
2451
|
+
skills = [],
|
|
2452
|
+
commands = [],
|
|
2453
|
+
memory = null,
|
|
2454
|
+
} = {}) {
|
|
2455
|
+
const errorEntries = Array.isArray(memory?.errorEntries)
|
|
2456
|
+
? memory.errorEntries
|
|
2457
|
+
: Array.isArray(memory?.errorIds)
|
|
2458
|
+
? memory.errorIds.map((id) => ({ id }))
|
|
2459
|
+
: [];
|
|
2460
|
+
const decisionEntries = Array.isArray(memory?.decisionEntries)
|
|
2461
|
+
? memory.decisionEntries
|
|
2462
|
+
: Array.isArray(memory?.decisionIds)
|
|
2463
|
+
? memory.decisionIds.map((id) => ({ id }))
|
|
2464
|
+
: Array.isArray(memory?.recentDecisions)
|
|
2465
|
+
? memory.recentDecisions.map((d) => ({ id: d?.id || d }))
|
|
2466
|
+
: [];
|
|
2467
|
+
|
|
2468
|
+
return {
|
|
2469
|
+
agents: (agents || [])
|
|
2470
|
+
.map((a) => {
|
|
2471
|
+
const id = inventoryKey(a?.id || a?.file);
|
|
2472
|
+
if (!id) return null;
|
|
2473
|
+
return { id, fingerprint: String(a?.description || "") };
|
|
2474
|
+
})
|
|
2475
|
+
.filter(Boolean),
|
|
2476
|
+
skills: (skills || [])
|
|
2477
|
+
.map((s) => {
|
|
2478
|
+
const id = inventoryKey(s?.id || s?.title);
|
|
2479
|
+
if (!id) return null;
|
|
2480
|
+
return {
|
|
2481
|
+
id,
|
|
2482
|
+
fingerprint: `${String(s?.title || "")}\0${String(s?.description || "")}`,
|
|
2483
|
+
};
|
|
2484
|
+
})
|
|
2485
|
+
.filter(Boolean),
|
|
2486
|
+
commands: (commands || [])
|
|
2487
|
+
.map((c) => {
|
|
2488
|
+
const id = inventoryKey(c?.id || c?.file);
|
|
2489
|
+
return id ? { id, fingerprint: "" } : null;
|
|
2490
|
+
})
|
|
2491
|
+
.filter(Boolean),
|
|
2492
|
+
memory: {
|
|
2493
|
+
errors: errorEntries
|
|
2494
|
+
.map((e) => {
|
|
2495
|
+
const id = inventoryKey(e?.id || e);
|
|
2496
|
+
if (!id) return null;
|
|
2497
|
+
const mtime =
|
|
2498
|
+
typeof e?.modifiedAt === "string"
|
|
2499
|
+
? e.modifiedAt
|
|
2500
|
+
: typeof e?.mtimeMs === "number"
|
|
2501
|
+
? String(e.mtimeMs)
|
|
2502
|
+
: "";
|
|
2503
|
+
return { id, fingerprint: mtime };
|
|
2504
|
+
})
|
|
2505
|
+
.filter(Boolean),
|
|
2506
|
+
decisions: decisionEntries
|
|
2507
|
+
.map((e) => {
|
|
2508
|
+
const id = inventoryKey(e?.id || e);
|
|
2509
|
+
if (!id) return null;
|
|
2510
|
+
const mtime =
|
|
2511
|
+
typeof e?.modifiedAt === "string"
|
|
2512
|
+
? e.modifiedAt
|
|
2513
|
+
: typeof e?.mtimeMs === "number"
|
|
2514
|
+
? String(e.mtimeMs)
|
|
2515
|
+
: "";
|
|
2516
|
+
return { id, fingerprint: mtime };
|
|
2517
|
+
})
|
|
2518
|
+
.filter(Boolean),
|
|
2519
|
+
},
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
/**
|
|
2524
|
+
* Emit durable inventory semantic events (agents/skills/commands/memory).
|
|
2525
|
+
* Requires a previous baseline; cold start (null/undefined) emits nothing so
|
|
2526
|
+
* the first snapshot does not flood "added" for the whole tree.
|
|
2527
|
+
*
|
|
2528
|
+
* Ids: `agent:added:<name>`, `skill:removed:<name>`, `memory:error:changed:<file>`, …
|
|
2529
|
+
* Kinds stay off the Monitor allowlist (`MONITOR_ACTIVITY_KINDS`).
|
|
2530
|
+
*
|
|
2531
|
+
* @param {{ agents?: object[], skills?: object[], commands?: object[], memory?: object|null }} current
|
|
2532
|
+
* @param {object|null|undefined} previous - prior `buildInventoryBaseline` result
|
|
2533
|
+
* @param {{ limit?: number }} [opts]
|
|
2534
|
+
*/
|
|
2535
|
+
export function formatInventoryActivity(
|
|
2536
|
+
current,
|
|
2537
|
+
previous,
|
|
2538
|
+
{ limit = MAX_INVENTORY_ACTIVITY } = {},
|
|
2539
|
+
) {
|
|
2540
|
+
if (!previous || typeof previous !== "object") return [];
|
|
2541
|
+
|
|
2542
|
+
const cur = buildInventoryBaseline(current || {});
|
|
2543
|
+
const prevAgents = inventoryFingerprintMap(previous.agents, (a) => ({
|
|
2544
|
+
id: a?.id,
|
|
2545
|
+
fingerprint: a?.fingerprint,
|
|
2546
|
+
}));
|
|
2547
|
+
const curAgents = inventoryFingerprintMap(cur.agents, (a) => ({
|
|
2548
|
+
id: a?.id,
|
|
2549
|
+
fingerprint: a?.fingerprint,
|
|
2550
|
+
}));
|
|
2551
|
+
const prevSkills = inventoryFingerprintMap(previous.skills, (a) => ({
|
|
2552
|
+
id: a?.id,
|
|
2553
|
+
fingerprint: a?.fingerprint,
|
|
2554
|
+
}));
|
|
2555
|
+
const curSkills = inventoryFingerprintMap(cur.skills, (a) => ({
|
|
2556
|
+
id: a?.id,
|
|
2557
|
+
fingerprint: a?.fingerprint,
|
|
2558
|
+
}));
|
|
2559
|
+
const prevCommands = inventoryFingerprintMap(previous.commands, (a) => ({
|
|
2560
|
+
id: a?.id,
|
|
2561
|
+
fingerprint: a?.fingerprint,
|
|
2562
|
+
}));
|
|
2563
|
+
const curCommands = inventoryFingerprintMap(cur.commands, (a) => ({
|
|
2564
|
+
id: a?.id,
|
|
2565
|
+
fingerprint: a?.fingerprint,
|
|
2566
|
+
}));
|
|
2567
|
+
const prevErrors = inventoryFingerprintMap(previous.memory?.errors, (a) => ({
|
|
2568
|
+
id: a?.id,
|
|
2569
|
+
fingerprint: a?.fingerprint,
|
|
2570
|
+
}));
|
|
2571
|
+
const curErrors = inventoryFingerprintMap(cur.memory?.errors, (a) => ({
|
|
2572
|
+
id: a?.id,
|
|
2573
|
+
fingerprint: a?.fingerprint,
|
|
2574
|
+
}));
|
|
2575
|
+
const prevDecisions = inventoryFingerprintMap(previous.memory?.decisions, (a) => ({
|
|
2576
|
+
id: a?.id,
|
|
2577
|
+
fingerprint: a?.fingerprint,
|
|
2578
|
+
}));
|
|
2579
|
+
const curDecisions = inventoryFingerprintMap(cur.memory?.decisions, (a) => ({
|
|
2580
|
+
id: a?.id,
|
|
2581
|
+
fingerprint: a?.fingerprint,
|
|
2582
|
+
}));
|
|
2583
|
+
|
|
2584
|
+
/** @type {object[]} */
|
|
2585
|
+
const events = [];
|
|
2586
|
+
if (
|
|
2587
|
+
!diffInventoryMaps(events, {
|
|
2588
|
+
kind: "agent",
|
|
2589
|
+
source: "agents",
|
|
2590
|
+
previous: prevAgents,
|
|
2591
|
+
current: curAgents,
|
|
2592
|
+
limit,
|
|
2593
|
+
})
|
|
2594
|
+
) {
|
|
2595
|
+
return events;
|
|
2596
|
+
}
|
|
2597
|
+
if (
|
|
2598
|
+
!diffInventoryMaps(events, {
|
|
2599
|
+
kind: "skill",
|
|
2600
|
+
source: "skills",
|
|
2601
|
+
previous: prevSkills,
|
|
2602
|
+
current: curSkills,
|
|
2603
|
+
limit,
|
|
2604
|
+
})
|
|
2605
|
+
) {
|
|
2606
|
+
return events;
|
|
2607
|
+
}
|
|
2608
|
+
if (
|
|
2609
|
+
!diffInventoryMaps(events, {
|
|
2610
|
+
kind: "command",
|
|
2611
|
+
source: "commands",
|
|
2612
|
+
previous: prevCommands,
|
|
2613
|
+
current: curCommands,
|
|
2614
|
+
limit,
|
|
2615
|
+
})
|
|
2616
|
+
) {
|
|
2617
|
+
return events;
|
|
2618
|
+
}
|
|
2619
|
+
// Memory errors + decisions share kind `memory`; namespace ids to avoid collisions.
|
|
2620
|
+
if (
|
|
2621
|
+
!diffInventoryMaps(events, {
|
|
2622
|
+
kind: "memory",
|
|
2623
|
+
source: "memory",
|
|
2624
|
+
previous: prevErrors,
|
|
2625
|
+
current: curErrors,
|
|
2626
|
+
limit,
|
|
2627
|
+
idNamespace: "error",
|
|
2628
|
+
})
|
|
2629
|
+
) {
|
|
2630
|
+
return events;
|
|
2631
|
+
}
|
|
2632
|
+
diffInventoryMaps(events, {
|
|
2633
|
+
kind: "memory",
|
|
2634
|
+
source: "memory",
|
|
2635
|
+
previous: prevDecisions,
|
|
2636
|
+
current: curDecisions,
|
|
2637
|
+
limit,
|
|
2638
|
+
idNamespace: "decision",
|
|
2639
|
+
});
|
|
2640
|
+
return events;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/**
|
|
2644
|
+
* Action contract for Field Report and Checklist rows.
|
|
2645
|
+
*
|
|
2646
|
+
* The panel is read-only: it copies and a human pastes. `path` targets are
|
|
2647
|
+
* copied for the file picker; `copy` targets carry their own `subject` and
|
|
2648
|
+
* `pasteDestination`. No action type opens anything.
|
|
2649
|
+
* @param {'path'|'copy'} type
|
|
2650
|
+
*/
|
|
2651
|
+
function attentionAction(type, target, label) {
|
|
2652
|
+
return { type, target, label };
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/**
|
|
2656
|
+
* Build the Field Report attention stack: agent prompts + readiness + External
|
|
2657
|
+
* reviews + optional activity cadence warning (heads-up inbox). Plan-state
|
|
2658
|
+
* kinds and the HANDOFF awaiting-user gate do not belong here.
|
|
2659
|
+
* `buildChecklistNotes` emits readiness advisories only.
|
|
2660
|
+
*/
|
|
2661
|
+
export function buildAttentionItems({
|
|
2662
|
+
plans,
|
|
2663
|
+
handoff,
|
|
2664
|
+
agentPrompts = [],
|
|
2665
|
+
externalReports = [],
|
|
2666
|
+
dismissedIds = [],
|
|
2667
|
+
archivedPlanFiles = [],
|
|
2668
|
+
readinessPending = [],
|
|
2669
|
+
deferredCheckIds = [],
|
|
2670
|
+
cadenceLedger = null,
|
|
2671
|
+
cadenceConfig = null,
|
|
2672
|
+
limit = MAX_ATTENTION + MAX_CHECKLIST_NOTES,
|
|
2673
|
+
}) {
|
|
2674
|
+
const dismissed = new Set(
|
|
2675
|
+
(dismissedIds || []).filter((id) => typeof id === "string" && id.length > 0),
|
|
2676
|
+
);
|
|
2677
|
+
const items = [];
|
|
2678
|
+
const cadenceOpts = parseFieldReportReviewCadenceConfig(
|
|
2679
|
+
cadenceConfig ? { fieldReportReviewCadence: cadenceConfig } : null,
|
|
2680
|
+
);
|
|
2681
|
+
|
|
2682
|
+
// Agent prompts lead the payload; the panel reorders by severity / report group.
|
|
2683
|
+
for (const prompt of buildAgentPromptItems(agentPrompts, { plans, handoff })) {
|
|
2684
|
+
if (dismissed.has(prompt.id)) continue;
|
|
2685
|
+
if (items.length >= limit) break;
|
|
2686
|
+
items.push(prompt);
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
for (const note of buildChecklistNotes({
|
|
2690
|
+
plans,
|
|
2691
|
+
handoff,
|
|
2692
|
+
readinessPending,
|
|
2693
|
+
deferredCheckIds,
|
|
2694
|
+
limit: MAX_CHECKLIST_NOTES,
|
|
2695
|
+
})) {
|
|
2696
|
+
if (dismissed.has(note.id)) continue;
|
|
2697
|
+
if (items.length >= limit) break;
|
|
2698
|
+
items.push(note);
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
const unreviewed = listUnreviewedReviewTargets(
|
|
2702
|
+
plans,
|
|
2703
|
+
handoff,
|
|
2704
|
+
externalReports,
|
|
2705
|
+
archivedPlanFiles,
|
|
2706
|
+
);
|
|
2707
|
+
const cadenceItem = buildCadenceAttentionItem(cadenceLedger, unreviewed, {
|
|
2708
|
+
enabled: cadenceOpts.enabled,
|
|
2709
|
+
dismissed,
|
|
2710
|
+
});
|
|
2711
|
+
if (cadenceItem && items.length < limit) {
|
|
2712
|
+
items.push(cadenceItem);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// Strong triage clears a report; terminal reviewed-plan lifecycle classifies
|
|
2716
|
+
// it into the review-debt group (still shown). Dismissals then filter by id.
|
|
2717
|
+
for (const report of buildExternalReportItems(externalReports, plans, {
|
|
2718
|
+
handoff,
|
|
2719
|
+
archivedPlanFiles,
|
|
2720
|
+
})) {
|
|
2721
|
+
if (dismissed.has(report.id)) continue;
|
|
2722
|
+
if (items.length >= limit) break;
|
|
2723
|
+
items.push(report);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
return items.slice(0, limit);
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
/**
|
|
2730
|
+
* Emit readiness advisories for the Field Report attention stack. Plan-state
|
|
2731
|
+
* NOTE kinds (backlog / parked / incomplete) stay on Checklist plan cards and
|
|
2732
|
+
* must not emit here. `plans` / `handoff` remain in the signature for callers
|
|
2733
|
+
* that still pass them; they are unused for note emission.
|
|
2734
|
+
*/
|
|
2735
|
+
export function buildChecklistNotes({
|
|
2736
|
+
plans: _plans,
|
|
2737
|
+
handoff: _handoff,
|
|
2738
|
+
readinessPending = [],
|
|
2739
|
+
deferredCheckIds = [],
|
|
2740
|
+
limit = MAX_CHECKLIST_NOTES,
|
|
2741
|
+
}) {
|
|
2742
|
+
const items = [];
|
|
2743
|
+
const deferredIds = deferredCheckIdSet(deferredCheckIds);
|
|
2744
|
+
for (const pending of readinessPending || []) {
|
|
2745
|
+
if (items.length >= limit) break;
|
|
2746
|
+
if (!pending || pending.essential === true) continue;
|
|
2747
|
+
if (pending.status === "ready") continue;
|
|
2748
|
+
if (isDeferredReadinessPending(pending, deferredIds)) continue;
|
|
2749
|
+
const id = pending.id || pending.checkId || "readiness";
|
|
2750
|
+
items.push({
|
|
2751
|
+
id: `attention:readiness:${id}`,
|
|
2752
|
+
kind: "readiness",
|
|
2753
|
+
severity: "info",
|
|
2754
|
+
label: truncateStr(
|
|
2755
|
+
pending.label ||
|
|
2756
|
+
pending.title ||
|
|
2757
|
+
`Non-essential readiness: ${id} (${pending.status || "pending"})`,
|
|
2758
|
+
MAX_SEMANTIC_LABEL,
|
|
2759
|
+
),
|
|
2760
|
+
sourcePath: ".cursor/context/readiness.json",
|
|
2761
|
+
modifiedAt: null,
|
|
2762
|
+
progress: null,
|
|
2763
|
+
action: {
|
|
2764
|
+
type: "copy",
|
|
2765
|
+
target: "/agent-kit-onboard",
|
|
2766
|
+
label: "Copy /agent-kit-onboard (non-essential)",
|
|
2767
|
+
subject: "/agent-kit-onboard",
|
|
2768
|
+
pasteDestination: "chatInput",
|
|
2769
|
+
},
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
return items.slice(0, limit);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
/**
|
|
2777
|
+
* Enrich plan records with lifecycle classification (non-mutating copy).
|
|
2778
|
+
*/
|
|
2779
|
+
export function enrichPlans(plans, handoff) {
|
|
2780
|
+
const runQueueView = buildRunQueueView(handoff);
|
|
2781
|
+
return (plans || []).map((plan) => {
|
|
2782
|
+
const stats = todoStats(plan);
|
|
2783
|
+
const parked = isParkedPlan(plan, handoff);
|
|
2784
|
+
const backlog = isBacklogPlan(plan, handoff);
|
|
2785
|
+
const queueIdx = runQueueView ? runQueueView.queue.indexOf(planFileKey(plan)) : -1;
|
|
2786
|
+
return {
|
|
2787
|
+
id: plan.id,
|
|
2788
|
+
file: plan.file,
|
|
2789
|
+
path: plan.path,
|
|
2790
|
+
overview: truncateStr(plan.overview || "", MAX_SEMANTIC_LABEL),
|
|
2791
|
+
modifiedAt: plan.modifiedAt || null,
|
|
2792
|
+
progress: {
|
|
2793
|
+
completed: stats.completed,
|
|
2794
|
+
total: stats.total,
|
|
2795
|
+
label: `${stats.completed} of ${stats.total}`,
|
|
2796
|
+
},
|
|
2797
|
+
lifecycle: classifyPlan(plan, handoff),
|
|
2798
|
+
// Preserved when lifecycle is completed so UI/sort can still know provenance.
|
|
2799
|
+
parked,
|
|
2800
|
+
backlog,
|
|
2801
|
+
// /run-plan-all layer: role + queue position (null outside queue mode).
|
|
2802
|
+
// Additive to lifecycle; the executing shimmer keys on lifecycle only.
|
|
2803
|
+
queueRole: planQueueRole(plan, runQueueView),
|
|
2804
|
+
queueIndex: queueIdx >= 0 ? queueIdx : null,
|
|
2805
|
+
currentTodo: compactTodo(pickCurrentTodo(plan, handoff)),
|
|
2806
|
+
nextTodo: compactTodo(pickNextTodo(plan, pickCurrentTodo(plan, handoff))),
|
|
2807
|
+
};
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
/**
|
|
2812
|
+
* Allowlisted readiness pending actions for attention (no nested scan dump).
|
|
2813
|
+
* Preserves `checkId` so Checklist can match `onboarding.deferredItems.checkId`
|
|
2814
|
+
* (pillar check id, e.g. collaboration.provider) against action ids (confirm-provider).
|
|
2815
|
+
*/
|
|
2816
|
+
export function allowlistReadinessPending(rawPending) {
|
|
2817
|
+
if (!Array.isArray(rawPending)) return [];
|
|
2818
|
+
return rawPending.slice(0, MAX_ATTENTION).map((item) => {
|
|
2819
|
+
const out = {
|
|
2820
|
+
id: typeof item?.id === "string" ? item.id : "unknown",
|
|
2821
|
+
status: typeof item?.status === "string" ? item.status : "unknown",
|
|
2822
|
+
essential: item?.essential === true,
|
|
2823
|
+
};
|
|
2824
|
+
if (typeof item?.checkId === "string" && item.checkId) {
|
|
2825
|
+
out.checkId = item.checkId;
|
|
2826
|
+
}
|
|
2827
|
+
const title =
|
|
2828
|
+
typeof item?.title === "string"
|
|
2829
|
+
? truncateStr(item.title, 120)
|
|
2830
|
+
: typeof item?.label === "string"
|
|
2831
|
+
? truncateStr(item.label, 120)
|
|
2832
|
+
: undefined;
|
|
2833
|
+
if (title !== undefined) out.title = title;
|
|
2834
|
+
return out;
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
/**
|
|
2839
|
+
* Derive Checklist readiness rows from a doctor report.
|
|
2840
|
+
* Prefers pillars so each action carries `checkId` + `essential` (pendingActions alone lack both).
|
|
2841
|
+
* Falls back to `pendingActions` when pillars are absent.
|
|
2842
|
+
* @param {unknown} readiness
|
|
2843
|
+
* @returns {{ id: string, checkId?: string, status: string, essential: boolean, title?: string }[]}
|
|
2844
|
+
*/
|
|
2845
|
+
export function collectReadinessPendingFromReport(readiness) {
|
|
2846
|
+
if (!readiness || typeof readiness !== "object") return [];
|
|
2847
|
+
const fromPillars = [];
|
|
2848
|
+
for (const pillar of readiness.pillars || []) {
|
|
2849
|
+
if (!pillar || typeof pillar !== "object") continue;
|
|
2850
|
+
for (const check of pillar.checks || []) {
|
|
2851
|
+
if (!check || typeof check !== "object") continue;
|
|
2852
|
+
if (check.status === "ready") continue;
|
|
2853
|
+
const actions = Array.isArray(check.actions) ? check.actions : [];
|
|
2854
|
+
for (const action of actions) {
|
|
2855
|
+
if (!action || typeof action !== "object") continue;
|
|
2856
|
+
if (action.status === "ready") continue;
|
|
2857
|
+
fromPillars.push({
|
|
2858
|
+
id: typeof action.id === "string" ? action.id : "unknown",
|
|
2859
|
+
checkId: typeof check.id === "string" ? check.id : undefined,
|
|
2860
|
+
status:
|
|
2861
|
+
typeof action.status === "string"
|
|
2862
|
+
? action.status
|
|
2863
|
+
: typeof check.status === "string"
|
|
2864
|
+
? check.status
|
|
2865
|
+
: "unknown",
|
|
2866
|
+
essential: check.essential === true,
|
|
2867
|
+
title: typeof check.title === "string" ? check.title : undefined,
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
if (fromPillars.length > 0) return fromPillars;
|
|
2873
|
+
if (!Array.isArray(readiness.pendingActions)) return [];
|
|
2874
|
+
return readiness.pendingActions.map((item) => ({
|
|
2875
|
+
id: typeof item?.id === "string" ? item.id : "unknown",
|
|
2876
|
+
checkId: typeof item?.checkId === "string" ? item.checkId : undefined,
|
|
2877
|
+
status: typeof item?.status === "string" ? item.status : "unknown",
|
|
2878
|
+
essential: item?.essential === true,
|
|
2879
|
+
title:
|
|
2880
|
+
typeof item?.title === "string"
|
|
2881
|
+
? item.title
|
|
2882
|
+
: typeof item?.recommendation === "string"
|
|
2883
|
+
? item.recommendation
|
|
2884
|
+
: undefined,
|
|
2885
|
+
}));
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
/**
|
|
2889
|
+
* Extract deferred check ids from config onboarding.deferredItems.
|
|
2890
|
+
* Requires a non-empty reason (same rule as doctor onboarding reconcile).
|
|
2891
|
+
* @param {unknown} rawConfig
|
|
2892
|
+
* @returns {string[]}
|
|
2893
|
+
*/
|
|
2894
|
+
export function collectDeferredCheckIds(rawConfig) {
|
|
2895
|
+
if (!rawConfig || typeof rawConfig !== "object") return [];
|
|
2896
|
+
const onboarding = rawConfig.onboarding;
|
|
2897
|
+
if (!onboarding || typeof onboarding !== "object") return [];
|
|
2898
|
+
if (!Array.isArray(onboarding.deferredItems)) return [];
|
|
2899
|
+
const ids = [];
|
|
2900
|
+
for (const item of onboarding.deferredItems) {
|
|
2901
|
+
if (!item || typeof item !== "object") continue;
|
|
2902
|
+
if (typeof item.checkId !== "string" || !item.checkId.trim()) continue;
|
|
2903
|
+
if (typeof item.reason !== "string" || !item.reason.trim()) continue;
|
|
2904
|
+
ids.push(item.checkId.trim());
|
|
2905
|
+
}
|
|
2906
|
+
return ids;
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/** @param {unknown} deferredCheckIds */
|
|
2910
|
+
function deferredCheckIdSet(deferredCheckIds) {
|
|
2911
|
+
const set = new Set();
|
|
2912
|
+
if (!Array.isArray(deferredCheckIds)) return set;
|
|
2913
|
+
for (const id of deferredCheckIds) {
|
|
2914
|
+
if (typeof id === "string" && id.trim()) set.add(id.trim());
|
|
2915
|
+
}
|
|
2916
|
+
return set;
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
/**
|
|
2920
|
+
* True when a pending readiness row is explicitly deferred.
|
|
2921
|
+
* Matches deferredItems.checkId against action id or pillar checkId
|
|
2922
|
+
* (confirm-provider ↔ collaboration.provider).
|
|
2923
|
+
* @param {{ id?: string, checkId?: string }} pending
|
|
2924
|
+
* @param {Set<string>} deferredIds
|
|
2925
|
+
*/
|
|
2926
|
+
export function isDeferredReadinessPending(pending, deferredIds) {
|
|
2927
|
+
if (!pending || !(deferredIds instanceof Set) || deferredIds.size === 0) return false;
|
|
2928
|
+
if (typeof pending.id === "string" && deferredIds.has(pending.id)) return true;
|
|
2929
|
+
if (typeof pending.checkId === "string" && deferredIds.has(pending.checkId)) return true;
|
|
2930
|
+
return false;
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
/**
|
|
2934
|
+
* Agent-prompt detection contract (pure half).
|
|
2935
|
+
*
|
|
2936
|
+
* A transcript is one JSON object per line. Conversation entries carry a
|
|
2937
|
+
* `role` of `user` or `assistant`; a trailing turn marker object has no
|
|
2938
|
+
* `role` and is ignored. An assistant entry counts as a QUESTION when its
|
|
2939
|
+
* `message.content` holds a `tool_use` whose `name` matches
|
|
2940
|
+
* `AGENT_QUESTION_TOOL_RE` (the AskQuestion family). An ANSWER is any later
|
|
2941
|
+
* entry with `role === 'user'`. A transcript is "awaiting a reply" only when it
|
|
2942
|
+
* contains at least one question and no user entry follows the last one. The
|
|
2943
|
+
* assistant speaking last carries no signal (almost every transcript ends on an
|
|
2944
|
+
* assistant entry) and is deliberately not used. The fs half (directory
|
|
2945
|
+
* location, file cap, recency window) lives in `dashboard-data.mjs`.
|
|
2946
|
+
*/
|
|
2947
|
+
export function isAgentQuestionEntry(entry) {
|
|
2948
|
+
if (!entry || entry.role !== "assistant") return false;
|
|
2949
|
+
const content = entry.message?.content;
|
|
2950
|
+
if (!Array.isArray(content)) return false;
|
|
2951
|
+
return content.some(
|
|
2952
|
+
(c) => c && c.type === "tool_use" && AGENT_QUESTION_TOOL_RE.test(String(c.name || "")),
|
|
2953
|
+
);
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
export function isUserEntry(entry) {
|
|
2957
|
+
return !!entry && entry.role === "user";
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/**
|
|
2961
|
+
* Collapse whitespace in derived Field Report labels.
|
|
2962
|
+
*
|
|
2963
|
+
* AskQuestion prompts often carry blank lines and markdown breaks. Those must
|
|
2964
|
+
* not reach the panel as multi-line labels (they truncate mid-paragraph and
|
|
2965
|
+
* break the single-line attention row).
|
|
2966
|
+
*/
|
|
2967
|
+
export function collapseAttentionLabel(text) {
|
|
2968
|
+
if (typeof text !== "string") return "";
|
|
2969
|
+
return text.replace(/\s+/g, " ").trim();
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
/**
|
|
2973
|
+
* Turn raw first-user transcript text into a short chat identifier.
|
|
2974
|
+
* Prefers `<user_query>` body; otherwise strips Cursor wrapper blocks and
|
|
2975
|
+
* falls back to a slash-command name when the message is command-only.
|
|
2976
|
+
*/
|
|
2977
|
+
export function normalizeUserTextToSnippet(text) {
|
|
2978
|
+
let t = String(text || "");
|
|
2979
|
+
const query = t.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i);
|
|
2980
|
+
if (query) {
|
|
2981
|
+
t = query[1];
|
|
2982
|
+
} else {
|
|
2983
|
+
const cmdMatch = t.match(/---\s*Cursor Command:\s*([^\n]+?)\s*---/i);
|
|
2984
|
+
const commandName = cmdMatch ? collapseAttentionLabel(cmdMatch[1]) : "";
|
|
2985
|
+
t = t
|
|
2986
|
+
.replace(/<timestamp>[\s\S]*?<\/timestamp>/gi, " ")
|
|
2987
|
+
.replace(/<cursor_commands>[\s\S]*?<\/cursor_commands>/gi, " ")
|
|
2988
|
+
.replace(/<external_links>[\s\S]*?<\/external_links>/gi, " ")
|
|
2989
|
+
.replace(/<\/?[a-z_:-]+>/gi, " ");
|
|
2990
|
+
t = collapseAttentionLabel(t);
|
|
2991
|
+
if (!t && commandName) {
|
|
2992
|
+
const slash = commandName.startsWith("/") ? commandName : `/${commandName}`;
|
|
2993
|
+
return truncateStr(slash, MAX_CHAT_SNIPPET);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
t = collapseAttentionLabel(t);
|
|
2997
|
+
if (!t) return null;
|
|
2998
|
+
return truncateStr(t, MAX_CHAT_SNIPPET);
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
/**
|
|
3002
|
+
* First identifying snippet from a transcript (earliest usable user text).
|
|
3003
|
+
* @param {object[]} entries
|
|
3004
|
+
* @returns {string|null}
|
|
3005
|
+
*/
|
|
3006
|
+
export function extractChatSnippet(entries) {
|
|
3007
|
+
if (!Array.isArray(entries)) return null;
|
|
3008
|
+
for (const e of entries) {
|
|
3009
|
+
if (!isUserEntry(e)) continue;
|
|
3010
|
+
const content = e.message?.content;
|
|
3011
|
+
if (!Array.isArray(content)) continue;
|
|
3012
|
+
for (const c of content) {
|
|
3013
|
+
if (!c || c.type !== "text" || typeof c.text !== "string") continue;
|
|
3014
|
+
const snippet = normalizeUserTextToSnippet(c.text);
|
|
3015
|
+
if (snippet) return snippet;
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
return null;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
/**
|
|
3022
|
+
* Clipboard payload for the past-chat picker: bare chat id only.
|
|
3023
|
+
* Identifying context (snippet, question, time) stays on the Field Report row
|
|
3024
|
+
* and in the copy subject/guidance so a paste into the picker stays reliable.
|
|
3025
|
+
*/
|
|
3026
|
+
export function formatChatReferencePayload(chatId) {
|
|
3027
|
+
if (typeof chatId !== "string") return "";
|
|
3028
|
+
return chatId.trim();
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
/** Toast/tooltip subject that names which chat id was copied. */
|
|
3032
|
+
export function formatChatReferenceSubject(chatId, chatSnippet) {
|
|
3033
|
+
const id = typeof chatId === "string" ? chatId.trim() : "";
|
|
3034
|
+
const short = id ? id.slice(0, 8) : "";
|
|
3035
|
+
const snip = collapseAttentionLabel(chatSnippet);
|
|
3036
|
+
if (short && snip) {
|
|
3037
|
+
return `chat id ${short} (${truncateStr(snip, 48)})`;
|
|
3038
|
+
}
|
|
3039
|
+
if (short) return `chat id ${short}`;
|
|
3040
|
+
return "chat id";
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
/**
|
|
3044
|
+
* Untruncated collapsed question text from the question tool_use itself.
|
|
3045
|
+
* This is the detection value: lifecycle clear parses plan refs from it before
|
|
3046
|
+
* any display truncation, so a terminal ref before the 200-char cutoff cannot
|
|
3047
|
+
* clear a row whose active ref sits past the cutoff (FR-SAC-01).
|
|
3048
|
+
*/
|
|
3049
|
+
export function extractQuestionText(entry) {
|
|
3050
|
+
const content = entry?.message?.content;
|
|
3051
|
+
if (!Array.isArray(content)) return null;
|
|
3052
|
+
for (const c of content) {
|
|
3053
|
+
if (!c || c.type !== "tool_use" || !AGENT_QUESTION_TOOL_RE.test(String(c.name || ""))) {
|
|
3054
|
+
continue;
|
|
3055
|
+
}
|
|
3056
|
+
const questions = c.input?.questions;
|
|
3057
|
+
if (Array.isArray(questions)) {
|
|
3058
|
+
for (const q of questions) {
|
|
3059
|
+
const prompt = collapseAttentionLabel(q?.prompt || q?.question || q?.text);
|
|
3060
|
+
if (prompt) return prompt;
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
const single = collapseAttentionLabel(c.input?.prompt || c.input?.question);
|
|
3064
|
+
if (single) return single;
|
|
3065
|
+
}
|
|
3066
|
+
return null;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
/** Human-readable label for display; truncated to the panel label bound. */
|
|
3070
|
+
export function extractQuestionLabel(entry) {
|
|
3071
|
+
const text = extractQuestionText(entry);
|
|
3072
|
+
return text ? truncateStr(text, MAX_SEMANTIC_LABEL) : null;
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
/**
|
|
3076
|
+
* Scan parsed transcript entries for an unanswered agent question.
|
|
3077
|
+
* @param {object[]} entries - parsed JSONL objects, in file order
|
|
3078
|
+
* @returns {{ label: string|null }|null} match when awaiting a reply, else null
|
|
3079
|
+
*/
|
|
3080
|
+
export function detectAwaitingPrompt(entries) {
|
|
3081
|
+
if (!Array.isArray(entries)) return null;
|
|
3082
|
+
let lastQuestionIdx = -1;
|
|
3083
|
+
let lastUserIdx = -1;
|
|
3084
|
+
let lastQuestionEntry = null;
|
|
3085
|
+
for (let i = 0; i < entries.length; i++) {
|
|
3086
|
+
const e = entries[i];
|
|
3087
|
+
if (!e || typeof e !== "object") continue;
|
|
3088
|
+
if (isUserEntry(e)) lastUserIdx = i;
|
|
3089
|
+
if (isAgentQuestionEntry(e)) {
|
|
3090
|
+
lastQuestionIdx = i;
|
|
3091
|
+
lastQuestionEntry = e;
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
if (lastQuestionIdx < 0) return null;
|
|
3095
|
+
if (lastUserIdx > lastQuestionIdx) return null;
|
|
3096
|
+
const labelFull = extractQuestionText(lastQuestionEntry);
|
|
3097
|
+
return {
|
|
3098
|
+
label: labelFull ? truncateStr(labelFull, MAX_SEMANTIC_LABEL) : null,
|
|
3099
|
+
// Untruncated detection value for lifecycle clear (FR-SAC-01).
|
|
3100
|
+
labelFull,
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
/**
|
|
3105
|
+
* Map detected prompts into attention-item shape. Each item copies the bare
|
|
3106
|
+
* chat id for the past-chat picker; it does not open a chat. Row fields carry
|
|
3107
|
+
* snippet, timestamp, and pending question so the human can identify the chat.
|
|
3108
|
+
* Drops prompts whose pending label names only terminal plans (lifecycle clear).
|
|
3109
|
+
* @param {{chatId:string,label?:string,chatSnippet?:string,quietAt?:string}[]} prompts
|
|
3110
|
+
*/
|
|
3111
|
+
export function buildAgentPromptItems(
|
|
3112
|
+
prompts,
|
|
3113
|
+
{ limit = MAX_AGENT_PROMPTS, plans = [], handoff = null } = {},
|
|
3114
|
+
) {
|
|
3115
|
+
const items = [];
|
|
3116
|
+
for (const p of prompts || []) {
|
|
3117
|
+
if (items.length >= limit) break;
|
|
3118
|
+
if (!p || !p.chatId) continue;
|
|
3119
|
+
const label = collapseAttentionLabel(p.label) || "Agent question awaiting a reply";
|
|
3120
|
+
// Parse plan refs from the untruncated text so a ref past the display cutoff
|
|
3121
|
+
// still counts toward the all-references-terminal rule (FR-SAC-01).
|
|
3122
|
+
const lifecycleText = collapseAttentionLabel(p.labelFull) || label;
|
|
3123
|
+
if (isPromptClearedByPlanLifecycle(lifecycleText, plans, handoff)) continue;
|
|
3124
|
+
const rawSnippet = collapseAttentionLabel(p.chatSnippet);
|
|
3125
|
+
const snippet = rawSnippet ? truncateStr(rawSnippet, MAX_CHAT_SNIPPET) : null;
|
|
3126
|
+
const payload = formatChatReferencePayload(p.chatId);
|
|
3127
|
+
if (!payload) continue;
|
|
3128
|
+
items.push(
|
|
3129
|
+
withResolveAction({
|
|
3130
|
+
id: `attention:prompt:${p.chatId}`,
|
|
3131
|
+
kind: "prompt",
|
|
3132
|
+
severity: "action",
|
|
3133
|
+
label: truncateStr(label, MAX_SEMANTIC_LABEL),
|
|
3134
|
+
chatSnippet: snippet,
|
|
3135
|
+
sourcePath: null,
|
|
3136
|
+
chatId: p.chatId,
|
|
3137
|
+
modifiedAt: p.quietAt || null,
|
|
3138
|
+
progress: null,
|
|
3139
|
+
action: {
|
|
3140
|
+
type: "copy",
|
|
3141
|
+
target: payload,
|
|
3142
|
+
label: "Copy chat id",
|
|
3143
|
+
subject: formatChatReferenceSubject(p.chatId, snippet),
|
|
3144
|
+
pasteDestination: "pastChatPicker",
|
|
3145
|
+
},
|
|
3146
|
+
}),
|
|
3147
|
+
);
|
|
3148
|
+
}
|
|
3149
|
+
return items;
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
/** Compare slugs across the `-` / `_` split between report and plan names. */
|
|
3153
|
+
function normalizeSlug(value) {
|
|
3154
|
+
return String(value || "")
|
|
3155
|
+
.toLowerCase()
|
|
3156
|
+
.replace(/\.plan\.md$/, "")
|
|
3157
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
3158
|
+
.replace(/^-|-$/g, "");
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
/**
|
|
3162
|
+
* Empty-state string for a report that carries no structured findings section.
|
|
3163
|
+
* The row still renders; extraction is best-effort against the plan-monitor
|
|
3164
|
+
* template (2026-07-25_mission-control-field-report-source-contract.md,
|
|
3165
|
+
* "Findings summary extraction").
|
|
3166
|
+
*/
|
|
3167
|
+
export const FINDINGS_SUMMARY_EMPTY = "No structured findings extracted";
|
|
3168
|
+
|
|
3169
|
+
/** Char cap for the derived findings summary before an ellipsis truncation. */
|
|
3170
|
+
export const MAX_FINDINGS_SUMMARY = 240;
|
|
3171
|
+
|
|
3172
|
+
/** Residual/standing/full-review items taken into the summary, at most. */
|
|
3173
|
+
const MAX_FINDINGS_BULLETS = 3;
|
|
3174
|
+
|
|
3175
|
+
// Heading prefix matchers. Real monitors append parentheticals (for example
|
|
3176
|
+
// `### Residual items for human attention (none are severe; ...)`) and
|
|
3177
|
+
// pluralize the standing heading (`## Standing findings — not owned ...`), so
|
|
3178
|
+
// match on the leading text only. Tested per line, no global/multiline state.
|
|
3179
|
+
const RESIDUAL_HEADING_RE = /^#{2,6}\s+Residual items for human attention/i;
|
|
3180
|
+
const STANDING_HEADING_RE = /^#{2,6}\s+Standing finding/i;
|
|
3181
|
+
const FULL_REVIEW_HEADING_RE = /^#{2,6}\s+Full review/i;
|
|
3182
|
+
const ANY_HEADING_RE = /^#{2,6}\s/;
|
|
3183
|
+
const HR_RE = /^-{3,}\s*$/;
|
|
3184
|
+
|
|
3185
|
+
/** Strip inline markdown emphasis and collapse whitespace for display. */
|
|
3186
|
+
function cleanFindingsInline(value) {
|
|
3187
|
+
return String(value || "")
|
|
3188
|
+
.replace(/\*\*/g, "")
|
|
3189
|
+
.replace(/`/g, "")
|
|
3190
|
+
.replace(/\s+/g, " ")
|
|
3191
|
+
.trim();
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
/** Body lines under the first heading matching `headingRe`, until the next
|
|
3195
|
+
* heading or horizontal rule. Null when the heading is absent. */
|
|
3196
|
+
function findingsSectionLines(text, headingRe) {
|
|
3197
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
3198
|
+
let start = -1;
|
|
3199
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3200
|
+
if (headingRe.test(lines[i])) {
|
|
3201
|
+
start = i + 1;
|
|
3202
|
+
break;
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
if (start === -1) return null;
|
|
3206
|
+
const body = [];
|
|
3207
|
+
for (let i = start; i < lines.length; i++) {
|
|
3208
|
+
if (ANY_HEADING_RE.test(lines[i]) || HR_RE.test(lines[i])) break;
|
|
3209
|
+
body.push(lines[i]);
|
|
3210
|
+
}
|
|
3211
|
+
return body;
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
/** First blank-line-delimited paragraph in a section body, cleaned. */
|
|
3215
|
+
function findingsFirstParagraph(body) {
|
|
3216
|
+
const para = [];
|
|
3217
|
+
for (const raw of body || []) {
|
|
3218
|
+
if (raw.trim() === "") {
|
|
3219
|
+
if (para.length) break;
|
|
3220
|
+
continue;
|
|
3221
|
+
}
|
|
3222
|
+
para.push(raw.trim());
|
|
3223
|
+
}
|
|
3224
|
+
return para.length ? cleanFindingsInline(para.join(" ")) : null;
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/** Up to `MAX_FINDINGS_BULLETS` numbered residual items, cleaned. */
|
|
3228
|
+
function findingsResidualItems(text) {
|
|
3229
|
+
const body = findingsSectionLines(text, RESIDUAL_HEADING_RE);
|
|
3230
|
+
if (!body) return null;
|
|
3231
|
+
const items = [];
|
|
3232
|
+
for (const raw of body) {
|
|
3233
|
+
const match = /^\s*\d+\.\s+(.*\S)\s*$/.exec(raw);
|
|
3234
|
+
if (!match) continue;
|
|
3235
|
+
const cleaned = cleanFindingsInline(match[1]);
|
|
3236
|
+
if (cleaned) items.push(cleaned);
|
|
3237
|
+
if (items.length >= MAX_FINDINGS_BULLETS) break;
|
|
3238
|
+
}
|
|
3239
|
+
return items.length ? items : null;
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
/** Outcome line (or first paragraph) under the Full review heading. */
|
|
3243
|
+
function findingsOutcome(text) {
|
|
3244
|
+
const body = findingsSectionLines(text, FULL_REVIEW_HEADING_RE);
|
|
3245
|
+
if (!body) return null;
|
|
3246
|
+
for (const raw of body) {
|
|
3247
|
+
const line = cleanFindingsInline(raw);
|
|
3248
|
+
const match = /^Outcome:?\s*(.+)$/i.exec(line);
|
|
3249
|
+
if (match?.[1]) return match[1];
|
|
3250
|
+
}
|
|
3251
|
+
return findingsFirstParagraph(body);
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
/**
|
|
3255
|
+
* Derive a short, human-readable findings summary from monitor markdown.
|
|
3256
|
+
* Order: numbered Residual items, else the Standing finding paragraph, else
|
|
3257
|
+
* the Full-review Outcome, else the stable empty fallback. Always returns a
|
|
3258
|
+
* string; never null. The result is plain text and MUST be HTML-escaped by the
|
|
3259
|
+
* renderer, since it comes from untrusted report markdown.
|
|
3260
|
+
* @param {string} content - raw report markdown
|
|
3261
|
+
*/
|
|
3262
|
+
export function extractFindingsSummary(content) {
|
|
3263
|
+
const text = typeof content === "string" ? content : "";
|
|
3264
|
+
const residual = findingsResidualItems(text);
|
|
3265
|
+
let summary = null;
|
|
3266
|
+
if (residual) {
|
|
3267
|
+
summary = residual.join(" • ");
|
|
3268
|
+
} else {
|
|
3269
|
+
const standingBody = findingsSectionLines(text, STANDING_HEADING_RE);
|
|
3270
|
+
summary = findingsFirstParagraph(standingBody) || findingsOutcome(text);
|
|
3271
|
+
}
|
|
3272
|
+
if (!summary) return FINDINGS_SUMMARY_EMPTY;
|
|
3273
|
+
return truncateStr(summary, MAX_FINDINGS_SUMMARY);
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
const STILL_OPEN_HEADING_RE = /^#{2,6}\s+Still open/i;
|
|
3277
|
+
const NONE_ONLY_RE = /^(none\.?|—|-|n\/a|no\s+open\s+items?\.?)$/i;
|
|
3278
|
+
|
|
3279
|
+
/**
|
|
3280
|
+
* Whether monitor markdown still has open review gaps for Review-all targeting.
|
|
3281
|
+
* True when numbered Residual items, non-empty Still open rows, or a substantive
|
|
3282
|
+
* Standing finding exist. False for empty Still open / no residuals (clean
|
|
3283
|
+
* Outcome). Prefer include when structure is ambiguous (no Still open section
|
|
3284
|
+
* and no clear empty signal).
|
|
3285
|
+
* @param {string} content
|
|
3286
|
+
*/
|
|
3287
|
+
export function reportHasOpenReviewGaps(content) {
|
|
3288
|
+
const text = typeof content === "string" ? content : "";
|
|
3289
|
+
if (!text.trim()) return true;
|
|
3290
|
+
|
|
3291
|
+
const residuals = findingsResidualItems(text);
|
|
3292
|
+
if (residuals && residuals.length > 0) return true;
|
|
3293
|
+
|
|
3294
|
+
const standingBody = findingsSectionLines(text, STANDING_HEADING_RE);
|
|
3295
|
+
const standing = findingsFirstParagraph(standingBody);
|
|
3296
|
+
if (standing && !NONE_ONLY_RE.test(standing) && !/^no standing/i.test(standing)) {
|
|
3297
|
+
return true;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
const stillOpen = findingsSectionLines(text, STILL_OPEN_HEADING_RE);
|
|
3301
|
+
if (stillOpen) {
|
|
3302
|
+
const lines = stillOpen.map((l) => l.trim()).filter(Boolean);
|
|
3303
|
+
let hasDataRow = false;
|
|
3304
|
+
let sawNone = false;
|
|
3305
|
+
for (const line of lines) {
|
|
3306
|
+
if (/^\|\s*-+/.test(line)) continue;
|
|
3307
|
+
if (/^\|\s*ID\s*\|/i.test(line)) continue;
|
|
3308
|
+
if (NONE_ONLY_RE.test(line)) {
|
|
3309
|
+
sawNone = true;
|
|
3310
|
+
continue;
|
|
3311
|
+
}
|
|
3312
|
+
if (/^\|/.test(line)) {
|
|
3313
|
+
const cells = line
|
|
3314
|
+
.split("|")
|
|
3315
|
+
.map((c) => c.trim())
|
|
3316
|
+
.filter(Boolean);
|
|
3317
|
+
if (cells.length === 0) continue;
|
|
3318
|
+
if (cells.every((c) => NONE_ONLY_RE.test(c) || c === "")) {
|
|
3319
|
+
sawNone = true;
|
|
3320
|
+
continue;
|
|
3321
|
+
}
|
|
3322
|
+
hasDataRow = true;
|
|
3323
|
+
break;
|
|
3324
|
+
}
|
|
3325
|
+
// Non-table body (e.g. "None.")
|
|
3326
|
+
if (!NONE_ONLY_RE.test(line)) {
|
|
3327
|
+
hasDataRow = true;
|
|
3328
|
+
break;
|
|
3329
|
+
}
|
|
3330
|
+
sawNone = true;
|
|
3331
|
+
}
|
|
3332
|
+
if (hasDataRow) return true;
|
|
3333
|
+
if (sawNone || lines.length === 0) return false;
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// No Still open section: Outcome-only / empty structure → no gap for bulk Review all.
|
|
3337
|
+
return false;
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
/**
|
|
3341
|
+
* Parse one external review report into the fields the triage rule needs.
|
|
3342
|
+
* @param {{file:string,content:string,modifiedAt?:string}} input
|
|
3343
|
+
*/
|
|
3344
|
+
export function parseExternalReport({ file, content, modifiedAt = null } = {}) {
|
|
3345
|
+
const match = EXTERNAL_REPORT_FILE_RE.exec(String(file || ""));
|
|
3346
|
+
if (!match) return null;
|
|
3347
|
+
const text = typeof content === "string" ? content : "";
|
|
3348
|
+
const reviewed = text.match(REPORT_REVIEWED_PLAN_RE);
|
|
3349
|
+
return {
|
|
3350
|
+
file,
|
|
3351
|
+
path: `.cursor/memory/${file}`,
|
|
3352
|
+
slug: match[1],
|
|
3353
|
+
reviewedPlanFile: reviewed ? reviewed[1].trim() : null,
|
|
3354
|
+
triageNoteInReport: TRIAGE_HEADING_RE.test(text),
|
|
3355
|
+
findingsSummary: extractFindingsSummary(text),
|
|
3356
|
+
hasOpenReviewGaps: reportHasOpenReviewGaps(text),
|
|
3357
|
+
modifiedAt,
|
|
3358
|
+
};
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
/**
|
|
3362
|
+
* "Not yet a plan" contract (pure half), derived from the local reports rather
|
|
3363
|
+
* than assumed. A report at `.cursor/memory/plan-monitor-<slug>.md` counts as
|
|
3364
|
+
* ALREADY TRIAGED when either signal holds:
|
|
3365
|
+
*
|
|
3366
|
+
* 1. The report carries a triage heading (`TRIAGE_HEADING_RE`). Both triaged
|
|
3367
|
+
* reports in this repository do: one records `## Triage note` for an
|
|
3368
|
+
* "ack and stop" outcome that produced no plan, the other records
|
|
3369
|
+
* `## Follow-up plan`.
|
|
3370
|
+
* 2. A plan other than the reviewed plan names the report slug or the
|
|
3371
|
+
* reviewed plan in its own id or overview. The reviewed plan is read from
|
|
3372
|
+
* the report's `**Plan:**` header, so a hash-suffixed plan file is
|
|
3373
|
+
* excluded as itself rather than mistaken for its own follow-up.
|
|
3374
|
+
*
|
|
3375
|
+
* Neither signal alone is enough historically: a residuals plan may land
|
|
3376
|
+
* without editing the report. `/plan-review-triage` now requires a durable
|
|
3377
|
+
* triage heading for every outcome (including Ack and stop) so the heading
|
|
3378
|
+
* path clears Field Report without relying on HANDOFF alone. A report with
|
|
3379
|
+
* neither signal is surfaced as awaiting triage. The fs half (directory, file
|
|
3380
|
+
* cap, size cap, recency window) lives in `dashboard-data.mjs`, next to the
|
|
3381
|
+
* prompt-scan contract.
|
|
3382
|
+
*/
|
|
3383
|
+
export function isReportTriaged(report, plans) {
|
|
3384
|
+
if (!report) return false;
|
|
3385
|
+
if (report.triageNoteInReport) return true;
|
|
3386
|
+
|
|
3387
|
+
const slug = normalizeSlug(report.slug);
|
|
3388
|
+
const reviewed = normalizeSlug(report.reviewedPlanFile);
|
|
3389
|
+
if (!slug && !reviewed) return false;
|
|
3390
|
+
|
|
3391
|
+
return (plans || []).some((plan) => {
|
|
3392
|
+
if (!plan) return false;
|
|
3393
|
+
const planFile = String(plan.file || "");
|
|
3394
|
+
if (report.reviewedPlanFile && planFile === report.reviewedPlanFile) return false;
|
|
3395
|
+
const planSlug = normalizeSlug(plan.id || planFile);
|
|
3396
|
+
if (planSlug === slug || (reviewed && planSlug === reviewed)) return false;
|
|
3397
|
+
const haystack = normalizeSlug(`${plan.id || ""} ${plan.overview || ""}`);
|
|
3398
|
+
if (!haystack) return false;
|
|
3399
|
+
return (!!slug && haystack.includes(slug)) || (!!reviewed && haystack.includes(reviewed));
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
/** Ascending-sort key for a report timestamp; missing/unparsable sort last. */
|
|
3404
|
+
function reportSortMs(modifiedAt) {
|
|
3405
|
+
if (typeof modifiedAt !== "string" || !modifiedAt.trim()) return Number.POSITIVE_INFINITY;
|
|
3406
|
+
const t = Date.parse(modifiedAt);
|
|
3407
|
+
return Number.isNaN(t) ? Number.POSITIVE_INFINITY : t;
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
/** Shape one untriaged report into an attention item carrying its group. */
|
|
3411
|
+
function shapeExternalReportItem(report, group) {
|
|
3412
|
+
const reviewed = report.reviewedPlanFile
|
|
3413
|
+
? report.reviewedPlanFile.replace(/\.plan\.md$/, "")
|
|
3414
|
+
: report.slug;
|
|
3415
|
+
return withResolveAction({
|
|
3416
|
+
id: `attention:report:${report.slug}`,
|
|
3417
|
+
kind: "report",
|
|
3418
|
+
// Blocking rows gate a live or queued plan; debt rows are owed triage but
|
|
3419
|
+
// no longer gate execution. Phase 3 renders the group without re-deriving
|
|
3420
|
+
// lifecycle (2026-07-26_mission-control-field-report-review-debt-inbox).
|
|
3421
|
+
group,
|
|
3422
|
+
severity: "action",
|
|
3423
|
+
label: truncateStr(`${reviewed} awaiting triage`, MAX_SEMANTIC_LABEL),
|
|
3424
|
+
// Plain text derived from untrusted report markdown; escapeHtml at render.
|
|
3425
|
+
findingsSummary: report.findingsSummary || FINDINGS_SUMMARY_EMPTY,
|
|
3426
|
+
hasOpenReviewGaps: report.hasOpenReviewGaps !== false,
|
|
3427
|
+
sourcePath: report.path,
|
|
3428
|
+
modifiedAt: report.modifiedAt || null,
|
|
3429
|
+
progress: null,
|
|
3430
|
+
pathAction: attentionAction("path", report.path, "Copy path"),
|
|
3431
|
+
action: {
|
|
3432
|
+
type: "copy",
|
|
3433
|
+
target: `/plan-review-triage ${report.path}`,
|
|
3434
|
+
label: "Copy triage command",
|
|
3435
|
+
subject: "triage command",
|
|
3436
|
+
pasteDestination: "chatInput",
|
|
3437
|
+
},
|
|
3438
|
+
});
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
/**
|
|
3442
|
+
* Map untriaged reports into attention items, classified by reviewed-plan
|
|
3443
|
+
* lifecycle rather than filtered by it. Each row copies the report path and
|
|
3444
|
+
* `/plan-review-triage <path>` for a fresh chat; it does not run triage.
|
|
3445
|
+
*
|
|
3446
|
+
* Hard hides (row removed): strong triage (`isReportTriaged`) and, upstream,
|
|
3447
|
+
* ID-only dismissals. Lifecycle no longer removes a row: a terminal reviewed
|
|
3448
|
+
* plan (completed, parked, archived) sets `group: "debt"`; a live, queued, or
|
|
3449
|
+
* unknown-not-archived plan sets `group: "blocking"`. Blocking keeps the
|
|
3450
|
+
* incoming freshness order; debt is ordered oldest first. The surfaced total is
|
|
3451
|
+
* capped at `limit`, blocking before debt.
|
|
3452
|
+
* @param {object[]} reports - parsed reports (see `parseExternalReport`)
|
|
3453
|
+
* @param {object[]} plans - plan records from the snapshot
|
|
3454
|
+
*/
|
|
3455
|
+
export function buildExternalReportItems(
|
|
3456
|
+
reports,
|
|
3457
|
+
plans,
|
|
3458
|
+
{ limit = MAX_EXTERNAL_REPORTS, handoff = null, archivedPlanFiles = [] } = {},
|
|
3459
|
+
) {
|
|
3460
|
+
const blocking = [];
|
|
3461
|
+
const debt = [];
|
|
3462
|
+
for (const report of reports || []) {
|
|
3463
|
+
if (!report || !report.file) continue;
|
|
3464
|
+
if (isReportTriaged(report, plans)) continue;
|
|
3465
|
+
const demoted = isReportDemotedByPlanLifecycle(report, plans, handoff, archivedPlanFiles);
|
|
3466
|
+
if (demoted) {
|
|
3467
|
+
debt.push(shapeExternalReportItem(report, "debt"));
|
|
3468
|
+
} else {
|
|
3469
|
+
blocking.push(shapeExternalReportItem(report, "blocking"));
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
// Within debt: oldest first. Blocking keeps the scan (freshness) order.
|
|
3473
|
+
debt.sort((a, b) => reportSortMs(a.modifiedAt) - reportSortMs(b.modifiedAt));
|
|
3474
|
+
return [...blocking, ...debt].slice(0, limit);
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
/**
|
|
3478
|
+
* Assemble the Mission Control view model attached to the dashboard snapshot.
|
|
3479
|
+
*/
|
|
3480
|
+
export function buildMissionControlView({
|
|
3481
|
+
plans = [],
|
|
3482
|
+
handoff = null,
|
|
3483
|
+
gitLogLines = [],
|
|
3484
|
+
terminals = [],
|
|
3485
|
+
readinessPending = [],
|
|
3486
|
+
deferredCheckIds = [],
|
|
3487
|
+
agentPrompts = [],
|
|
3488
|
+
externalReports = [],
|
|
3489
|
+
dismissedIds = [],
|
|
3490
|
+
archivedPlanFiles = [],
|
|
3491
|
+
agents = [],
|
|
3492
|
+
skills = [],
|
|
3493
|
+
commands = [],
|
|
3494
|
+
memory = null,
|
|
3495
|
+
previousInventory = null,
|
|
3496
|
+
timingLedger = null,
|
|
3497
|
+
flightLogLedger = null,
|
|
3498
|
+
cadenceLedger = null,
|
|
3499
|
+
cadenceConfig = null,
|
|
3500
|
+
nowMs = Date.now(),
|
|
3501
|
+
} = {}) {
|
|
3502
|
+
const nowBase = buildCurrentExecution(plans, handoff);
|
|
3503
|
+
const activeForTiming = (plans || []).find((p) => isActivePlan(p, handoff)) || null;
|
|
3504
|
+
const { ledger: nextTimingLedger, timing } = observeMissionTiming(
|
|
3505
|
+
parseMissionTimingLedger(timingLedger),
|
|
3506
|
+
nowBase,
|
|
3507
|
+
{ nowMs, todoItems: activeForTiming?.todos?.items || [] },
|
|
3508
|
+
);
|
|
3509
|
+
const now = withMissionTiming(nowBase, timing);
|
|
3510
|
+
const { ledger: nextFlightLogLedger, flightLog } = observeFlightLog(
|
|
3511
|
+
parseFlightLogLedger(flightLogLedger),
|
|
3512
|
+
now.gaps,
|
|
3513
|
+
{
|
|
3514
|
+
nowMs,
|
|
3515
|
+
sourcePath: ".cursor/HANDOFF.md",
|
|
3516
|
+
flightKey: buildFlightLogFlightKey(handoff),
|
|
3517
|
+
},
|
|
3518
|
+
);
|
|
3519
|
+
flightLog.warnings = buildFlightLogWarnings(handoff);
|
|
3520
|
+
const classifiedPlans = enrichPlans(plans, handoff);
|
|
3521
|
+
const planEvents = formatPlanHandoffActivity({ now, handoff, plans });
|
|
3522
|
+
const inventoryEvents = formatInventoryActivity(
|
|
3523
|
+
{ agents, skills, commands, memory },
|
|
3524
|
+
previousInventory,
|
|
3525
|
+
);
|
|
3526
|
+
// Per-event delivery attribution from each merge branch (never the active plan).
|
|
3527
|
+
// Delivery precedes raw git so merge/delivery rows are not starved by MAX_ACTIVITY;
|
|
3528
|
+
// superseded merge + absorbed commit SHAs are excluded from the git stream so
|
|
3529
|
+
// each merge appears once (as delivery) on the unified Activity stream.
|
|
3530
|
+
const deliveryEvents = formatDeliveryActivity(gitLogLines, {
|
|
3531
|
+
plans,
|
|
3532
|
+
limit: MAX_GIT_ACTIVITY,
|
|
3533
|
+
});
|
|
3534
|
+
const supersededShas = deliverySupersededShas(deliveryEvents);
|
|
3535
|
+
const activity = mergeActivity([
|
|
3536
|
+
planEvents.filter((e) => e.kind === "run_plan" || e.kind === "handoff"),
|
|
3537
|
+
planEvents.filter((e) => e.kind === "agent_step"),
|
|
3538
|
+
deliveryEvents,
|
|
3539
|
+
planEvents.filter((e) => e.kind === "plan_progress"),
|
|
3540
|
+
formatGitActivity(gitLogLines, { excludeShas: supersededShas }),
|
|
3541
|
+
formatTerminalRunEvidence(terminals),
|
|
3542
|
+
inventoryEvents,
|
|
3543
|
+
]);
|
|
3544
|
+
// Field Report attention inbox left the Flight Log card; builders stay
|
|
3545
|
+
// exported for /field-report-resolve + cadence scripts (ADR keep).
|
|
3546
|
+
// Quiet Gaps+Warnings: bounded report rows may surface on Flight Log.
|
|
3547
|
+
const attention = buildAttentionItems({
|
|
3548
|
+
plans,
|
|
3549
|
+
handoff,
|
|
3550
|
+
agentPrompts,
|
|
3551
|
+
externalReports,
|
|
3552
|
+
dismissedIds,
|
|
3553
|
+
archivedPlanFiles,
|
|
3554
|
+
readinessPending: allowlistReadinessPending(readinessPending),
|
|
3555
|
+
deferredCheckIds,
|
|
3556
|
+
cadenceLedger,
|
|
3557
|
+
cadenceConfig,
|
|
3558
|
+
});
|
|
3559
|
+
flightLog.quietOpenTriages = listFlightLogQuietOpenTriages(attention);
|
|
3560
|
+
// Deprecated: attention owns Field Report rows. Kept empty so older panel
|
|
3561
|
+
// code that still reads the field does not double-render.
|
|
3562
|
+
const checklistNotes = [];
|
|
3563
|
+
|
|
3564
|
+
return {
|
|
3565
|
+
schemaVersion: "1.0.0",
|
|
3566
|
+
now,
|
|
3567
|
+
activity,
|
|
3568
|
+
attention,
|
|
3569
|
+
flightLog,
|
|
3570
|
+
checklistNotes,
|
|
3571
|
+
plans: classifiedPlans,
|
|
3572
|
+
// Crew Monitor hero display cap (SoT for dashboard.html; no HTML literal).
|
|
3573
|
+
monitorFeedCap: MONITOR_FEED_CAP,
|
|
3574
|
+
// /run-plan-all queue slice (null outside queue mode). Copy-only data:
|
|
3575
|
+
// display order and roles; the panel never writes the queue back.
|
|
3576
|
+
runQueue: buildRunQueueView(handoff),
|
|
3577
|
+
// Next ledger after this observation (dashboard-data persists write-on-change).
|
|
3578
|
+
timingLedger: nextTimingLedger,
|
|
3579
|
+
flightLogLedger: nextFlightLogLedger,
|
|
3580
|
+
};
|
|
3581
|
+
}
|