@phnx-labs/agents-cli 1.21.1 → 1.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +28 -19
  6. package/dist/commands/hooks.js +9 -45
  7. package/dist/commands/menubar.js +24 -24
  8. package/dist/commands/message.js +23 -3
  9. package/dist/commands/perf.d.ts +13 -0
  10. package/dist/commands/perf.js +80 -23
  11. package/dist/commands/projects.d.ts +11 -0
  12. package/dist/commands/projects.js +153 -21
  13. package/dist/commands/routines.js +46 -1
  14. package/dist/commands/ssh.js +69 -0
  15. package/dist/commands/trends.d.ts +2 -0
  16. package/dist/commands/trends.js +158 -0
  17. package/dist/commands/usage.d.ts +4 -4
  18. package/dist/commands/view.d.ts +6 -0
  19. package/dist/commands/view.js +90 -45
  20. package/dist/index.js +14 -1
  21. package/dist/lib/agents.js +2 -2
  22. package/dist/lib/analytics/dashboard.d.ts +11 -0
  23. package/dist/lib/analytics/dashboard.js +31 -0
  24. package/dist/lib/analytics/recipes.d.ts +32 -0
  25. package/dist/lib/analytics/recipes.js +316 -0
  26. package/dist/lib/analytics/usage-db.d.ts +84 -0
  27. package/dist/lib/analytics/usage-db.js +301 -0
  28. package/dist/lib/browser/service.js +18 -0
  29. package/dist/lib/cli-resources.d.ts +20 -0
  30. package/dist/lib/cli-resources.js +48 -1
  31. package/dist/lib/daemon.js +51 -14
  32. package/dist/lib/devices/health-report.d.ts +5 -0
  33. package/dist/lib/devices/health-report.js +3 -0
  34. package/dist/lib/feed-broadcast.d.ts +52 -7
  35. package/dist/lib/feed-broadcast.js +125 -18
  36. package/dist/lib/fleet-cache.d.ts +37 -0
  37. package/dist/lib/fleet-cache.js +40 -0
  38. package/dist/lib/fleet-status.d.ts +53 -0
  39. package/dist/lib/fleet-status.js +120 -0
  40. package/dist/lib/friction-heuristics.d.ts +32 -0
  41. package/dist/lib/friction-heuristics.js +47 -0
  42. package/dist/lib/hooks/cache.js +28 -6
  43. package/dist/lib/hooks/profile.d.ts +8 -0
  44. package/dist/lib/hooks/profile.js +14 -4
  45. package/dist/lib/hooks.js +72 -17
  46. package/dist/lib/linear-cache.d.ts +63 -0
  47. package/dist/lib/linear-cache.js +146 -0
  48. package/dist/lib/linear-project-counts.d.ts +35 -5
  49. package/dist/lib/linear-project-counts.js +61 -16
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  54. package/dist/lib/menubar/install-menubar.js +36 -6
  55. package/dist/lib/perf/db.d.ts +6 -1
  56. package/dist/lib/perf/db.js +35 -5
  57. package/dist/lib/perf/types.d.ts +10 -0
  58. package/dist/lib/project-doctor.d.ts +36 -0
  59. package/dist/lib/project-doctor.js +45 -0
  60. package/dist/lib/project-import.d.ts +11 -1
  61. package/dist/lib/project-import.js +17 -3
  62. package/dist/lib/project-status.d.ts +25 -5
  63. package/dist/lib/project-status.js +48 -6
  64. package/dist/lib/rotate.d.ts +27 -0
  65. package/dist/lib/rotate.js +44 -17
  66. package/dist/lib/routines.d.ts +16 -0
  67. package/dist/lib/routines.js +39 -0
  68. package/dist/lib/runner.js +34 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  70. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  71. package/dist/lib/secrets/usage-db.d.ts +3 -63
  72. package/dist/lib/secrets/usage-db.js +46 -186
  73. package/dist/lib/session/db.d.ts +2 -1
  74. package/dist/lib/session/db.js +14 -3
  75. package/dist/lib/session/discover.d.ts +3 -0
  76. package/dist/lib/session/discover.js +8 -0
  77. package/dist/lib/session/types.d.ts +1 -0
  78. package/dist/lib/startup/command-registry.d.ts +1 -0
  79. package/dist/lib/startup/command-registry.js +2 -0
  80. package/dist/lib/state.d.ts +31 -3
  81. package/dist/lib/state.js +53 -10
  82. package/dist/lib/types.d.ts +8 -4
  83. package/dist/lib/usage-refresh.d.ts +106 -0
  84. package/dist/lib/usage-refresh.js +238 -0
  85. package/dist/lib/usage.d.ts +152 -17
  86. package/dist/lib/usage.js +393 -79
  87. package/package.json +1 -1
@@ -6,6 +6,7 @@
6
6
  * agents perf hooks per-hook p50/p99 + cache hit rates
7
7
  * agents perf commands slowest CLI command paths (from command.end)
8
8
  * agents perf run agent.run / perf.timing labels
9
+ * agents perf friction sessions stuck repeatedly hitting the same guard
9
10
  *
10
11
  * Soft-joins sessions.db via shared string keys (session_id, agent, machine) —
11
12
  * no foreign keys. Warehouse lives at ~/.agents/.cache/perf/perf.db (safe to wipe).
@@ -13,6 +14,8 @@
13
14
  import chalk from 'chalk';
14
15
  import { aggregateSamples, perfDbPath, } from '../lib/perf/db.js';
15
16
  import { formatMs, formatCacheColumn, DEFAULT_SLOW_HOOK_WARN_MS, loadHookFireEvents, aggregateHookProfile, } from '../lib/hooks/profile.js';
17
+ import { query } from '../lib/events.js';
18
+ import { detectRepeatedGuardBlocks } from '../lib/friction-heuristics.js';
16
19
  function parseDays(raw) {
17
20
  const n = parseInt(raw ?? '7', 10);
18
21
  return Number.isFinite(n) && n > 0 ? n : 7;
@@ -26,11 +29,12 @@ function parseLimit(raw, fallback) {
26
29
  return Number.isFinite(n) && n > 0 ? n : fallback;
27
30
  }
28
31
  /** Map warehouse rows shaped like hook.fire into the existing HookProfileRow UI. */
29
- function asHookRows(rows) {
32
+ export function asHookRows(rows) {
30
33
  return rows.map((r) => ({
31
34
  hook: r.label,
32
35
  n: r.n,
33
36
  p50Ms: r.p50Ms,
37
+ p95Ms: r.p95Ms,
34
38
  p99Ms: r.p99Ms,
35
39
  meanMs: r.meanMs,
36
40
  maxMs: r.maxMs,
@@ -38,6 +42,9 @@ function asHookRows(rows) {
38
42
  cacheStalePct: r.cacheStalePct ?? 0,
39
43
  cacheMissPct: r.cacheMissPct ?? 0,
40
44
  errorCount: r.errorCount ?? 0,
45
+ errorRate: r.errorRate,
46
+ timeoutRate: r.timeoutRate,
47
+ project: r.project,
41
48
  }));
42
49
  }
43
50
  function printTable(headers, widths, lines, slowFlags) {
@@ -50,23 +57,34 @@ function printTable(headers, widths, lines, slowFlags) {
50
57
  console.log(slowFlags[i] ? chalk.yellow(line) : line);
51
58
  }
52
59
  }
53
- function renderHookTable(rows, warnMs) {
60
+ /** `err:12% to:4%` when either rate is present, else ''. */
61
+ function formatRateColumn(r) {
62
+ const parts = [];
63
+ if (r.errorRate)
64
+ parts.push(`err:${Math.round(r.errorRate * 100)}%`);
65
+ if (r.timeoutRate)
66
+ parts.push(`to:${Math.round(r.timeoutRate * 100)}%`);
67
+ return parts.join(' ');
68
+ }
69
+ export function renderHookTable(rows, warnMs) {
54
70
  if (rows.length === 0) {
55
71
  console.log(chalk.gray('No hook timing samples yet.'));
56
72
  console.log(chalk.gray(`Warehouse: ${perfDbPath()}`));
57
73
  console.log(chalk.gray('Hooks write via cache/matches shims into the spool; run a session or resync hooks.'));
58
74
  return;
59
75
  }
60
- const widths = { hook: 36, n: 5, p50: 7, p99: 7, mean: 7, max: 7, cache: 28 };
76
+ const widths = { hook: 36, n: 5, p50: 7, p95: 7, p99: 7, mean: 7, max: 7, cache: 22, rate: 14 };
61
77
  const pad = (s, w) => (s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length));
62
78
  const header = [
63
79
  pad('HOOK', widths.hook),
64
80
  pad('N', widths.n),
65
81
  pad('P50', widths.p50),
82
+ pad('P95', widths.p95),
66
83
  pad('P99', widths.p99),
67
84
  pad('MEAN', widths.mean),
68
85
  pad('MAX', widths.max),
69
86
  pad('CACHE', widths.cache),
87
+ pad('ERR/TIMEOUT', widths.rate),
70
88
  ].join(' ');
71
89
  console.log(chalk.bold(header));
72
90
  console.log(chalk.gray('─'.repeat(header.length)));
@@ -78,10 +96,12 @@ function renderHookTable(rows, warnMs) {
78
96
  pad(r.hook, widths.hook),
79
97
  pad(String(r.n), widths.n),
80
98
  pad(formatMs(r.p50Ms), widths.p50),
99
+ pad(formatMs(r.p95Ms), widths.p95),
81
100
  pad(formatMs(r.p99Ms), widths.p99),
82
101
  pad(formatMs(r.meanMs), widths.mean),
83
102
  pad(formatMs(r.maxMs), widths.max),
84
103
  pad(cacheCol, widths.cache),
104
+ pad(formatRateColumn(r), widths.rate),
85
105
  ].join(' ') + warning;
86
106
  console.log(slow ? chalk.yellow(line) : line);
87
107
  }
@@ -92,30 +112,34 @@ function renderLabelTable(title, rows, warnMs, limit) {
92
112
  console.log(chalk.gray(`No ${title} samples yet.`));
93
113
  return;
94
114
  }
95
- const widths = [40, 5, 7, 7, 7, 7];
96
- printTable(['LABEL', 'N', 'P50', 'P99', 'MEAN', 'MAX'], widths, sliced.map((r) => [
115
+ const widths = [40, 5, 7, 7, 7, 7, 7, 14];
116
+ printTable(['LABEL', 'N', 'P50', 'P95', 'P99', 'MEAN', 'MAX', 'ERR/TIMEOUT'], widths, sliced.map((r) => [
97
117
  r.label,
98
118
  String(r.n),
99
119
  formatMs(r.p50Ms),
120
+ formatMs(r.p95Ms),
100
121
  formatMs(r.p99Ms),
101
122
  formatMs(r.meanMs),
102
123
  formatMs(r.maxMs),
124
+ formatRateColumn(r),
103
125
  ]), sliced.map((r) => r.p99Ms > warnMs));
104
126
  }
105
127
  /**
106
128
  * Prefer SQLite samples; fall back to the legacy daily JSONL so existing
107
- * instrumentation still surfaces until shims are resynced.
129
+ * instrumentation still surfaces until shims are resynced. `project` only
130
+ * narrows the SQLite path — the legacy JSONL log has no cwd, so a fallback
131
+ * hit ignores it (a caller filtering by project has no legacy rows to miss).
108
132
  */
109
- function loadHookProfile(days) {
110
- const fromDb = asHookRows(aggregateSamples({ days, kinds: ['hook.fire'] }));
111
- if (fromDb.length > 0)
133
+ export function loadHookProfile(days, project) {
134
+ const fromDb = asHookRows(aggregateSamples({ days, kinds: ['hook.fire'], project }));
135
+ if (fromDb.length > 0 || project)
112
136
  return fromDb;
113
137
  return aggregateHookProfile(loadHookFireEvents(days));
114
138
  }
115
139
  function hooksAction(opts) {
116
140
  const days = parseDays(opts.days);
117
141
  const warnMs = parseWarnMs(opts.warnMs, DEFAULT_SLOW_HOOK_WARN_MS);
118
- const rows = loadHookProfile(days);
142
+ const rows = loadHookProfile(days, opts.project);
119
143
  if (opts.json) {
120
144
  console.log(JSON.stringify(rows, null, 2));
121
145
  return;
@@ -126,7 +150,7 @@ function commandsAction(opts) {
126
150
  const days = parseDays(opts.days);
127
151
  const warnMs = parseWarnMs(opts.warnMs, 500);
128
152
  const limit = parseLimit(opts.limit, 40);
129
- const rows = aggregateSamples({ days, kinds: ['command.end'] });
153
+ const rows = aggregateSamples({ days, kinds: ['command.end'], project: opts.project });
130
154
  if (opts.json) {
131
155
  console.log(JSON.stringify(rows.slice(0, limit), null, 2));
132
156
  return;
@@ -137,42 +161,69 @@ function runAction(opts) {
137
161
  const days = parseDays(opts.days);
138
162
  const warnMs = parseWarnMs(opts.warnMs, 60_000);
139
163
  const limit = parseLimit(opts.limit, 40);
140
- const rows = aggregateSamples({ days, kinds: ['perf.timing'] });
164
+ const rows = aggregateSamples({ days, kinds: ['perf.timing'], project: opts.project });
141
165
  if (opts.json) {
142
166
  console.log(JSON.stringify(rows.slice(0, limit), null, 2));
143
167
  return;
144
168
  }
145
169
  renderLabelTable('run/timing', rows, warnMs, limit);
146
170
  }
171
+ /**
172
+ * Sessions stuck repeatedly hitting the SAME guard block (git-guard,
173
+ * rm-guard, git-require-clean-tree, …) instead of adapting after the first
174
+ * denial. Reads the `friction` event sink (emitFriction in events.ts) that
175
+ * guard hooks self-report into via `agents _internal friction` before they
176
+ * exit 2 — see lib/friction-heuristics.ts for the grouping.
177
+ */
178
+ function frictionAction(opts) {
179
+ const days = parseDays(opts.days);
180
+ const startDate = new Date(Date.now() - days * 86_400_000);
181
+ const events = query({ eventTypes: ['friction'], startDate });
182
+ const findings = detectRepeatedGuardBlocks(events);
183
+ if (opts.json) {
184
+ console.log(JSON.stringify(findings, null, 2));
185
+ return;
186
+ }
187
+ if (findings.length === 0) {
188
+ console.log(chalk.gray(`No repeated guard blocks in the last ${days} day${days === 1 ? '' : 's'}.`));
189
+ return;
190
+ }
191
+ console.log(chalk.bold(`Repeated guard blocks — last ${days} day${days === 1 ? '' : 's'}\n`));
192
+ const widths = [14, 10, 24, 5, 22, 22];
193
+ printTable(['SESSION', 'SURFACE', 'FAILURE ID', 'N', 'FIRST', 'LAST'], widths, findings.map((f) => [f.session, f.surface, f.failureId, String(f.count), f.firstTs, f.lastTs]), findings.map(() => false));
194
+ }
147
195
  function summaryAction(opts) {
148
196
  const days = parseDays(opts.days);
197
+ const project = opts.project;
149
198
  if (opts.json) {
150
199
  console.log(JSON.stringify({
151
200
  days,
201
+ project: project ?? null,
152
202
  warehouse: perfDbPath(),
153
- hooks: loadHookProfile(days),
154
- commands: aggregateSamples({ days, kinds: ['command.end'] }).slice(0, 20),
155
- run: aggregateSamples({ days, kinds: ['perf.timing'] }).slice(0, 20),
203
+ hooks: loadHookProfile(days, project),
204
+ commands: aggregateSamples({ days, kinds: ['command.end'], project }).slice(0, 20),
205
+ run: aggregateSamples({ days, kinds: ['perf.timing'], project }).slice(0, 20),
156
206
  }, null, 2));
157
207
  return;
158
208
  }
159
- console.log(chalk.bold(`agents perf — last ${days} day${days === 1 ? '' : 's'}`));
209
+ console.log(chalk.bold(`agents perf — last ${days} day${days === 1 ? '' : 's'}${project ? ` — project ${project}` : ''}`));
160
210
  console.log(chalk.gray(`warehouse: ${perfDbPath()} (disposable; soft-join sessions via session_id/agent/machine)`));
161
211
  console.log('');
162
212
  console.log(chalk.bold('Commands (slowest by p99)'));
163
- renderLabelTable('command', aggregateSamples({ days, kinds: ['command.end'] }), parseWarnMs(opts.warnMs, 500), 12);
213
+ renderLabelTable('command', aggregateSamples({ days, kinds: ['command.end'], project }), parseWarnMs(opts.warnMs, 500), 12);
164
214
  console.log('');
165
215
  console.log(chalk.bold('Hooks'));
166
- renderHookTable(loadHookProfile(days), parseWarnMs(opts.warnMs, DEFAULT_SLOW_HOOK_WARN_MS));
216
+ renderHookTable(loadHookProfile(days, project), parseWarnMs(opts.warnMs, DEFAULT_SLOW_HOOK_WARN_MS));
167
217
  console.log('');
168
218
  console.log(chalk.bold('Runs (perf.timing)'));
169
- renderLabelTable('run/timing', aggregateSamples({ days, kinds: ['perf.timing'] }), parseWarnMs(opts.warnMs, 60_000), 12);
219
+ renderLabelTable('run/timing', aggregateSamples({ days, kinds: ['perf.timing'], project }), parseWarnMs(opts.warnMs, 60_000), 12);
170
220
  }
171
221
  function attachSharedOptions(cmd) {
172
222
  return cmd
173
223
  .option('--days <n>', 'Days of samples to include', '7')
174
224
  .option('--warn-ms <n>', 'p99 above this is highlighted')
175
225
  .option('--limit <n>', 'Max rows in the table', '40')
226
+ .option('--project <key>', 'Scope to one project (the repo directory name a sample\'s cwd resolves to — see project-key.ts)')
176
227
  .option('--json', 'Emit JSON instead of a table');
177
228
  }
178
229
  /**
@@ -195,11 +246,13 @@ Identity columns reuse sessions/events string shapes (session_id, agent, machine
195
246
  for soft cross-reference; there are no foreign keys.
196
247
 
197
248
  Examples:
198
- agents perf # summary: commands + hooks + runs
199
- agents perf hooks # per-hook p50/p99 + cache hit rate
200
- agents perf commands --days 30 # slowest CLI entrypoints
201
- agents perf run --json # agent.run timings as JSON
249
+ agents perf # summary: commands + hooks + runs
250
+ agents perf hooks # per-hook p50/p95/p99 + cache hit rate
251
+ agents perf commands --days 30 # slowest CLI entrypoints
252
+ agents perf run --json # agent.run timings as JSON
202
253
  agents perf hooks --warn-ms 500
254
+ agents perf hooks --project agents-cli # scope to one repo's samples
255
+ agents perf friction # sessions stuck retrying the same guard block
203
256
  `);
204
257
  // Options live on the parent so `agents perf --json` and
205
258
  // `agents perf commands --json` both work (see leafOpts).
@@ -218,4 +271,8 @@ Examples:
218
271
  .action(function run() {
219
272
  runAction(leafOpts(this));
220
273
  });
274
+ perf.command('friction').description('Sessions stuck repeatedly hitting the same guard block')
275
+ .action(function friction() {
276
+ frictionAction(leafOpts(this));
277
+ });
221
278
  }
@@ -20,6 +20,8 @@ export declare const PROJECTS_NO_FANOUT_ENV = "AGENTS_PROJECTS_LOCAL";
20
20
  * string when everything answered.
21
21
  */
22
22
  export declare function formatFleetSkippedNote(skipped: string[]): string;
23
+ /** Best-effort `owner/repo` from a repo's origin remote. */
24
+ export declare function originSlug(cwd: string): string | undefined;
23
25
  /** One `projects list` row, pre-render. */
24
26
  export interface ProjectListRow {
25
27
  name: string;
@@ -45,6 +47,15 @@ export declare function computeProjectListWidths(rows: ProjectListRow[]): {
45
47
  * read it as UTC and shift the answer by a day for anyone west of Greenwich.
46
48
  */
47
49
  export declare function formatMilestoneDue(targetDate: string, nowMs: number): string | undefined;
50
+ /**
51
+ * The milestone block. `status` prints one line — the next checkpoint — because
52
+ * a roll-up across projects has to stay scannable. `view` prints every declared
53
+ * milestone, because "how many are there and when are they due" is the shape of
54
+ * the plan and the reason to open one project.
55
+ *
56
+ * Pure (chalk only) so the layout is testable without a Linear account.
57
+ */
58
+ export declare function formatMilestoneLines(milestones: LinearMilestone[], next: LinearMilestone | undefined, nowMs: number, limit: number): string[];
48
59
  /** The `next` card line: what this project is due to hit, and how far along it is. */
49
60
  export declare function formatNextMilestone(ms: LinearMilestone, nowMs: number): string;
50
61
  export declare function registerProjectsCommands(program: Command): void;
@@ -25,9 +25,10 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
25
25
  import { factoryProjectsPath } from '../lib/auto-dispatch.js';
26
26
  import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
27
27
  import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
28
- import { rollupSessionsByProject, planPct, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
28
+ import { rollupSessionsByProject, liveDeadSplit, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
29
29
  import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
30
30
  import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
31
+ import { checkRepoSlug } from '../lib/project-doctor.js';
31
32
  import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
32
33
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
33
34
  export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
@@ -57,7 +58,7 @@ function parseContextFlag(raw) {
57
58
  return { path: raw.slice(0, i).trim(), purpose: raw.slice(i + 1).trim() };
58
59
  }
59
60
  /** Best-effort `owner/repo` from a repo's origin remote. */
60
- function originSlug(cwd) {
61
+ export function originSlug(cwd) {
61
62
  try {
62
63
  const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' }).trim();
63
64
  return parseOwnerRepoFromRemote(url) ?? undefined;
@@ -94,7 +95,9 @@ function runFactoryImport(existing, opts) {
94
95
  : Array.isArray(parsed?.projects)
95
96
  ? parsed.projects
96
97
  : [];
97
- return buildFactoryImportCandidates(rows, existing, opts);
98
+ // The checkout's real `origin` overrides the registry's path-derived slug —
99
+ // see buildFactoryImportCandidates for why the path guess is not trustworthy.
100
+ return buildFactoryImportCandidates(rows, existing, opts, (root) => originSlug(expandLocalHome(root)));
98
101
  }
99
102
  /**
100
103
  * List the workspace's Linear projects and plan their import, binding each to a
@@ -183,6 +186,40 @@ export function formatMilestoneDue(targetDate, nowMs) {
183
186
  });
184
187
  return `due ${label}`;
185
188
  }
189
+ /**
190
+ * The milestone block. `status` prints one line — the next checkpoint — because
191
+ * a roll-up across projects has to stay scannable. `view` prints every declared
192
+ * milestone, because "how many are there and when are they due" is the shape of
193
+ * the plan and the reason to open one project.
194
+ *
195
+ * Pure (chalk only) so the layout is testable without a Linear account.
196
+ */
197
+ export function formatMilestoneLines(milestones, next, nowMs, limit) {
198
+ if (milestones.length === 0) {
199
+ // `next` without a list only happens on a cached answer written before the
200
+ // list existed; render what we have rather than dropping the row.
201
+ return next ? [` ${chalk.dim('next')} ${formatNextMilestone(next, nowMs)}`] : [];
202
+ }
203
+ // The next milestone leads, always. Linear can flag a LATER-dated milestone
204
+ // as next, and `milestones` is date-ordered — so slicing the front would show
205
+ // an earlier one and hide the actual next behind "+N more", which is the one
206
+ // thing this row exists to say. Identity is name+targetDate: two milestones
207
+ // can share a name, and matching on name alone labelled the wrong row.
208
+ const key = (m) => `${m.name}${m.targetDate ?? ''}`;
209
+ const lead = next ? milestones.filter((m) => key(m) === key(next)).slice(0, 1) : [];
210
+ const others = next ? milestones.filter((m) => key(m) !== key(next)) : milestones;
211
+ const ordered = [...lead, ...others];
212
+ const shown = ordered.slice(0, Math.max(1, limit));
213
+ const out = shown.map((m, i) => {
214
+ const label = i === 0 && lead.length > 0 ? 'next' : i === 0 ? 'plan' : '';
215
+ return ` ${chalk.dim(label.padEnd(4))} ${formatNextMilestone(m, nowMs)}`;
216
+ });
217
+ const rest = milestones.length - shown.length;
218
+ if (rest > 0) {
219
+ out.push(` ${' '.repeat(4)} ${chalk.dim(`+${rest} more milestone${rest === 1 ? '' : 's'} — agents projects view <name>`)}`);
220
+ }
221
+ return out;
222
+ }
186
223
  /** The `next` card line: what this project is due to hit, and how far along it is. */
187
224
  export function formatNextMilestone(ms, nowMs) {
188
225
  const parts = [chalk.bold(ms.name)];
@@ -206,25 +243,39 @@ function statusBar(r) {
206
243
  push(r.byStatus.input_required, 'need-input', chalk.yellow);
207
244
  push(r.byStatus.queued, 'queued', chalk.gray);
208
245
  const shown = (r.byStatus.running ?? 0) + (r.byStatus.idle ?? 0) + (r.byStatus.input_required ?? 0) + (r.byStatus.queued ?? 0);
209
- // Sessions in a non-live state (orphaned/crashed/unknown) count toward the
210
- // headline render the remainder so the bar never sums to less than it.
211
- if (r.agents > shown)
212
- parts.push(chalk.gray(`+${r.agents - shown} other`));
246
+ // The remainder is the LIVE sessions in a state without its own chip
247
+ // (orphaned, unknown). Dead ones are on their own row now, so counting them
248
+ // here made the line disagree with the headline — `19 live` beside
249
+ // `+24 other`, which invites the reader to trust neither.
250
+ const live = liveDeadSplit(r.byStatus).live;
251
+ if (live > shown)
252
+ parts.push(chalk.gray(`+${live - shown} other`));
213
253
  return parts.join(' · ') || chalk.gray('no live agents');
214
254
  }
215
- function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
216
- const agents = r?.agents ?? 0;
217
- const pct = r ? planPct(r.plan) : undefined;
218
- const planStr = pct === undefined ? '' : ` · ${chalk.cyan(`${pct}% plan`)}`;
219
- console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${agents} agents`)}${planStr}`);
255
+ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now(),
256
+ /** How many milestones to print. `status` shows the next one; `view` shows all. */
257
+ milestoneLimit = 1) {
258
+ // The headline counts LIVE agents. It used to be every matched session, which
259
+ // read `39 agents` on a project where 19 had crashed. `planPct` used to sit
260
+ // here too and is gone: it summed each session's latest checklist snapshot,
261
+ // so one agent opening a fresh 40-item plan rendered the whole project `0%`.
262
+ const split = r ? liveDeadSplit(r.byStatus) : { live: 0, dead: 0, deadByStatus: [] };
263
+ console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${split.live} live`)}`);
220
264
  if (def.description)
221
265
  console.log(` ${chalk.dim(def.description)}`);
222
266
  console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
267
+ if (split.dead > 0) {
268
+ // Wreckage is worth a number of its own — 19 crashed sessions is a thing to
269
+ // go fix, not a throughput signal to fold into the headline.
270
+ const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
271
+ console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`);
272
+ }
223
273
  if (r && r.members.length)
224
274
  console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
225
275
  const ships = [];
226
- if (remote?.mergedPrs)
227
- ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
276
+ if (remote?.mergedPrs) {
277
+ ships.push(chalk.green(`${remote.mergedPrs}${remote.mergedPrsTruncated ? '+' : ''} merged (${remote.windowDays}d)`));
278
+ }
228
279
  if (r?.openPrs.length)
229
280
  ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
230
281
  if (r?.worktrees)
@@ -236,8 +287,8 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
236
287
  if (linear) {
237
288
  console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
238
289
  }
239
- if (linear?.nextMilestone) {
240
- console.log(` ${chalk.dim('next')} ${formatNextMilestone(linear.nextMilestone, nowMs)}`);
290
+ for (const line of formatMilestoneLines(linear?.milestones ?? [], linear?.nextMilestone, nowMs, milestoneLimit)) {
291
+ console.log(line);
241
292
  }
242
293
  if (r && r.tickets.length) {
243
294
  console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
@@ -258,6 +309,14 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
258
309
  const repos = [def.repo, ...(def.repos ?? []).map((x) => x.slug)].filter(Boolean);
259
310
  if (repos.length)
260
311
  console.log(` ${chalk.dim('repos')} ${[...new Set(repos)].join(' · ')}`);
312
+ // A def whose `repo` disagrees with the checkout's origin reads PR and release
313
+ // counts from a DIFFERENT repository — both slugs resolve, so nothing errors
314
+ // and the wrong numbers look right. Say it here, with the fix attached.
315
+ const mismatch = def.root ? checkRepoSlug(def, originSlug(expandLocalHome(def.root))) : undefined;
316
+ if (mismatch) {
317
+ console.log(` ${chalk.yellow('!')} ${chalk.yellow(mismatch.message)}`);
318
+ console.log(` ${' '.repeat(8)}${chalk.gray(mismatch.remediation)}`);
319
+ }
261
320
  if (def.contexts?.length) {
262
321
  console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
263
322
  }
@@ -381,17 +440,24 @@ export function registerProjectsCommands(program) {
381
440
  });
382
441
  // ---- show ----
383
442
  projects
384
- .command('show <name>')
385
- .description('Show a project definition, resolved paths, repos, contexts, and links.')
443
+ .command('view <name>')
444
+ .alias('show')
445
+ .description('Show a project definition, resolved paths, repos, contexts, links, and milestones.')
386
446
  .option('--json', 'Machine-readable output')
387
- .action((name, opts) => {
447
+ .option('--no-remote', 'Skip the Linear lookup; definition only')
448
+ .action(async (name, opts) => {
388
449
  const def = loadProjectDef(name);
389
450
  if (!def) {
390
451
  console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
391
452
  process.exit(1);
392
453
  }
454
+ // Every milestone the project declares, not just the next one — the whole
455
+ // point of opening ONE project is to see the shape of its plan.
456
+ const counts = opts.remote !== false && def.linear?.projectId
457
+ ? await fetchLinearProjectCounts(def.linear.projectId)
458
+ : undefined;
393
459
  if (opts.json) {
394
- console.log(JSON.stringify(def, null, 2));
460
+ console.log(JSON.stringify({ ...def, linear: { ...def.linear, ...(counts ?? {}) } }, null, 2));
395
461
  return;
396
462
  }
397
463
  console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
@@ -410,6 +476,24 @@ export function registerProjectsCommands(program) {
410
476
  console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
411
477
  for (const d of def.docs ?? [])
412
478
  console.log(` doc ${d}`);
479
+ if (counts) {
480
+ const staleNote = counts.stale ? chalk.dim(' (cached)') : '';
481
+ console.log(` issues ${counts.done}/${counts.total}${counts.truncated ? '+' : ''} done · ${counts.inProgress} in progress${staleNote}`);
482
+ }
483
+ const ms = counts?.milestones ?? [];
484
+ if (ms.length) {
485
+ console.log(` ${chalk.bold('milestones')}`);
486
+ for (const m of ms) {
487
+ const mark = m.name === counts?.nextMilestone?.name ? chalk.cyan('next') : '';
488
+ console.log(` ${mark.padEnd(6)} ${formatNextMilestone(m, Date.now())}`);
489
+ }
490
+ // A milestone nothing is filed against cannot report progress. Say that
491
+ // once, plainly, rather than printing a row of silent 0%s.
492
+ const unfiled = ms.filter((m) => m.total === 0).length;
493
+ if (unfiled === ms.length) {
494
+ console.log(` ${chalk.yellow('!')} ${chalk.yellow(`no issues are assigned to any milestone — progress against them cannot be measured`)}`);
495
+ }
496
+ }
413
497
  console.log(chalk.gray(` ${projectDefPath(name)}`));
414
498
  });
415
499
  // ---- edit ----
@@ -514,7 +598,8 @@ export function registerProjectsCommands(program) {
514
598
  byStatus: r?.byStatus ?? {},
515
599
  members: r?.members ?? [],
516
600
  plan: r?.plan ?? { done: 0, total: 0 },
517
- planPct: r ? planPct(r.plan) ?? null : null,
601
+ live: r ? liveDeadSplit(r.byStatus).live : 0,
602
+ dead: r ? liveDeadSplit(r.byStatus).dead : 0,
518
603
  openPrs: r?.openPrs ?? [],
519
604
  mergedPrs: rem?.mergedPrs ?? 0,
520
605
  latestRelease: rem?.latestRelease ?? null,
@@ -577,6 +662,53 @@ export function registerProjectsCommands(program) {
577
662
  console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
578
663
  }
579
664
  });
665
+ // ---- set ----
666
+ projects
667
+ .command('set <name>')
668
+ .description('Change one field on a project definition, preserving everything else.')
669
+ .option('--repo <owner/repo>', 'Primary GitHub slug')
670
+ .option('--root <path>', 'Repo / monorepo root')
671
+ .option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
672
+ .option('--description <text>', 'One-line description shown on the card')
673
+ .action((name, opts) => {
674
+ const def = loadProjectDef(name);
675
+ if (!def) {
676
+ console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
677
+ process.exit(1);
678
+ }
679
+ const fields = ['repo', 'root', 'path', 'description'].filter((k) => opts[k] !== undefined);
680
+ if (fields.length === 0) {
681
+ console.error(chalk.red('Nothing to set. Pass a field, e.g. --repo <owner/repo>.'));
682
+ process.exit(1);
683
+ }
684
+ // Load, mutate the named fields, write back — the `link` pattern. NEVER
685
+ // the `add --force` pattern, which rebuilds the def from flags alone and
686
+ // silently drops linear/contexts/integrations that were not re-passed.
687
+ if (opts.repo !== undefined)
688
+ def.repo = opts.repo;
689
+ if (opts.root !== undefined)
690
+ def.root = opts.root;
691
+ if (opts.description !== undefined)
692
+ def.description = opts.description;
693
+ if (opts.path !== undefined) {
694
+ // `--path` is a subdir OF the root, so without one there is nothing to
695
+ // hang it off. A def imported with `--from-linear` that found no local
696
+ // checkout carries name + linear and no root — joining against '' there
697
+ // would silently write `/apps/cli`, an absolute path at the filesystem
698
+ // root, and the def would resolve somewhere that does not exist.
699
+ const base = (opts.root ?? def.root ?? '').replace(/\/$/, '');
700
+ if (!base) {
701
+ console.error(chalk.red(`"${def.name}" has no root, so --path has nothing to resolve against.`));
702
+ console.error(chalk.gray(` Set one first: agents projects set ${def.name} --root <path> --path ${opts.path}`));
703
+ process.exit(1);
704
+ }
705
+ def.defaultPath = `${base}/${opts.path.replace(/^\//, '')}`;
706
+ }
707
+ writeProjectDef(def);
708
+ console.log(chalk.green(`Updated ${def.name}`));
709
+ for (const f of fields)
710
+ console.log(chalk.gray(` ${f} ${f === 'path' ? def.defaultPath : def[f]}`));
711
+ });
580
712
  // ---- link ----
581
713
  projects
582
714
  .command('link <name>')
@@ -14,7 +14,7 @@ import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemo
14
14
  import { assertSchedulerEnabled } from '../lib/device-config.js';
15
15
  import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
16
16
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
17
- import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
17
+ import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
18
18
  import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
19
19
  import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
20
20
  import { getRoutinesDir } from '../lib/state.js';
@@ -359,6 +359,7 @@ function runMetaJson(run) {
359
359
  completedAt: run.completedAt,
360
360
  exitCode: run.exitCode,
361
361
  errorMessage: run.errorMessage ?? null,
362
+ duration: run.duration ?? null,
362
363
  };
363
364
  }
364
365
  export function buildRunsJson(runs) {
@@ -1034,6 +1035,50 @@ export function registerRoutinesCommands(program) {
1034
1035
  console.log(` ${run.runId} ${status} ${run.startedAt}`);
1035
1036
  }
1036
1037
  });
1038
+ routinesCmd
1039
+ .command('stats [name]')
1040
+ .description('Duration + outcome rollup per job: run count, failed, missed, avg/p50/p95 duration')
1041
+ .option('--json', 'Emit machine-readable JSON')
1042
+ .action(async (name, options) => {
1043
+ // No name: summarize every job (like `routines list`). A name narrows to
1044
+ // one job's rollup — no interactive picker, since a bare `stats` already
1045
+ // has a useful all-jobs default.
1046
+ if (!name) {
1047
+ const jobs = listAllJobs();
1048
+ const rows = jobs.map((j) => ({ name: j.name, ...routineStats(j.name) }));
1049
+ if (options.json) {
1050
+ writeJson({ jobs: rows });
1051
+ return;
1052
+ }
1053
+ if (rows.length === 0) {
1054
+ console.log(chalk.gray('No jobs configured'));
1055
+ return;
1056
+ }
1057
+ console.log(chalk.bold('Routine stats\n'));
1058
+ const pad = (s, w) => (s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length));
1059
+ console.log(chalk.gray(` ${pad('JOB', 28)} ${pad('N', 5)} ${pad('FAILED', 7)} ${pad('MISSED', 7)} ${pad('AVG', 7)} ${pad('P50', 7)} P95`));
1060
+ for (const r of rows) {
1061
+ console.log(` ${pad(r.name, 28)} ${pad(String(r.count), 5)} ${pad(String(r.failed), 7)} ${pad(String(r.missed), 7)} ${pad(`${r.avgMs}ms`, 7)} ${pad(`${r.p50}ms`, 7)} ${r.p95}ms`);
1062
+ }
1063
+ return;
1064
+ }
1065
+ const stats = routineStats(name);
1066
+ if (options.json) {
1067
+ writeJson({ jobId: name, name, ...stats });
1068
+ return;
1069
+ }
1070
+ if (stats.count === 0) {
1071
+ console.log(chalk.yellow(`No runs found for job '${name}'`));
1072
+ return;
1073
+ }
1074
+ console.log(chalk.bold(`Stats: ${name}\n`));
1075
+ console.log(` Runs: ${stats.count}`);
1076
+ console.log(` Failed: ${stats.failed}`);
1077
+ console.log(` Missed: ${stats.missed}`);
1078
+ console.log(` Avg: ${stats.avgMs}ms`);
1079
+ console.log(` P50: ${stats.p50}ms`);
1080
+ console.log(` P95: ${stats.p95}ms`);
1081
+ });
1037
1082
  routinesCmd
1038
1083
  .command('run [name]')
1039
1084
  .description('Execute a routine right now in the foreground. Ignores the schedule; useful for testing before enabling.')