@dadado/agent-kit-cli 4.8.0 → 4.8.3

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