@dadado/agent-kit-cli 4.7.2 → 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.
@@ -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
+ }