@dadado/agent-kit-cli 4.8.2 → 4.8.4

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.
@@ -29,6 +29,22 @@ export const DEFAULT_PORT_BASE = 3333;
29
29
  */
30
30
  export const DEFAULT_PORT_RANGE = 256;
31
31
 
32
+ /**
33
+ * Escape a path/string for embedding in a Perl double-quoted literal.
34
+ * Scoped npm package paths contain `@` (e.g. `node_modules/@dadado/...`);
35
+ * unescaped `@name` is array interpolation in Perl and strips the scope segment,
36
+ * so macOS detach-start via `perl -e` cannot exec `dashboard/serve.mjs`.
37
+ * @param {string} value
38
+ * @returns {string}
39
+ */
40
+ export function escapePerlDoubleQuoted(value) {
41
+ return String(value)
42
+ .replace(/\\/g, "\\\\")
43
+ .replace(/"/g, '\\"')
44
+ .replace(/\$/g, "\\$")
45
+ .replace(/@/g, "\\@");
46
+ }
47
+
32
48
  /**
33
49
  * Resolve the repository root Mission Control should snapshot.
34
50
  * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
@@ -141,7 +157,9 @@ export function sameRepoRoot(a, b) {
141
157
  export function resolveMissionControlPort({ repoRoot, envPort, probe, opts = {} }) {
142
158
  const root = resolve(String(repoRoot || "").trim() || ".");
143
159
  const raw =
144
- envPort != null && String(envPort).trim() !== "" ? Number.parseInt(String(envPort), 10) : NaN;
160
+ envPort != null && String(envPort).trim() !== ""
161
+ ? Number.parseInt(String(envPort), 10)
162
+ : Number.NaN;
145
163
 
146
164
  if (Number.isFinite(raw) && raw > 0) {
147
165
  const info = probe(raw);
@@ -2,6 +2,10 @@
2
2
  // Pure Mission Control view-model helpers (testable; no fs/git I/O).
3
3
 
4
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 };
5
9
 
6
10
  export const MAX_ACTIVITY = 28;
7
11
  export const MAX_ATTENTION = 15;
@@ -62,15 +66,6 @@ export const MAX_CHECKLIST_NOTES = 15;
62
66
  * External review reports are `.cursor/memory/plan-monitor-<slug>.md`. */
63
67
  export const EXTERNAL_REPORT_FILE_RE = /^plan-monitor-(.+)\.md$/;
64
68
 
65
- /**
66
- * A heading the triage step leaves behind in the report itself. Confirmed
67
- * against the local reports: `## Triage note - residual (A) verified` and
68
- * `## Follow-up plan - hitl_ask_questions_residuals_2026_07_20.plan.md`.
69
- * `/plan-review-triage` must write one of these for every outcome, including
70
- * Ack and stop, so Field Report can clear the untriaged row.
71
- */
72
- export const TRIAGE_HEADING_RE = /^#{2,6}\s+.*\b(triage|follow-?up plan|residuals plan)\b/im;
73
-
74
69
  /**
75
70
  * Local Field Report dismissals store (IDs only). Valid attention ids that
76
71
  * `/field-report-resolve` may append: External reviews, agent prompts, and
@@ -576,21 +571,23 @@ export function buildFlightLogWarnings(handoff, opts = {}) {
576
571
 
577
572
  /**
578
573
  * Bounded untriaged external-review rows for Flight Log quiet state.
579
- * Filters attention to `kind === "report"` only (no cadence, prompts, readiness,
580
- * or bulk FR CTAs). Used when Gaps + Warnings are empty; callers must not mix
581
- * these rows into a non-quiet Gaps/Warnings stack.
582
- * @param {object[]|null|undefined} attention - buildAttentionItems output
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
583
580
  * @param {{ limit?: number }} [opts]
584
581
  * @returns {object[]}
585
582
  */
586
- export function listFlightLogQuietOpenTriages(attention, opts = {}) {
583
+ export function listFlightLogQuietOpenTriages(reportOrAttentionItems, opts = {}) {
587
584
  const limit =
588
585
  typeof opts.limit === "number" && opts.limit > 0
589
586
  ? Math.floor(opts.limit)
590
587
  : FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP;
591
- if (!Array.isArray(attention) || attention.length === 0) return [];
588
+ if (!Array.isArray(reportOrAttentionItems) || reportOrAttentionItems.length === 0) return [];
592
589
  const out = [];
593
- for (const item of attention) {
590
+ for (const item of reportOrAttentionItems) {
594
591
  if (!item || item.kind !== "report") continue;
595
592
  if (typeof item.sourcePath !== "string" || !item.sourcePath.trim()) continue;
596
593
  out.push(item);
@@ -1369,26 +1366,33 @@ export function normalizeHandoffGaps(raw) {
1369
1366
 
1370
1367
  /**
1371
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
1372
  * @param {string | null | undefined} text
1373
1373
  * @param {{ lane?: 'gaps' | 'warning' }} [opts]
1374
1374
  * @returns {FlightLogMessageKind}
1375
1375
  */
1376
1376
  export function classifyFlightLogMessageKind(text, opts = {}) {
1377
1377
  if (opts.lane === "warning") return "warning";
1378
- const normalized = typeof text === "string" ? normalizeHandoffGaps(text) : null;
1379
- if (normalized == null) return "ok";
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";
1380
1384
  if (
1381
- /\bAPI\s*\/\s*usage\s+limit\b|\bAPI\s+usage\s+limit\b|\bSTOPPED:\s*API\b/i.test(normalized) ||
1382
- /\b(hard.?stop|quota\s+pause)\b/i.test(normalized)
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)
1383
1387
  ) {
1384
1388
  return "warning";
1385
1389
  }
1386
1390
  if (
1387
- /\b(confirm|ask questions|hitl|\bpaste\b|choose\b|approve\b|operator yes)\b/i.test(normalized)
1391
+ /\b(confirm|ask questions|hitl|\bpaste\b|choose\b|approve\b|operator yes)\b/i.test(collapsed)
1388
1392
  ) {
1389
1393
  return "prompt";
1390
1394
  }
1391
- if (/\b(tip:|advice:|consider\b|recommends?\b|recommended\b|prefer\b)/i.test(normalized)) {
1395
+ if (/\b(tip:|advice:|consider\b|recommends?\b|recommended\b|prefer\b)/i.test(collapsed)) {
1392
1396
  return "advice";
1393
1397
  }
1394
1398
  return "residual";
@@ -3544,6 +3548,15 @@ export function buildMissionControlView({
3544
3548
  // Field Report attention inbox left the Flight Log card; builders stay
3545
3549
  // exported for /field-report-resolve + cadence scripts (ADR keep).
3546
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));
3547
3560
  const attention = buildAttentionItems({
3548
3561
  plans,
3549
3562
  handoff,
@@ -3556,7 +3569,7 @@ export function buildMissionControlView({
3556
3569
  cadenceLedger,
3557
3570
  cadenceConfig,
3558
3571
  });
3559
- flightLog.quietOpenTriages = listFlightLogQuietOpenTriages(attention);
3572
+ flightLog.quietOpenTriages = listFlightLogQuietOpenTriages(quietReportItems);
3560
3573
  // Deprecated: attention owns Field Report rows. Kept empty so older panel
3561
3574
  // code that still reads the field does not double-render.
3562
3575
  const checklistNotes = [];
@@ -3571,6 +3584,8 @@ export function buildMissionControlView({
3571
3584
  plans: classifiedPlans,
3572
3585
  // Crew Monitor hero display cap (SoT for dashboard.html; no HTML literal).
3573
3586
  monitorFeedCap: MONITOR_FEED_CAP,
3587
+ // Quiet open-triage fallback cap for dashboard.html attention mirror.
3588
+ flightLogQuietOpenTriagesCap: FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP,
3574
3589
  // /run-plan-all queue slice (null outside queue mode). Copy-only data:
3575
3590
  // display order and roles; the panel never writes the queue back.
3576
3591
  runQueue: buildRunQueueView(handoff),
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Durable triage headings written by `/plan-review-triage` (L0).
3
+ *
4
+ * Shared SoT for Mission Control (`isReportTriaged`) and
5
+ * `agent-kit monitors --untriaged`. Match ONLY these heading titles, not tick
6
+ * headings that merely name a `triage-*` to-do id (hyphens are word boundaries,
7
+ * so `\btriage\b` falsely matched those).
8
+ *
9
+ * Allowed forms (case-insensitive; optional suffix after the title):
10
+ * ## Triage note
11
+ * ## Follow-up plan (also "Followup plan")
12
+ * ## Residuals plan
13
+ */
14
+ export const TRIAGE_HEADING_RE =
15
+ /^#{2,6}\s+(?:Triage note|Follow-?up plan|Residuals plan)\b/im;
16
+
17
+ /** True when markdown carries a durable triage heading. */
18
+ export function hasTriageHeading(text) {
19
+ return TRIAGE_HEADING_RE.test(String(text ?? ""));
20
+ }
@@ -7,13 +7,14 @@
7
7
  * prints LAN URL(s) with token. Does not weaken loopback `/dashboard`.
8
8
  */
9
9
 
10
- import { spawn, execFileSync, execSync } from "node:child_process";
10
+ import { execFileSync, execSync, spawn } from "node:child_process";
11
11
  import { existsSync, openSync } from "node:fs";
12
+ import { platform } from "node:os";
12
13
  import { dirname, join } from "node:path";
13
14
  import { fileURLToPath } from "node:url";
14
- import { platform } from "node:os";
15
15
  import {
16
16
  BROADCAST_TOKEN_ENV,
17
+ escapePerlDoubleQuoted,
17
18
  generateBroadcastToken,
18
19
  isLoopbackBindHost,
19
20
  isValidBroadcastToken,
@@ -26,7 +27,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
26
27
  const ROOT = join(__dirname, "..");
27
28
  const SERVE = join(__dirname, "serve.mjs");
28
29
  const LOG = process.env.MISSION_CONTROL_LOG || "/tmp/mission-control-broadcast.log";
29
- const PORT = parseInt(process.env.PORT || "3333", 10);
30
+ const PORT = Number.parseInt(process.env.PORT || "3333", 10);
30
31
  const READY_TIMEOUT_MS = 20_000;
31
32
  const READY_POLL_MS = 250;
32
33
 
@@ -57,11 +58,10 @@ function urlsForProbe(token) {
57
58
 
58
59
  function probeHttp(url) {
59
60
  try {
60
- const code = execFileSync(
61
- "curl",
62
- ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url],
63
- { encoding: "utf8", timeout: 3000 },
64
- ).trim();
61
+ const code = execFileSync("curl", ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], {
62
+ encoding: "utf8",
63
+ timeout: 3000,
64
+ }).trim();
65
65
  return code === "200";
66
66
  } catch {
67
67
  return false;
@@ -70,11 +70,10 @@ function probeHttp(url) {
70
70
 
71
71
  function listeningPids() {
72
72
  try {
73
- const out = execFileSync(
74
- "lsof",
75
- ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t"],
76
- { encoding: "utf8", timeout: 3000 },
77
- ).trim();
73
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t"], {
74
+ encoding: "utf8",
75
+ timeout: 3000,
76
+ }).trim();
78
77
  return out ? out.split(/\n+/).filter(Boolean) : [];
79
78
  } catch {
80
79
  return [];
@@ -107,12 +106,13 @@ function detachStart(env) {
107
106
  return;
108
107
  }
109
108
 
110
- const rootEsc = ROOT.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
111
- const serveEsc = SERVE.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
112
- const logEsc = LOG.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
113
- const hostEsc = String(env.HOST).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
114
- const tokenEsc = String(env[BROADCAST_TOKEN_ENV]).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
115
- const portEsc = String(PORT);
109
+ // Escape @/$ so scoped package paths (node_modules/@scope/...) survive Perl qq.
110
+ const rootEsc = escapePerlDoubleQuoted(ROOT);
111
+ const serveEsc = escapePerlDoubleQuoted(SERVE);
112
+ const logEsc = escapePerlDoubleQuoted(LOG);
113
+ const hostEsc = escapePerlDoubleQuoted(String(env.HOST));
114
+ const tokenEsc = escapePerlDoubleQuoted(String(env[BROADCAST_TOKEN_ENV]));
115
+ const portEsc = escapePerlDoubleQuoted(String(PORT));
116
116
  const perl = [
117
117
  "use POSIX qw(setsid);",
118
118
  "exit if fork;",
@@ -169,7 +169,9 @@ function openBrowser(url) {
169
169
  async function main() {
170
170
  const { env, host, token } = resolveBroadcastEnv();
171
171
  if (isLoopbackBindHost(host)) {
172
- console.error("Broadcast refused: bind host resolved to loopback. Set HOST to a non-loopback address.");
172
+ console.error(
173
+ "Broadcast refused: bind host resolved to loopback. Set HOST to a non-loopback address.",
174
+ );
173
175
  process.exit(1);
174
176
  }
175
177
 
@@ -193,9 +195,7 @@ async function main() {
193
195
  detachStart(env);
194
196
  const ready = await waitReady(urls);
195
197
  if (!ready) {
196
- console.error(
197
- `Mission Control broadcast did not answer within ${READY_TIMEOUT_MS}ms.`,
198
- );
198
+ console.error(`Mission Control broadcast did not answer within ${READY_TIMEOUT_MS}ms.`);
199
199
  console.error(`Check the log: ${LOG}`);
200
200
  process.exit(1);
201
201
  }
@@ -15,17 +15,18 @@
15
15
  * Foreground serve for debugging remains: `npm run start:dashboard`.
16
16
  */
17
17
 
18
- import { spawn, execFileSync, execSync } from "node:child_process";
18
+ import { execFileSync, execSync, spawn } from "node:child_process";
19
19
  import { existsSync, openSync } from "node:fs";
20
+ import { platform } from "node:os";
20
21
  import { dirname, join, resolve } from "node:path";
21
22
  import { fileURLToPath } from "node:url";
22
- import { platform } from "node:os";
23
23
  import {
24
24
  REPO_ROOT_ENV,
25
- resolveSnapshotRepoRoot,
25
+ escapePerlDoubleQuoted,
26
+ repoRootLogId,
26
27
  resolveMissionControlPort,
28
+ resolveSnapshotRepoRoot,
27
29
  sameRepoRoot,
28
- repoRootLogId,
29
30
  } from "./lib/guards.mjs";
30
31
 
31
32
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -50,18 +51,15 @@ function setPort(port) {
50
51
  PORT = port;
51
52
  URL = `http://${DISPLAY_HOST}:${PORT}/`;
52
53
  DATA_URL = `http://${DISPLAY_HOST}:${PORT}/dashboard-data.json`;
53
- LOG =
54
- process.env.MISSION_CONTROL_LOG ||
55
- `/tmp/mission-control-${repoRootLogId(ROOT)}.log`;
54
+ LOG = process.env.MISSION_CONTROL_LOG || `/tmp/mission-control-${repoRootLogId(ROOT)}.log`;
56
55
  }
57
56
 
58
57
  function probeHttp(url = URL) {
59
58
  try {
60
- const code = execFileSync(
61
- "curl",
62
- ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url],
63
- { encoding: "utf8", timeout: 3000 },
64
- ).trim();
59
+ const code = execFileSync("curl", ["-sf", "-o", "/dev/null", "-w", "%{http_code}", url], {
60
+ encoding: "utf8",
61
+ timeout: 3000,
62
+ }).trim();
65
63
  return code === "200";
66
64
  } catch {
67
65
  return false;
@@ -70,11 +68,10 @@ function probeHttp(url = URL) {
70
68
 
71
69
  function listeningPids(port = PORT) {
72
70
  try {
73
- const out = execFileSync(
74
- "lsof",
75
- ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
76
- { encoding: "utf8", timeout: 3000 },
77
- ).trim();
71
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
72
+ encoding: "utf8",
73
+ timeout: 3000,
74
+ }).trim();
78
75
  return out ? out.split(/\n+/).filter(Boolean) : [];
79
76
  } catch {
80
77
  return [];
@@ -153,11 +150,12 @@ function detachStart() {
153
150
  }
154
151
 
155
152
  // macOS and other hosts without setsid: Perl double-fork + setsid().
156
- const rootEsc = KIT_ROOT.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
157
- const serveEsc = SERVE.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
158
- const logEsc = LOG.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
159
- const portEsc = String(PORT);
160
- const snapEsc = ROOT.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
153
+ // Escape @/$ so scoped package paths (node_modules/@scope/...) survive Perl qq.
154
+ const rootEsc = escapePerlDoubleQuoted(KIT_ROOT);
155
+ const serveEsc = escapePerlDoubleQuoted(SERVE);
156
+ const logEsc = escapePerlDoubleQuoted(LOG);
157
+ const portEsc = escapePerlDoubleQuoted(String(PORT));
158
+ const snapEsc = escapePerlDoubleQuoted(ROOT);
161
159
  const perl = [
162
160
  "use POSIX qw(setsid);",
163
161
  "exit if fork;",
@@ -258,9 +256,7 @@ async function ensureServer() {
258
256
  detachStart();
259
257
  const ready = await waitReady();
260
258
  if (!ready) {
261
- console.error(
262
- `Mission Control did not answer ${URL} within ${READY_TIMEOUT_MS}ms.`,
263
- );
259
+ console.error(`Mission Control did not answer ${URL} within ${READY_TIMEOUT_MS}ms.`);
264
260
  console.error(`Check the log: ${LOG}`);
265
261
  process.exit(1);
266
262
  }
@@ -280,9 +276,7 @@ async function main() {
280
276
  "Opened in the default browser. In Cursor, Simple Browser or /dashboard also works.",
281
277
  );
282
278
  } else {
283
- console.log(
284
- "Open that URL in a browser (Cursor: Simple Browser, or run /dashboard in chat).",
285
- );
279
+ console.log("Open that URL in a browser (Cursor: Simple Browser, or run /dashboard in chat).");
286
280
  }
287
281
  }
288
282