@phnx-labs/agents-cli 1.22.25 → 1.22.26

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +17 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/browser.js +35 -0
  7. package/dist/commands/doctor.js +8 -0
  8. package/dist/commands/insights.d.ts +25 -19
  9. package/dist/commands/insights.js +107 -33
  10. package/dist/commands/reconnect.d.ts +46 -0
  11. package/dist/commands/reconnect.js +109 -0
  12. package/dist/commands/routines.js +2 -2
  13. package/dist/commands/secrets.d.ts +2 -8
  14. package/dist/commands/secrets.js +29 -105
  15. package/dist/commands/sessions.js +4 -0
  16. package/dist/commands/setup-secrets.d.ts +1 -0
  17. package/dist/commands/setup-secrets.js +1 -1
  18. package/dist/commands/setup.d.ts +26 -3
  19. package/dist/commands/setup.js +105 -46
  20. package/dist/commands/teams.d.ts +6 -0
  21. package/dist/commands/teams.js +43 -0
  22. package/dist/commands/trends.d.ts +8 -0
  23. package/dist/commands/trends.js +10 -156
  24. package/dist/index.js +1 -1
  25. package/dist/lib/agents.d.ts +11 -0
  26. package/dist/lib/agents.js +29 -2
  27. package/dist/lib/analytics/dashboard.d.ts +10 -6
  28. package/dist/lib/analytics/dashboard.js +6 -4
  29. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  30. package/dist/lib/analytics/mix-commands.js +229 -0
  31. package/dist/lib/analytics/recipes.d.ts +19 -14
  32. package/dist/lib/analytics/recipes.js +4 -2
  33. package/dist/lib/browser/ipc.d.ts +26 -0
  34. package/dist/lib/browser/ipc.js +139 -24
  35. package/dist/lib/browser/profiles.d.ts +11 -0
  36. package/dist/lib/browser/profiles.js +1 -1
  37. package/dist/lib/browser/stream.d.ts +14 -0
  38. package/dist/lib/browser/stream.js +71 -0
  39. package/dist/lib/channels/owner-sink.d.ts +27 -0
  40. package/dist/lib/channels/owner-sink.js +93 -0
  41. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  42. package/dist/lib/devices/doctor-findings.js +33 -1
  43. package/dist/lib/fleet/apply.d.ts +59 -3
  44. package/dist/lib/fleet/apply.js +183 -6
  45. package/dist/lib/fleet/types.d.ts +21 -2
  46. package/dist/lib/hooks/cache.js +15 -0
  47. package/dist/lib/hosts/passthrough.d.ts +23 -0
  48. package/dist/lib/hosts/passthrough.js +45 -0
  49. package/dist/lib/hosts/ready.d.ts +2 -0
  50. package/dist/lib/hosts/ready.js +10 -1
  51. package/dist/lib/hosts/reconnect.d.ts +14 -12
  52. package/dist/lib/hosts/reconnect.js +41 -40
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  54. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  55. package/dist/lib/routines.js +14 -2
  56. package/dist/lib/runner.d.ts +0 -3
  57. package/dist/lib/runner.js +1 -14
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/push.d.ts +94 -0
  61. package/dist/lib/secrets/push.js +145 -0
  62. package/dist/lib/secrets/reaper.d.ts +15 -1
  63. package/dist/lib/secrets/reaper.js +30 -3
  64. package/dist/lib/session/db.d.ts +21 -3
  65. package/dist/lib/session/db.js +221 -13
  66. package/dist/lib/session/discover.d.ts +1 -0
  67. package/dist/lib/session/discover.js +115 -19
  68. package/dist/lib/session/insights.d.ts +18 -0
  69. package/dist/lib/session/insights.js +143 -1
  70. package/dist/lib/session/tool-index.js +133 -22
  71. package/dist/lib/session/tool-store.d.ts +26 -2
  72. package/dist/lib/session/tool-store.js +36 -17
  73. package/dist/lib/ssh-exec.js +8 -2
  74. package/dist/lib/startup/command-registry.d.ts +1 -0
  75. package/dist/lib/startup/command-registry.js +4 -0
  76. package/dist/lib/teams/agents.d.ts +13 -0
  77. package/dist/lib/teams/agents.js +75 -7
  78. package/dist/lib/teams/placement-probe.d.ts +21 -0
  79. package/dist/lib/teams/placement-probe.js +135 -0
  80. package/dist/lib/teams/scheduler.d.ts +74 -1
  81. package/dist/lib/teams/scheduler.js +187 -10
  82. package/package.json +1 -1
@@ -877,6 +877,7 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
877
877
  meta: result.meta,
878
878
  content: result.content,
879
879
  scan,
880
+ events: result.events,
880
881
  toolCalls: result.toolCalls,
881
882
  toolIndexMode: result.toolIndexMode,
882
883
  });
@@ -1756,7 +1757,7 @@ async function scanAntigravityIncremental(onProgress) {
1756
1757
  const result = readAntigravityMeta(filePath, currentVersion);
1757
1758
  if (result && !seen.has(result.meta.id)) {
1758
1759
  seen.add(result.meta.id);
1759
- entries.push({ meta: result.meta, content: result.content, scan });
1760
+ entries.push({ meta: result.meta, content: result.content, scan, events: result.events });
1760
1761
  }
1761
1762
  else {
1762
1763
  touched.push({ filePath, scan });
@@ -1802,23 +1803,28 @@ function readAntigravityMeta(filePath, currentVersion) {
1802
1803
  topic: topic ? topic.slice(0, 120) : undefined,
1803
1804
  messageCount: events.length,
1804
1805
  };
1805
- return { meta, content: contentParts.join('\n') };
1806
+ return { meta, content: contentParts.join('\n'), events };
1806
1807
  }
1807
1808
  // ---------------------------------------------------------------------------
1808
1809
  // OpenCode
1809
1810
  // ---------------------------------------------------------------------------
1810
1811
  const OPENCODE_DB = path.join(HOME, '.local', 'share', 'opencode', 'opencode.db');
1811
1812
  let cachedOpenCodeAccount;
1812
- /** Query the active OpenCode account email from its SQLite database. */
1813
- async function getOpenCodeAccount() {
1813
+ /**
1814
+ * Query the active OpenCode account email from its SQLite database.
1815
+ *
1816
+ * Takes the scan's already-open handle so a scan opens `opencode.db` exactly
1817
+ * once (RUSH-2210); opens its own only when called without one.
1818
+ */
1819
+ function getOpenCodeAccount(handle) {
1814
1820
  if (cachedOpenCodeAccount !== undefined)
1815
1821
  return cachedOpenCodeAccount || undefined;
1816
1822
  // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1817
1823
  // works on every OS — the CLI is absent on Windows.
1818
- let db;
1824
+ let owned;
1819
1825
  try {
1820
- if (fs.existsSync(OPENCODE_DB)) {
1821
- db = new Database(OPENCODE_DB);
1826
+ const db = handle ?? (fs.existsSync(OPENCODE_DB) ? (owned = new Database(OPENCODE_DB)) : undefined);
1827
+ if (db) {
1822
1828
  const row = db
1823
1829
  .prepare('SELECT email FROM control_account WHERE active=1 LIMIT 1;')
1824
1830
  .get();
@@ -1832,13 +1838,63 @@ async function getOpenCodeAccount() {
1832
1838
  catch { /* DB not accessible, sqlite module unavailable, or query failed */ }
1833
1839
  finally {
1834
1840
  try {
1835
- db?.close();
1841
+ owned?.close();
1836
1842
  }
1837
1843
  catch { /* best-effort close */ }
1838
1844
  }
1839
1845
  cachedOpenCodeAccount = '';
1840
1846
  return undefined;
1841
1847
  }
1848
+ /**
1849
+ * The per-session ledger stamp for an OpenCode row: the newest write time across
1850
+ * the session's own row, its messages, and its parts, paired with the total byte
1851
+ * length of that session's message + part payloads.
1852
+ *
1853
+ * OpenCode keeps every session in ONE shared SQLite file, so that file's
1854
+ * mtime/size changes whenever *any* session is written. Stamping each session
1855
+ * with the whole-DB stat therefore invalidated every indexed session on every
1856
+ * scan: up to `LIMIT 1000` sessions were re-emitted into `upsertSessionsBatch`,
1857
+ * and its enrichment step re-opened `opencode.db` once per re-emitted entry via
1858
+ * `parseSession` (RUSH-2210).
1859
+ *
1860
+ * `s.time_updated` alone is NOT that session's change signal, which is the trap
1861
+ * this shape exists to avoid. On a real database, parts land long after the
1862
+ * session row was last touched — e.g. `ses_3955202dfffe…` carried
1863
+ * `time_updated = 1771316403087` with its newest part at `1771331512162`, over
1864
+ * four hours later. So the stamp maxes `time_updated` with the newest message
1865
+ * and part times, and pairs it with a byte total that also moves when an
1866
+ * existing part's `data` is rewritten in place (a streaming turn) without any
1867
+ * new row or timestamp.
1868
+ *
1869
+ * The byte total is a REAL size, not a repurposed counter, because
1870
+ * `sessions.file_size` is read back as bytes elsewhere: `ensureToolIndex` uses
1871
+ * it as the tool-backfill byte budget and `toolCallsForBackfill` as the 16 MiB
1872
+ * in-memory parser cap (`tool-index.ts`). A message/part byte total is the
1873
+ * honest cost of parsing that session — strictly better than the whole-DB size
1874
+ * this column used to hold for every OpenCode row.
1875
+ *
1876
+ * The size is `LENGTH(CAST(data AS BLOB))`, not `LENGTH(data)`: SQLite's
1877
+ * `LENGTH()` on a TEXT column counts CHARACTERS, so a CJK/emoji-heavy transcript
1878
+ * would under-report its real size by up to 4x — and this number is a byte
1879
+ * budget downstream.
1880
+ *
1881
+ * A degenerate row — nothing but zeros/NaN for every time AND no payload at all
1882
+ * — falls back to the whole-DB stat, keeping the pre-RUSH-2210 always-rescan
1883
+ * behavior for exactly those rows rather than pinning them to a stamp that never
1884
+ * changes.
1885
+ */
1886
+ function openCodeSessionStamp(times, bytes, dbScan) {
1887
+ // A positive time, not merely a finite one: the SQL COALESCEs the aggregates
1888
+ // to 0, so `Number.isFinite` alone would accept a row that told us nothing and
1889
+ // this branch could never fire.
1890
+ const known = [times.timeUpdated, times.lastMessageAt, times.lastPartAt, times.timeCreated]
1891
+ .filter(t => Number.isFinite(t) && t > 0);
1892
+ const size = (Number.isFinite(bytes.messageBytes) ? bytes.messageBytes : 0)
1893
+ + (Number.isFinite(bytes.partBytes) ? bytes.partBytes : 0);
1894
+ if (known.length === 0 && size === 0)
1895
+ return dbScan;
1896
+ return { fileMtimeMs: known.length === 0 ? 0 : Math.floor(Math.max(...known)), fileSize: size };
1897
+ }
1842
1898
  /** Scan OpenCode sessions from its SQLite database when the DB file has changed. */
1843
1899
  async function scanOpenCodeIncremental() {
1844
1900
  if (!fs.existsSync(OPENCODE_DB))
@@ -1846,8 +1902,9 @@ async function scanOpenCodeIncremental() {
1846
1902
  const stat = safeStatSync(OPENCODE_DB);
1847
1903
  if (!stat)
1848
1904
  return;
1849
- // OpenCode is one big DB; we use its mtime/size as the ledger for the
1850
- // entire fleet of OpenCode sessions.
1905
+ // OpenCode is one big DB. Its mtime/size is the cheap "did ANYTHING change"
1906
+ // short-circuit for the whole harness — not the per-session stamp, which each
1907
+ // row carries itself (see openCodeSessionStamp, RUSH-2210).
1851
1908
  const currentScan = {
1852
1909
  fileMtimeMs: Math.floor(stat.mtimeMs),
1853
1910
  fileSize: stat.size,
@@ -1856,7 +1913,6 @@ async function scanOpenCodeIncremental() {
1856
1913
  if (prev && prev.fileMtimeMs === currentScan.fileMtimeMs && prev.fileSize === currentScan.fileSize) {
1857
1914
  return;
1858
1915
  }
1859
- const account = await getOpenCodeAccount();
1860
1916
  const currentVersion = await getCurrentAgentVersion('opencode');
1861
1917
  // Read through the node/bun SQLite wrapper (not the `sqlite3` CLI) so this
1862
1918
  // works on every OS — the CLI is absent on Windows.
@@ -1871,14 +1927,28 @@ async function scanOpenCodeIncremental() {
1871
1927
  s.time_created AS time_created,
1872
1928
  s.time_updated AS time_updated,
1873
1929
  COALESCE(stats.message_count, 0) AS message_count,
1930
+ COALESCE(stats.last_message_at, 0) AS last_message_at,
1931
+ COALESCE(stats.message_bytes, 0) AS message_bytes,
1932
+ COALESCE(parts.last_part_at, 0) AS last_part_at,
1933
+ COALESCE(parts.part_bytes, 0) AS part_bytes,
1874
1934
  stats.token_count AS token_count,
1875
1935
  stats.output_tokens AS output_tokens,
1876
1936
  COALESCE(stats.has_token_data, 0) AS has_token_data
1877
1937
  FROM session s
1938
+ LEFT JOIN (
1939
+ SELECT
1940
+ session_id,
1941
+ MAX(time_created) AS last_part_at,
1942
+ SUM(LENGTH(CAST(data AS BLOB))) AS part_bytes
1943
+ FROM part
1944
+ GROUP BY session_id
1945
+ ) parts ON parts.session_id = s.id
1878
1946
  LEFT JOIN (
1879
1947
  SELECT
1880
1948
  session_id,
1881
1949
  COUNT(*) AS message_count,
1950
+ MAX(time_created) AS last_message_at,
1951
+ SUM(LENGTH(CAST(data AS BLOB))) AS message_bytes,
1882
1952
  SUM(
1883
1953
  COALESCE(json_extract(data, '$.tokens.input'), 0) +
1884
1954
  COALESCE(json_extract(data, '$.tokens.output'), 0) +
@@ -1896,16 +1966,42 @@ async function scanOpenCodeIncremental() {
1896
1966
  LIMIT 1000;
1897
1967
  `.replace(/\n/g, ' ');
1898
1968
  db = new Database(OPENCODE_DB);
1969
+ const account = getOpenCodeAccount(db);
1899
1970
  const rows = db.prepare(query).all();
1900
- const entries = [];
1901
- for (const row of rows) {
1971
+ // Two passes. First derive each row's identity + per-session stamp, then
1972
+ // bulk-load the prior stamps in ONE query and keep only the rows that
1973
+ // actually changed. Skipped rows never reach upsertSessionsBatch, so they
1974
+ // never pay its per-entry `parseSession` re-open of this same DB.
1975
+ const asInt = (v) => typeof v === 'number' ? v : parseInt(String(v), 10);
1976
+ const candidates = rows.flatMap(row => {
1902
1977
  const id = typeof row.id === 'string' ? row.id : '';
1903
1978
  if (!id)
1904
- continue;
1979
+ return [];
1980
+ const filePath = `${OPENCODE_DB}#${id}`;
1981
+ return [{
1982
+ row,
1983
+ id,
1984
+ filePath,
1985
+ scan: openCodeSessionStamp({
1986
+ timeUpdated: asInt(row.time_updated),
1987
+ timeCreated: asInt(row.time_created),
1988
+ lastMessageAt: asInt(row.last_message_at),
1989
+ lastPartAt: asInt(row.last_part_at),
1990
+ }, { messageBytes: asInt(row.message_bytes), partBytes: asInt(row.part_bytes) }, currentScan),
1991
+ }];
1992
+ });
1993
+ const priorStamps = getScanStampsForPaths(candidates.map(c => c.filePath));
1994
+ const changed = candidates.filter(c => {
1995
+ const prevStamp = priorStamps.get(c.filePath);
1996
+ return !prevStamp
1997
+ || prevStamp.fileMtimeMs !== c.scan.fileMtimeMs
1998
+ || prevStamp.fileSize !== c.scan.fileSize;
1999
+ });
2000
+ const entries = [];
2001
+ for (const { row, id, filePath, scan } of changed) {
1905
2002
  const title = typeof row.title === 'string' ? row.title : '';
1906
2003
  const directory = typeof row.directory === 'string' ? row.directory : '';
1907
2004
  const version = typeof row.version === 'string' ? row.version : '';
1908
- const asInt = (v) => typeof v === 'number' ? v : parseInt(String(v), 10);
1909
2005
  const timeCreated = asInt(row.time_created);
1910
2006
  const timeUpdated = asInt(row.time_updated);
1911
2007
  const messageCount = asInt(row.message_count);
@@ -1926,7 +2022,7 @@ async function scanOpenCodeIncremental() {
1926
2022
  lastActivity,
1927
2023
  project: directory ? path.basename(directory) : undefined,
1928
2024
  cwd: directory ? normalizeCwd(directory) : undefined,
1929
- filePath: `${OPENCODE_DB}#${id}`,
2025
+ filePath,
1930
2026
  version: resolveSessionVersion('opencode', OPENCODE_DB, version || undefined, currentVersion),
1931
2027
  account,
1932
2028
  topic,
@@ -1934,7 +2030,7 @@ async function scanOpenCodeIncremental() {
1934
2030
  tokenCount: hasTokenData && !Number.isNaN(tokenCount) ? tokenCount : undefined,
1935
2031
  outputTokens: hasTokenData && !Number.isNaN(outputTokens) ? outputTokens : undefined,
1936
2032
  };
1937
- entries.push({ meta, content: topic || '', scan: currentScan });
2033
+ entries.push({ meta, content: topic || '', scan });
1938
2034
  }
1939
2035
  upsertSessionsBatch(entries);
1940
2036
  // Stamp the OpenCode DB itself so we can short-circuit on the next run.
@@ -3902,7 +3998,7 @@ async function scanCursorIncremental(onProgress) {
3902
3998
  const result = readCursorMeta(filePath, currentVersion);
3903
3999
  if (result && !seen.has(result.meta.id)) {
3904
4000
  seen.add(result.meta.id);
3905
- entries.push({ meta: result.meta, content: result.content, scan });
4001
+ entries.push({ meta: result.meta, content: result.content, scan, events: result.events });
3906
4002
  }
3907
4003
  else {
3908
4004
  touched.push({ filePath, scan });
@@ -3998,7 +4094,7 @@ export function readCursorMeta(filePath, currentVersion) {
3998
4094
  messageCount: events.filter((event) => event.type === 'message').length,
3999
4095
  todos: extractTodoProgressFromEvents(events),
4000
4096
  };
4001
- return { meta, content: userTexts.join('\n') };
4097
+ return { meta, content: userTexts.join('\n'), events };
4002
4098
  }
4003
4099
  // ---------------------------------------------------------------------------
4004
4100
  // Kimi
@@ -71,6 +71,10 @@ export interface InsightFacets {
71
71
  assistantTurns: number;
72
72
  toolCount: number;
73
73
  errorCount: number;
74
+ /** Deterministic evidence buckets used by the actions-forward report. */
75
+ frictionSignals: Record<string, number>;
76
+ correctionSignals: Record<string, number>;
77
+ automationSignals: Record<string, number>;
74
78
  }
75
79
  /**
76
80
  * Compute every behavioural facet of one session from its parsed events.
@@ -124,3 +128,17 @@ export declare function topEntries(counts: Record<string, number>, limit: number
124
128
  name: string;
125
129
  count: number;
126
130
  }>;
131
+ export type InsightActionCategory = 'rule' | 'skill' | 'automation' | 'product';
132
+ export interface InsightAction {
133
+ priority: 'high' | 'medium' | 'low';
134
+ category: InsightActionCategory;
135
+ action: string;
136
+ evidenceCount: number;
137
+ sampleSessionIds: string[];
138
+ }
139
+ export interface SessionFacetEvidence {
140
+ id: string;
141
+ facets: InsightFacets;
142
+ }
143
+ /** Build a stable, evidence-backed action list without exposing transcript text. */
144
+ export declare function buildInsightActions(sessions: SessionFacetEvidence[]): InsightAction[];
@@ -65,6 +65,7 @@ function emptyFacets() {
65
65
  shellCommandsSeen: 0,
66
66
  messageHours: new Array(24).fill(0), userTurns: 0, assistantTurns: 0,
67
67
  toolCount: 0, errorCount: 0,
68
+ frictionSignals: {}, correctionSignals: {}, automationSignals: {},
68
69
  };
69
70
  }
70
71
  function bump(map, key, by = 1) {
@@ -145,6 +146,7 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
145
146
  // a reply latency. It censors 5.5% of gaps, and `gapsOverCeiling` reports how many so
146
147
  // the number is never quietly truncated.
147
148
  let lastAssistantTs = null;
149
+ let lastFailedTool = null;
148
150
  for (const e of events) {
149
151
  const ts = new Date(e.timestamp).getTime();
150
152
  const hasTs = !Number.isNaN(ts);
@@ -153,13 +155,21 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
153
155
  f.interruptions++;
154
156
  break;
155
157
  case 'usage':
156
- // shortenModel so the label matches `agents sessions <id>` and `trends`
158
+ // shortenModel so the label matches `agents sessions <id>` and `insights mix`
157
159
  // rather than printing the raw id beside their shortened one.
158
160
  if (e.model)
159
161
  bump(f.models, shortenModel(e.model));
160
162
  break;
161
163
  case 'error':
162
164
  bump(f.errorCategories, categorizeError(e.content ?? e.output ?? ''));
165
+ if (e.tool && e.tool === lastFailedTool)
166
+ bump(f.frictionSignals, `failed tool loop: ${e.tool}`);
167
+ lastFailedTool = e.tool ?? null;
168
+ classifyFriction(e.content ?? e.output ?? '', f.frictionSignals);
169
+ break;
170
+ case 'tool_result':
171
+ if (e.success !== false && e.outcome !== 'error')
172
+ lastFailedTool = null;
163
173
  break;
164
174
  case 'message':
165
175
  if (e.role === 'assistant') {
@@ -169,6 +179,8 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
169
179
  }
170
180
  if (e.role !== 'user')
171
181
  break;
182
+ if (!e._synthetic)
183
+ classifyCorrection(e.content ?? '', f.correctionSignals);
172
184
  if (hasTs) {
173
185
  // Local-time hour. parse.ts falls back to `new Date()` for a record with no
174
186
  // timestamp; those are indistinguishable here, but they are rare and would
@@ -197,6 +209,8 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
197
209
  lastAssistantTs = ts;
198
210
  const args = e.args ?? {};
199
211
  const toolName = e.tool ?? '';
212
+ if (/askuserquestion/i.test(toolName))
213
+ classifyAskStall(args, f.correctionSignals);
200
214
  // Keyed on the SHARED cross-harness vocabulary, not Claude's literals. Keying
201
215
  // on 'Edit'|'MultiEdit'|'Write' meant codex (whose vocabulary is exec /
202
216
  // exec_command / write_stdin) reported 5,197 tool calls and exactly zero lines
@@ -218,6 +232,7 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
218
232
  f.shellCommandsSeen++;
219
233
  f.gitCommits += countGitOp(e.command, 'commit');
220
234
  f.gitPushes += countGitOp(e.command, 'push');
235
+ classifyAutomation(e.command, f.automationSignals);
221
236
  }
222
237
  break;
223
238
  }
@@ -228,6 +243,59 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
228
243
  }
229
244
  return f;
230
245
  }
246
+ const FRICTION_PATTERNS = [
247
+ [/blocked by|guard(?:rail)? (?:blocked|denied)|permission denied/i, 'blocked guard'],
248
+ [/\b(?:ci|check|workflow)\b.*\b(?:red|fail(?:ed|ure)?)\b|\b(?:red|failed)\b.*\bci\b/i, 'CI red loop'],
249
+ [/merge conflict|conflict in |CONFLICT \(/i, 'merge conflict'],
250
+ ];
251
+ const CORRECTION_PATTERNS = [
252
+ [/\b(?:continue|keep going|don'?t stop)\b/i, 'continue / keep going'],
253
+ [/\b(?:yes|go ahead|do it|merge it)\b/i, 'approval repeated'],
254
+ [/\b(?:are we done|done end.to.end|what(?:'s| is) left)\b/i, 'done end-to-end?'],
255
+ [/\b(?:did you merge|merged\??)\b/i, 'did you merge?'],
256
+ [/\bwhat(?:'s| is) next\??\b/i, "what's next?"],
257
+ [/\b(?:check now|check again|try now|did it work)\b/i, 'check now'],
258
+ [/\b(?:don'?t ask|just do it|run what)\b/i, "don't ask / just do it"],
259
+ ];
260
+ const AUTOMATION_PATTERNS = [
261
+ [/\bgh pr (?:checks|view|merge)\b/i, 'PR babysitting'],
262
+ [/\bagents secrets (?:list|exec|unlock|export)\b/i, 'secrets unlock dance'],
263
+ [/\bgit (?:fetch|rebase|merge|push)\b/i, 'git reconcile recipe'],
264
+ [/\b(?:scp|rsync|agents ssh)\b/i, 'fleet file transfer'],
265
+ [/\b(?:release\.sh|deploy\.sh|npm publish)\b/i, 'release / deploy recipe'],
266
+ ];
267
+ function classifyFriction(text, counts) {
268
+ for (const [pattern, label] of FRICTION_PATTERNS)
269
+ if (pattern.test(text))
270
+ bump(counts, label);
271
+ }
272
+ function classifyCorrection(text, counts) {
273
+ const normalized = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
274
+ for (const [pattern, label] of CORRECTION_PATTERNS)
275
+ if (pattern.test(normalized))
276
+ bump(counts, label);
277
+ }
278
+ function classifyAskStall(args, counts) {
279
+ const text = JSON.stringify(args).toLowerCase();
280
+ const categories = [
281
+ [/release|ship|deploy|publish/, 'Ask stall: release / ship / deploy'],
282
+ [/what'?s next|next step|next move/, "Ask stall: what's next?"],
283
+ [/merge|reconcile|rebase/, 'Ask stall: merge / reconcile'],
284
+ [/direction|approach|implementation/, 'Ask stall: direction / approach'],
285
+ ];
286
+ for (const [pattern, label] of categories) {
287
+ if (pattern.test(text)) {
288
+ bump(counts, label);
289
+ return;
290
+ }
291
+ }
292
+ bump(counts, 'AskUserQuestion');
293
+ }
294
+ function classifyAutomation(command, counts) {
295
+ for (const [pattern, label] of AUTOMATION_PATTERNS)
296
+ if (pattern.test(command))
297
+ bump(counts, label);
298
+ }
231
299
  /** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
232
300
  export function percentile(values, p) {
233
301
  if (values.length === 0)
@@ -298,6 +366,12 @@ export function mergeFacets(into, add) {
298
366
  bump(into.slashCommands, k, v);
299
367
  for (const [k, v] of Object.entries(add.errorCategories))
300
368
  bump(into.errorCategories, k, v);
369
+ for (const [k, v] of Object.entries(add.frictionSignals ?? {}))
370
+ bump(into.frictionSignals, k, v);
371
+ for (const [k, v] of Object.entries(add.correctionSignals ?? {}))
372
+ bump(into.correctionSignals, k, v);
373
+ for (const [k, v] of Object.entries(add.automationSignals ?? {}))
374
+ bump(into.automationSignals, k, v);
301
375
  into.interruptions += add.interruptions;
302
376
  into.responseGaps.push(...add.responseGaps);
303
377
  into.gapsOverCeiling += add.gapsOverCeiling;
@@ -328,3 +402,71 @@ export function topEntries(counts, limit) {
328
402
  .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
329
403
  .slice(0, limit);
330
404
  }
405
+ /** Build a stable, evidence-backed action list without exposing transcript text. */
406
+ export function buildInsightActions(sessions) {
407
+ const specs = [
408
+ { source: 'correctionSignals', label: 'continue / keep going', category: 'rule', action: 'Keep working through the delivery chain without waiting for another “continue”.' },
409
+ { source: 'correctionSignals', label: 'approval repeated', category: 'rule', action: 'Treat the original build or ship request as authorization for routine follow-through.' },
410
+ { source: 'correctionSignals', label: 'done end-to-end?', category: 'rule', action: 'Verify the user-visible outcome before declaring the task complete.' },
411
+ { source: 'correctionSignals', label: 'did you merge?', category: 'automation', action: 'Automate PR review, CI watching, and merge-on-green as one durable workflow.' },
412
+ { source: 'correctionSignals', label: "what's next?", category: 'rule', action: 'State and execute the next in-scope step instead of asking the owner to steer implementation.' },
413
+ { source: 'correctionSignals', label: 'check now', category: 'automation', action: 'Add bounded status polling with a terminal success or failure signal.' },
414
+ { source: 'correctionSignals', label: "don't ask / just do it", category: 'rule', action: 'Reserve questions for genuine product or scope choices.' },
415
+ { source: 'correctionSignals', label: 'Ask stall: release / ship / deploy', category: 'skill', action: 'Teach release workflows to carry publish, tag, rollout, and live verification as one chain.' },
416
+ { source: 'correctionSignals', label: "Ask stall: what's next?", category: 'rule', action: 'Remove workflow-stall “what next?” prompts from agent guidance.' },
417
+ { source: 'correctionSignals', label: 'Ask stall: merge / reconcile', category: 'skill', action: 'Encode the safe merge and reconcile path in the git workflow skill.' },
418
+ { source: 'correctionSignals', label: 'Ask stall: direction / approach', category: 'rule', action: 'Let agents choose implementation details after scope is clear.' },
419
+ { source: 'frictionSignals', label: 'blocked guard', category: 'product', action: 'Make guard failures return the safe next command and exact blocked operation.' },
420
+ { source: 'frictionSignals', label: 'CI red loop', category: 'automation', action: 'Deduplicate CI watchers and turn repeated red checks into one stateful wait.' },
421
+ { source: 'frictionSignals', label: 'merge conflict', category: 'skill', action: 'Standardize conflict diagnosis and fix-forward reconciliation.' },
422
+ { source: 'automationSignals', label: 'PR babysitting', category: 'automation', action: 'Bundle PR checks, review collection, comment handling, and merge-on-green.' },
423
+ { source: 'automationSignals', label: 'secrets unlock dance', category: 'product', action: 'Provide one headless secrets-backed command path for repeated credential operations.' },
424
+ { source: 'automationSignals', label: 'git reconcile recipe', category: 'skill', action: 'Promote repeated git reconciliation commands into the canonical workflow.' },
425
+ { source: 'automationSignals', label: 'fleet file transfer', category: 'product', action: 'Add a first-class fleet file transfer command with host/path validation.' },
426
+ { source: 'automationSignals', label: 'release / deploy recipe', category: 'automation', action: 'Turn repeated release shell recipes into a checked-in release script.' },
427
+ ];
428
+ const actions = [];
429
+ for (const spec of specs) {
430
+ let evidenceCount = 0;
431
+ const ids = [];
432
+ for (const session of sessions) {
433
+ const count = session.facets[spec.source]?.[spec.label] ?? 0;
434
+ if (count <= 0)
435
+ continue;
436
+ evidenceCount += count;
437
+ if (ids.length < 3)
438
+ ids.push(session.id.slice(0, 8));
439
+ }
440
+ if (evidenceCount === 0)
441
+ continue;
442
+ actions.push({
443
+ priority: evidenceCount >= 10 ? 'high' : evidenceCount >= 3 ? 'medium' : 'low',
444
+ category: spec.category,
445
+ action: spec.action,
446
+ evidenceCount,
447
+ sampleSessionIds: ids,
448
+ });
449
+ }
450
+ let failedLoopCount = 0;
451
+ const failedLoopIds = [];
452
+ for (const session of sessions) {
453
+ const count = Object.entries(session.facets.frictionSignals ?? {})
454
+ .filter(([label]) => label.startsWith('failed tool loop:'))
455
+ .reduce((sum, [, value]) => sum + value, 0);
456
+ if (count <= 0)
457
+ continue;
458
+ failedLoopCount += count;
459
+ if (failedLoopIds.length < 3)
460
+ failedLoopIds.push(session.id.slice(0, 8));
461
+ }
462
+ if (failedLoopCount > 0) {
463
+ actions.push({
464
+ priority: failedLoopCount >= 10 ? 'high' : failedLoopCount >= 3 ? 'medium' : 'low',
465
+ category: 'automation',
466
+ action: 'Detect repeated failures of the same tool and stop the retry loop with a different recovery path.',
467
+ evidenceCount: failedLoopCount,
468
+ sampleSessionIds: failedLoopIds,
469
+ });
470
+ }
471
+ return actions.sort((a, b) => b.evidenceCount - a.evidenceCount || a.action.localeCompare(b.action));
472
+ }