@phnx-labs/agents-cli 1.20.91 → 1.20.93

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 (105) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +72 -6
  5. package/dist/commands/activity.js +198 -49
  6. package/dist/commands/beta.js +1 -0
  7. package/dist/commands/doctor.js +4 -2
  8. package/dist/commands/events.js +91 -1
  9. package/dist/commands/exec.d.ts +14 -0
  10. package/dist/commands/exec.js +144 -14
  11. package/dist/commands/projects.d.ts +22 -0
  12. package/dist/commands/projects.js +539 -0
  13. package/dist/commands/secrets.d.ts +17 -0
  14. package/dist/commands/secrets.js +198 -7
  15. package/dist/commands/send.d.ts +14 -12
  16. package/dist/commands/send.js +105 -35
  17. package/dist/commands/sessions-picker.d.ts +15 -0
  18. package/dist/commands/sessions-picker.js +37 -12
  19. package/dist/commands/sessions-resume.d.ts +2 -0
  20. package/dist/commands/sessions-resume.js +9 -1
  21. package/dist/commands/sessions.d.ts +10 -5
  22. package/dist/commands/sessions.js +65 -27
  23. package/dist/commands/sync.js +9 -3
  24. package/dist/commands/view.js +4 -0
  25. package/dist/index.js +18 -1
  26. package/dist/lib/activity.d.ts +77 -12
  27. package/dist/lib/activity.js +424 -74
  28. package/dist/lib/beta.d.ts +1 -1
  29. package/dist/lib/beta.js +1 -1
  30. package/dist/lib/channels/send.d.ts +83 -0
  31. package/dist/lib/channels/send.js +112 -0
  32. package/dist/lib/devices/registry.d.ts +14 -0
  33. package/dist/lib/devices/registry.js +37 -0
  34. package/dist/lib/events-ingest.d.ts +46 -0
  35. package/dist/lib/events-ingest.js +182 -0
  36. package/dist/lib/events.d.ts +15 -3
  37. package/dist/lib/events.js +55 -3
  38. package/dist/lib/feed-post.js +8 -2
  39. package/dist/lib/hosts/remote-cmd.js +4 -0
  40. package/dist/lib/linear-project-counts.d.ts +62 -0
  41. package/dist/lib/linear-project-counts.js +122 -0
  42. package/dist/lib/linear-projects.d.ts +50 -0
  43. package/dist/lib/linear-projects.js +114 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  46. package/dist/lib/menubar/install-menubar.js +20 -6
  47. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  48. package/dist/lib/menubar/notify-desktop.js +8 -2
  49. package/dist/lib/project-key.d.ts +44 -0
  50. package/dist/lib/project-key.js +79 -0
  51. package/dist/lib/project-probe.d.ts +75 -0
  52. package/dist/lib/project-probe.js +160 -0
  53. package/dist/lib/project-resources.d.ts +8 -0
  54. package/dist/lib/project-resources.js +31 -3
  55. package/dist/lib/project-root.js +16 -0
  56. package/dist/lib/project-status.d.ts +100 -0
  57. package/dist/lib/project-status.js +182 -0
  58. package/dist/lib/projects.d.ts +144 -0
  59. package/dist/lib/projects.js +313 -0
  60. package/dist/lib/remote-agents-json.d.ts +9 -0
  61. package/dist/lib/remote-agents-json.js +11 -5
  62. package/dist/lib/routine-notify.d.ts +11 -0
  63. package/dist/lib/routine-notify.js +22 -0
  64. package/dist/lib/run-notify.js +3 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/audit.d.ts +1 -1
  68. package/dist/lib/secrets/audit.js +53 -10
  69. package/dist/lib/secrets/list-filter.d.ts +20 -5
  70. package/dist/lib/secrets/list-filter.js +22 -6
  71. package/dist/lib/secrets/usage-db.d.ts +106 -0
  72. package/dist/lib/secrets/usage-db.js +236 -0
  73. package/dist/lib/session/bash-command.d.ts +53 -0
  74. package/dist/lib/session/bash-command.js +364 -0
  75. package/dist/lib/session/digest.d.ts +6 -0
  76. package/dist/lib/session/digest.js +19 -0
  77. package/dist/lib/session/relative-time.d.ts +23 -0
  78. package/dist/lib/session/relative-time.js +60 -8
  79. package/dist/lib/session/remote-active.d.ts +5 -1
  80. package/dist/lib/session/remote-active.js +4 -1
  81. package/dist/lib/session/remote-list.js +5 -2
  82. package/dist/lib/session/render.d.ts +2 -9
  83. package/dist/lib/session/render.js +25 -56
  84. package/dist/lib/sqlite.js +28 -1
  85. package/dist/lib/ssh-exec.d.ts +6 -0
  86. package/dist/lib/ssh-exec.js +10 -1
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/state.d.ts +14 -0
  90. package/dist/lib/state.js +19 -0
  91. package/dist/lib/terminal/backends/index.d.ts +10 -2
  92. package/dist/lib/terminal/backends/index.js +14 -2
  93. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  94. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  95. package/dist/lib/terminal/index.d.ts +2 -1
  96. package/dist/lib/terminal/index.js +2 -1
  97. package/dist/lib/terminal/preferred.d.ts +89 -0
  98. package/dist/lib/terminal/preferred.js +87 -0
  99. package/dist/lib/terminal/run-surface.d.ts +82 -0
  100. package/dist/lib/terminal/run-surface.js +146 -0
  101. package/dist/lib/terminal/types.d.ts +1 -1
  102. package/dist/lib/types.d.ts +6 -5
  103. package/dist/lib/versions.d.ts +6 -0
  104. package/dist/lib/versions.js +6 -4
  105. package/package.json +2 -1
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Project workspace probing — the drift signal behind `projects status --fleet`.
3
+ *
4
+ * Projects are natively multi-device: the same definition (home-relative paths)
5
+ * re-roots on every fleet machine, and the question is whether the project's
6
+ * repos are PRESENT on each box, on which branch, how far ahead/behind their
7
+ * upstream, and whether they carry uncommitted changes. This module is the pure
8
+ * local half: given a set of home-relative paths it probes each one with a
9
+ * handful of read-only git calls. Drift is measured against the LAST-FETCHED
10
+ * upstream (`@{upstream}`) — deliberately no `git fetch`, so a probe is fast
11
+ * and offline-safe. The fleet half (`--fleet`) just runs this probe on every
12
+ * peer via the canonical `remote-agents-json` SSH fan-out.
13
+ */
14
+ import type { ProjectDef } from './projects.js';
15
+ /** The on-disk state of one workspace repo on one machine. */
16
+ export interface RepoWorkspaceStatus {
17
+ /** The probed path, echoed home-relative (re-roots per machine). */
18
+ path: string;
19
+ /** `.git` exists (a directory, or a FILE for a linked worktree). */
20
+ present: boolean;
21
+ branch?: string;
22
+ /** The configured upstream ref (e.g. `origin/main`); absent → no upstream. */
23
+ upstream?: string;
24
+ /** Commits on HEAD not on the upstream. Undefined without an upstream. */
25
+ ahead?: number;
26
+ /** Commits on the upstream not on HEAD. Undefined without an upstream. */
27
+ behind?: number;
28
+ /** Uncommitted (incl. untracked) paths from `git status --porcelain`. */
29
+ dirty?: number;
30
+ /** ISO 8601 committer date of HEAD. */
31
+ lastCommit?: string;
32
+ /** `.git` exists but git could not read it — never looks silently clean. */
33
+ error?: string;
34
+ }
35
+ /** A probe result tagged with the machine that answered (the fleet view). */
36
+ export interface HostWorkspaceStatus extends RepoWorkspaceStatus {
37
+ host: string;
38
+ }
39
+ /**
40
+ * Probe one workspace repo. A missing path yields `{present: false}` and no
41
+ * git call is made. On a present repo every signal is best-effort: whatever
42
+ * succeeded is reported, and a repo whose `.git` exists yet every git call
43
+ * failed surfaces as present-with-error rather than silently clean.
44
+ */
45
+ export declare function probeRepoWorkspace(absPath: string): RepoWorkspaceStatus;
46
+ /** Probe each home-relative path (expanded against the local home), in order. */
47
+ export declare function probeProjectWorkspaces(paths: string[]): RepoWorkspaceStatus[];
48
+ /**
49
+ * The home-relative paths to probe for a project definition: its `root` plus
50
+ * each `repos[].path` (the opt-in for additional repos), deduped. Every target
51
+ * is normalized through the same `toHomeRelative(expandLocalHome(...))` the
52
+ * probe echoes, so a hand-edited def (absolute path under home, trailing
53
+ * slash) matches its probe rows exactly — `writeProjectDef` normalizes on
54
+ * write, but defs are hand-editable YAML and never silently drop a row.
55
+ */
56
+ export declare function workspaceTargetsForDef(def: ProjectDef): string[];
57
+ /**
58
+ * Parse a peer's `projects probe --json` stdout, tagging each row with the
59
+ * machine that answered. Defensive against version skew / partial output, the
60
+ * same boundary contract as `parseRemoteActive`: non-JSON or a non-array
61
+ * yields `[]`, and rows without a `path`/`present` core are dropped.
62
+ */
63
+ export declare function parseRemoteProbe(stdout: string, machine: string): HostWorkspaceStatus[];
64
+ /**
65
+ * One workspace's compact state: `✓ clean · main`, `⚠ 12 dirty · ↑3 ↓1 ·
66
+ * feature/x`, `✗ missing`, or `⚠ error: …`. Pure — chalk styling only.
67
+ */
68
+ export declare function formatWorkspaceLine(s: RepoWorkspaceStatus): string;
69
+ /**
70
+ * The fleet view of one project's workspaces: one content line per probed
71
+ * path (host-sorted `host: state` cells joined by ` · `), labelled with the
72
+ * path when a project probes more than one. Pure — the caller adds the
73
+ * `fleet` row label.
74
+ */
75
+ export declare function formatFleetWorkspaces(statuses: HostWorkspaceStatus[]): string[];
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Project workspace probing — the drift signal behind `projects status --fleet`.
3
+ *
4
+ * Projects are natively multi-device: the same definition (home-relative paths)
5
+ * re-roots on every fleet machine, and the question is whether the project's
6
+ * repos are PRESENT on each box, on which branch, how far ahead/behind their
7
+ * upstream, and whether they carry uncommitted changes. This module is the pure
8
+ * local half: given a set of home-relative paths it probes each one with a
9
+ * handful of read-only git calls. Drift is measured against the LAST-FETCHED
10
+ * upstream (`@{upstream}`) — deliberately no `git fetch`, so a probe is fast
11
+ * and offline-safe. The fleet half (`--fleet`) just runs this probe on every
12
+ * peer via the canonical `remote-agents-json` SSH fan-out.
13
+ */
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import { execFileSync } from 'child_process';
17
+ import chalk from 'chalk';
18
+ import { expandLocalHome, toHomeRelative } from './project-root.js';
19
+ /** Per-call git budget. A read-only git call taking >3s is wedged by any
20
+ * definition (NFS stall, index lock) — and the fleet fan-out SIGKILLs the SSH
21
+ * hop at 12s, so a probe must fit inside that budget to avoid a slow peer
22
+ * being misreported as unreachable: 3s × 5 calls leaves headroom even when
23
+ * one repo is genuinely stuck. */
24
+ const GIT_TIMEOUT_MS = 3_000;
25
+ /** One read-only git call against `absPath`; undefined on any failure. */
26
+ function git(absPath, args) {
27
+ try {
28
+ return execFileSync('git', ['-C', absPath, ...args], {
29
+ encoding: 'utf8',
30
+ timeout: GIT_TIMEOUT_MS,
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
+ }).trim();
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /**
39
+ * Probe one workspace repo. A missing path yields `{present: false}` and no
40
+ * git call is made. On a present repo every signal is best-effort: whatever
41
+ * succeeded is reported, and a repo whose `.git` exists yet every git call
42
+ * failed surfaces as present-with-error rather than silently clean.
43
+ */
44
+ export function probeRepoWorkspace(absPath) {
45
+ const status = { path: toHomeRelative(absPath), present: false };
46
+ if (!fs.existsSync(path.join(absPath, '.git')))
47
+ return status;
48
+ status.present = true;
49
+ const branch = git(absPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
50
+ const upstream = git(absPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
51
+ // `--left-right --count A...B` prints "<left>\t<right>" — left is
52
+ // upstream-only (we are BEHIND by that much), right is HEAD-only (AHEAD).
53
+ const counts = upstream !== undefined
54
+ ? git(absPath, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'])
55
+ : undefined;
56
+ const dirtyOut = git(absPath, ['status', '--porcelain']);
57
+ const lastCommit = git(absPath, ['log', '-1', '--format=%cI']);
58
+ if (branch === undefined && dirtyOut === undefined && lastCommit === undefined) {
59
+ status.error = '.git exists but git could not read this repo';
60
+ return status;
61
+ }
62
+ if (branch !== undefined)
63
+ status.branch = branch;
64
+ if (upstream !== undefined)
65
+ status.upstream = upstream;
66
+ if (counts !== undefined) {
67
+ const [behind, ahead] = counts.split(/\s+/).map(Number);
68
+ if (Number.isFinite(behind) && Number.isFinite(ahead)) {
69
+ status.behind = behind;
70
+ status.ahead = ahead;
71
+ }
72
+ }
73
+ if (dirtyOut !== undefined)
74
+ status.dirty = dirtyOut === '' ? 0 : dirtyOut.split('\n').length;
75
+ if (lastCommit !== undefined)
76
+ status.lastCommit = lastCommit;
77
+ return status;
78
+ }
79
+ /** Probe each home-relative path (expanded against the local home), in order. */
80
+ export function probeProjectWorkspaces(paths) {
81
+ return paths.map((p) => probeRepoWorkspace(expandLocalHome(p)));
82
+ }
83
+ /**
84
+ * The home-relative paths to probe for a project definition: its `root` plus
85
+ * each `repos[].path` (the opt-in for additional repos), deduped. Every target
86
+ * is normalized through the same `toHomeRelative(expandLocalHome(...))` the
87
+ * probe echoes, so a hand-edited def (absolute path under home, trailing
88
+ * slash) matches its probe rows exactly — `writeProjectDef` normalizes on
89
+ * write, but defs are hand-editable YAML and never silently drop a row.
90
+ */
91
+ export function workspaceTargetsForDef(def) {
92
+ const targets = [def.root, ...(def.repos ?? []).map((r) => r.path)]
93
+ .filter((p) => typeof p === 'string' && p.length > 0)
94
+ .map((p) => toHomeRelative(expandLocalHome(p)));
95
+ return [...new Set(targets)];
96
+ }
97
+ /**
98
+ * Parse a peer's `projects probe --json` stdout, tagging each row with the
99
+ * machine that answered. Defensive against version skew / partial output, the
100
+ * same boundary contract as `parseRemoteActive`: non-JSON or a non-array
101
+ * yields `[]`, and rows without a `path`/`present` core are dropped.
102
+ */
103
+ export function parseRemoteProbe(stdout, machine) {
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(stdout);
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ if (!Array.isArray(parsed))
112
+ return [];
113
+ return parsed.flatMap((x) => {
114
+ if (x && typeof x === 'object' && !Array.isArray(x)) {
115
+ const o = x;
116
+ if (typeof o.path === 'string' && typeof o.present === 'boolean') {
117
+ return [{ ...o, host: machine }];
118
+ }
119
+ }
120
+ return [];
121
+ });
122
+ }
123
+ /**
124
+ * One workspace's compact state: `✓ clean · main`, `⚠ 12 dirty · ↑3 ↓1 ·
125
+ * feature/x`, `✗ missing`, or `⚠ error: …`. Pure — chalk styling only.
126
+ */
127
+ export function formatWorkspaceLine(s) {
128
+ if (!s.present)
129
+ return chalk.red('✗ missing');
130
+ if (s.error)
131
+ return chalk.yellow(`⚠ error: ${s.error}`);
132
+ const parts = [];
133
+ if (s.dirty !== undefined && s.dirty > 0)
134
+ parts.push(`${s.dirty} dirty`);
135
+ const drift = [
136
+ s.ahead !== undefined && s.ahead > 0 ? `↑${s.ahead}` : '',
137
+ s.behind !== undefined && s.behind > 0 ? `↓${s.behind}` : '',
138
+ ].filter(Boolean).join(' ');
139
+ if (drift)
140
+ parts.push(drift);
141
+ const head = parts.length > 0 ? chalk.yellow(`⚠ ${parts.join(' · ')}`) : chalk.green('✓ clean');
142
+ return s.branch ? `${head} ${chalk.dim('·')} ${s.branch}` : head;
143
+ }
144
+ /**
145
+ * The fleet view of one project's workspaces: one content line per probed
146
+ * path (host-sorted `host: state` cells joined by ` · `), labelled with the
147
+ * path when a project probes more than one. Pure — the caller adds the
148
+ * `fleet` row label.
149
+ */
150
+ export function formatFleetWorkspaces(statuses) {
151
+ const paths = [...new Set(statuses.map((s) => s.path))];
152
+ const multi = paths.length > 1;
153
+ return paths.map((p) => {
154
+ const rows = statuses
155
+ .filter((s) => s.path === p)
156
+ .sort((a, b) => a.host.localeCompare(b.host));
157
+ const body = rows.map((r) => `${chalk.cyan(r.host)}: ${formatWorkspaceLine(r)}`).join(chalk.dim(' · '));
158
+ return multi ? `${chalk.dim(`${p} · `)}${body}` : body;
159
+ });
160
+ }
@@ -5,3 +5,11 @@ export interface ProjectResourceSyncResult {
5
5
  }
6
6
  export declare function projectAgentRoot(projectRoot: string, agent: AgentId): string;
7
7
  export declare function syncProjectResourcesToAgent(agent: AgentId, version: string, projectAgentsDir: string): ProjectResourceSyncResult;
8
+ /**
9
+ * One human line for the files a project sync left alone because you already
10
+ * wrote them. This is the normal steady state — every sync of a project whose
11
+ * `.claude/commands/` you hand-authored hits it — so it is a single grouped
12
+ * line, not one wrapped warning per file, and it says "yours" rather than the
13
+ * internal "user-owned". Returns null when nothing was skipped.
14
+ */
15
+ export declare function formatKeptProjectResources(skipped: string[]): string | null;
@@ -127,9 +127,37 @@ function record(kind, name, relPaths, result, manifestPaths) {
127
127
  manifestPaths.add(toPosixRel(rel));
128
128
  }
129
129
  function skip(dest, projectRoot, result) {
130
- const rel = path.relative(projectRoot, dest);
131
- result.skipped.push(rel);
132
- console.warn(`Skipping project resource target ${rel}: already exists and is user-owned`);
130
+ result.skipped.push(path.relative(projectRoot, dest));
131
+ }
132
+ /**
133
+ * One human line for the files a project sync left alone because you already
134
+ * wrote them. This is the normal steady state — every sync of a project whose
135
+ * `.claude/commands/` you hand-authored hits it — so it is a single grouped
136
+ * line, not one wrapped warning per file, and it says "yours" rather than the
137
+ * internal "user-owned". Returns null when nothing was skipped.
138
+ */
139
+ export function formatKeptProjectResources(skipped) {
140
+ if (skipped.length === 0)
141
+ return null;
142
+ const rels = [...skipped].sort((a, b) => a.localeCompare(b)).map(toPosixRel);
143
+ if (rels.length === 1)
144
+ return `Kept your existing ${rels[0]}`;
145
+ const byDir = new Map();
146
+ for (const rel of rels) {
147
+ const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '.';
148
+ const names = byDir.get(dir) ?? [];
149
+ names.push(rel.slice(rel.lastIndexOf('/') + 1));
150
+ byDir.set(dir, names);
151
+ }
152
+ if (byDir.size === 1) {
153
+ const [dir, names] = [...byDir.entries()][0];
154
+ const PREVIEW = 3;
155
+ const preview = names.slice(0, PREVIEW).join(', ');
156
+ const more = names.length > PREVIEW ? `, +${names.length - PREVIEW} more` : '';
157
+ return `Kept ${rels.length} of your own files in ${dir}: ${preview}${more}`;
158
+ }
159
+ const dirs = [...byDir.entries()].map(([dir, names]) => `${dir} (${names.length})`).join(', ');
160
+ return `Kept ${rels.length} of your own files in ${dirs}`;
133
161
  }
134
162
  function syncProjectCommands(agent, version, projectAgentsDir, agentRoot, result, manifestPaths) {
135
163
  const cfg = AGENTS[agent];
@@ -17,6 +17,7 @@ import * as fs from 'fs';
17
17
  import { readMeta, updateMeta } from './state.js';
18
18
  import { getMainRepoRoot } from './git.js';
19
19
  import { toPosix } from './platform/index.js';
20
+ import { loadProjectDef, resolveDefinedProjectPath } from './projects.js';
20
21
  const HOME = process.env.HOME ?? os.homedir();
21
22
  /** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
22
23
  export function toHomeRelative(abs) {
@@ -124,6 +125,21 @@ export function buildProjectPath(root, ref, forRemote) {
124
125
  * absolute local path and verifies it exists (so a mistyped slug fails loudly).
125
126
  */
126
127
  export async function resolveProjectRef(ref, opts) {
128
+ const { slug, worktree } = parseProjectRef(ref);
129
+ if (!slug)
130
+ throw new Error(`Invalid --project value: "${ref}"`);
131
+ // Definition first: a named project in ~/.agents/projects/<slug>.yaml overrides
132
+ // the <root>/<slug> convention. Absent (or root-less) → fall through unchanged.
133
+ const def = loadProjectDef(slug);
134
+ if (def) {
135
+ const fromDef = resolveDefinedProjectPath(def, worktree, opts.forRemote);
136
+ if (fromDef) {
137
+ if (!opts.forRemote && !fs.existsSync(fromDef)) {
138
+ throw new Error(`Project path not found: ${fromDef} (defined in ${slug}.yaml)`);
139
+ }
140
+ return fromDef;
141
+ }
142
+ }
127
143
  const cwd = opts.cwd ?? process.cwd();
128
144
  const root = await ensureProjectRoot(cwd);
129
145
  const resolved = buildProjectPath(root, ref, opts.forRemote);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Project-level progress rollup — the headline of the projects subsystem.
3
+ *
4
+ * At 50–100 agents the per-agent activity line is noise; what matters is the
5
+ * PROJECT. This aggregates the signals already carried per session (status,
6
+ * plan progress, open PRs, tickets, worktrees) into one row per project, keyed
7
+ * by matching each session's cwd to a defined project root (`projectNameForCwd`).
8
+ * The session set is whatever the caller passes (today `getActiveSessions()` —
9
+ * this machine's live view, matched by local-home cwd; a fleet-wide fan-out is a
10
+ * deferred follow-up). Pure over an `ActiveSession[]` so the aggregation is
11
+ * unit-testable; the merged-PR signal IS repo-global (harvested via `gh`) and the
12
+ * artifact signal is local, both added in `enrichProjectSignals`.
13
+ */
14
+ import type { ActiveSession, ActiveStatus } from './session/active.js';
15
+ import { type ProjectDef } from './projects.js';
16
+ /** One live agent on a project — the WHO behind the byStatus count. */
17
+ export interface ProjectMember {
18
+ /** Harness name (claude / codex / …), from the session's `kind`. */
19
+ agent: string;
20
+ /** Lifecycle status (running / idle / …). */
21
+ status: string;
22
+ /** Tracker ticket the session is tied to, when any. */
23
+ ticket?: string;
24
+ /** Machine the session runs on (provenance host / fleet peer), when known. */
25
+ host?: string;
26
+ }
27
+ /** One project's live session rollup. */
28
+ export interface ProjectSessionRollup {
29
+ name: string;
30
+ /** Total sessions whose cwd is inside this project. */
31
+ agents: number;
32
+ /** Count per lifecycle status. */
33
+ byStatus: Partial<Record<ActiveStatus, number>>;
34
+ /** Which agents are on the project (one per matched session). */
35
+ members: ProjectMember[];
36
+ /** Summed checklist progress across this project's sessions. */
37
+ plan: {
38
+ done: number;
39
+ total: number;
40
+ };
41
+ /** Distinct open PRs held by this project's sessions. */
42
+ openPrs: {
43
+ url: string;
44
+ number?: number;
45
+ }[];
46
+ /** Distinct tickets worked or created by this project's sessions. */
47
+ tickets: string[];
48
+ /** Sessions running inside a worktree. */
49
+ worktrees: number;
50
+ }
51
+ /**
52
+ * Roll active sessions up by project. Returns a map keyed by project name,
53
+ * containing only projects with at least one matched session — callers merge
54
+ * with the full definition list to show zero-agent projects.
55
+ */
56
+ export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
57
+ /** Plan completion percentage (0–100), or undefined when nothing is tracked. */
58
+ export declare function planPct(plan: {
59
+ done: number;
60
+ total: number;
61
+ }): number | undefined;
62
+ /** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
63
+ export declare function sortProjectMembers(members: ProjectMember[]): ProjectMember[];
64
+ /** Cap for the members line before it collapses to `+N more`. */
65
+ export declare const MEMBERS_LINE_LIMIT = 6;
66
+ /**
67
+ * The `agents` line under `live`: one cell per DISTINCT member state —
68
+ * `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
69
+ * `×N` count (35 same-harness sessions in one state are one fact, not six
70
+ * truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
71
+ * `+N more` tail counting members, not cells. Pure (chalk styling only); the
72
+ * caller adds the label.
73
+ */
74
+ export declare function formatProjectMembers(members: ProjectMember[], limit?: number): string;
75
+ /** Harvested signals not on the session list: repo-global merged PRs + releases, local artifacts, in a time window. */
76
+ export interface ProjectRemoteSignals {
77
+ windowDays: number;
78
+ /** PRs merged into the primary repo within the window (via `gh`). */
79
+ mergedPrs: number;
80
+ /** Artifacts agents produced within the window (activity.created milestones). */
81
+ artifacts: number;
82
+ /** Basename of the most recent artifact, when any. */
83
+ lastArtifact?: string;
84
+ /** Latest release of the PRIMARY repo (via `gh release list`), when any. */
85
+ latestRelease?: {
86
+ tag: string;
87
+ publishedAt: string;
88
+ };
89
+ }
90
+ /**
91
+ * Harvest the signals that don't live on the active-session list: recently
92
+ * merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
93
+ * local activity-milestone log, matched to the project by cwd). Best-effort —
94
+ * a missing `gh`, no auth, or no repo degrades to zero rather than throwing, so
95
+ * `projects status` still renders. `nowMs` is injected for testability.
96
+ */
97
+ export declare function enrichProjectSignals(def: ProjectDef, windowDays: number, nowMs: number, opts?: {
98
+ activityRoot?: string;
99
+ skipRemote?: boolean;
100
+ }): Promise<ProjectRemoteSignals>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Project-level progress rollup — the headline of the projects subsystem.
3
+ *
4
+ * At 50–100 agents the per-agent activity line is noise; what matters is the
5
+ * PROJECT. This aggregates the signals already carried per session (status,
6
+ * plan progress, open PRs, tickets, worktrees) into one row per project, keyed
7
+ * by matching each session's cwd to a defined project root (`projectNameForCwd`).
8
+ * The session set is whatever the caller passes (today `getActiveSessions()` —
9
+ * this machine's live view, matched by local-home cwd; a fleet-wide fan-out is a
10
+ * deferred follow-up). Pure over an `ActiveSession[]` so the aggregation is
11
+ * unit-testable; the merged-PR signal IS repo-global (harvested via `gh`) and the
12
+ * artifact signal is local, both added in `enrichProjectSignals`.
13
+ */
14
+ import { execFile } from 'child_process';
15
+ import { promisify } from 'util';
16
+ import chalk from 'chalk';
17
+ import { projectNameForCwd } from './projects.js';
18
+ import { readRecentActivity } from './activity.js';
19
+ const execFileAsync = promisify(execFile);
20
+ function blank(name) {
21
+ return {
22
+ name,
23
+ agents: 0,
24
+ byStatus: {},
25
+ members: [],
26
+ plan: { done: 0, total: 0 },
27
+ openPrs: [],
28
+ tickets: [],
29
+ worktrees: 0,
30
+ };
31
+ }
32
+ /**
33
+ * Roll active sessions up by project. Returns a map keyed by project name,
34
+ * containing only projects with at least one matched session — callers merge
35
+ * with the full definition list to show zero-agent projects.
36
+ */
37
+ export function rollupSessionsByProject(defs, sessions) {
38
+ const map = new Map();
39
+ const prSeen = new Map();
40
+ const ticketSeen = new Map();
41
+ for (const s of sessions) {
42
+ const name = projectNameForCwd(s.cwd, defs);
43
+ if (!name)
44
+ continue;
45
+ let r = map.get(name);
46
+ if (!r) {
47
+ r = blank(name);
48
+ map.set(name, r);
49
+ prSeen.set(name, new Set());
50
+ ticketSeen.set(name, new Set());
51
+ }
52
+ r.agents++;
53
+ r.byStatus[s.status] = (r.byStatus[s.status] ?? 0) + 1;
54
+ const member = { agent: s.kind, status: s.status };
55
+ if (s.ticket?.id)
56
+ member.ticket = s.ticket.id;
57
+ if (s.machine)
58
+ member.host = s.machine;
59
+ r.members.push(member);
60
+ if (s.todos) {
61
+ r.plan.done += s.todos.done;
62
+ r.plan.total += s.todos.total;
63
+ }
64
+ if (s.pr?.url && !prSeen.get(name).has(s.pr.url)) {
65
+ prSeen.get(name).add(s.pr.url);
66
+ r.openPrs.push({ url: s.pr.url, number: s.pr.number });
67
+ }
68
+ const tset = ticketSeen.get(name);
69
+ for (const t of [s.ticket?.id, ...(s.createdTickets ?? [])]) {
70
+ if (t && !tset.has(t)) {
71
+ tset.add(t);
72
+ r.tickets.push(t);
73
+ }
74
+ }
75
+ if (s.worktree)
76
+ r.worktrees++;
77
+ }
78
+ return map;
79
+ }
80
+ /** Plan completion percentage (0–100), or undefined when nothing is tracked. */
81
+ export function planPct(plan) {
82
+ if (plan.total <= 0)
83
+ return undefined;
84
+ return Math.round((plan.done / plan.total) * 100);
85
+ }
86
+ /**
87
+ * Display order for the members line: the states a human scans for first
88
+ * (running, then idle, then need-input, then queued), everything else after,
89
+ * status name then agent name ascending within a state.
90
+ */
91
+ const MEMBER_STATUS_RANK = { running: 0, idle: 1, input_required: 2, queued: 3 };
92
+ /** Sort members for the card: running first, then idle, then the rest; agent name asc within a state. */
93
+ export function sortProjectMembers(members) {
94
+ return [...members].sort((a, b) => {
95
+ const ra = MEMBER_STATUS_RANK[a.status] ?? 4;
96
+ const rb = MEMBER_STATUS_RANK[b.status] ?? 4;
97
+ if (ra !== rb)
98
+ return ra - rb;
99
+ if (ra === 4 && a.status !== b.status)
100
+ return a.status.localeCompare(b.status);
101
+ return a.agent.localeCompare(b.agent);
102
+ });
103
+ }
104
+ /** Cap for the members line before it collapses to `+N more`. */
105
+ export const MEMBERS_LINE_LIMIT = 6;
106
+ /**
107
+ * The `agents` line under `live`: one cell per DISTINCT member state —
108
+ * `claude · running · RUSH-2107 @zion` — with identical cells collapsed to a
109
+ * `×N` count (35 same-harness sessions in one state are one fact, not six
110
+ * truncated duplicates), capped at {@link MEMBERS_LINE_LIMIT} cells with a
111
+ * `+N more` tail counting members, not cells. Pure (chalk styling only); the
112
+ * caller adds the label.
113
+ */
114
+ export function formatProjectMembers(members, limit = MEMBERS_LINE_LIMIT) {
115
+ if (members.length === 0)
116
+ return '';
117
+ // Collapse identical cells — 35 same-harness sessions in the same state are
118
+ // one fact (`claude · running ×16`), not six truncated duplicates.
119
+ const counts = new Map();
120
+ for (const m of sortProjectMembers(members)) {
121
+ const parts = [m.agent, m.status];
122
+ if (m.ticket)
123
+ parts.push(m.ticket);
124
+ const cell = parts.join(' · ') + (m.host ? ` @${m.host}` : '');
125
+ const key = cell.toLowerCase();
126
+ const entry = counts.get(key);
127
+ if (entry)
128
+ entry.n++;
129
+ else
130
+ counts.set(key, { cell, n: 1 });
131
+ }
132
+ const entries = [...counts.values()];
133
+ const shown = entries.slice(0, Math.max(1, limit));
134
+ const shownMembers = shown.reduce((acc, e) => acc + e.n, 0);
135
+ const more = members.length - shownMembers;
136
+ const cells = shown.map(({ cell, n }) => (n > 1 ? `${cell} ×${n}` : cell));
137
+ return cells.join(chalk.dim(' · ')) + (more > 0 ? chalk.dim(` · +${more} more`) : '');
138
+ }
139
+ /**
140
+ * Harvest the signals that don't live on the active-session list: recently
141
+ * merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
142
+ * local activity-milestone log, matched to the project by cwd). Best-effort —
143
+ * a missing `gh`, no auth, or no repo degrades to zero rather than throwing, so
144
+ * `projects status` still renders. `nowMs` is injected for testability.
145
+ */
146
+ export async function enrichProjectSignals(def, windowDays, nowMs, opts = {}) {
147
+ const sinceMs = nowMs - windowDays * 86_400_000;
148
+ const out = { windowDays, mergedPrs: 0, artifacts: 0 };
149
+ try {
150
+ const evs = readRecentActivity({ events: ['artifact.created'], sinceMs, root: opts.activityRoot });
151
+ const mine = evs.filter((e) => projectNameForCwd(e.cwd, [def]) === def.name);
152
+ out.artifacts = mine.length;
153
+ if (mine.length && typeof mine[0].detail === 'string')
154
+ out.lastArtifact = mine[0].detail;
155
+ }
156
+ catch {
157
+ /* activity log unreadable — best-effort */
158
+ }
159
+ if (def.repo && !opts.skipRemote) {
160
+ try {
161
+ const { stdout } = await execFileAsync('gh', ['pr', 'list', '--repo', def.repo, '--state', 'merged', '--json', 'number,mergedAt', '--limit', '100'], { timeout: 8000, encoding: 'utf8' });
162
+ const rows = JSON.parse(stdout);
163
+ out.mergedPrs = rows.filter((r) => r.mergedAt && Date.parse(r.mergedAt) >= sinceMs).length;
164
+ }
165
+ catch {
166
+ /* gh missing / unauthenticated / repo not found — skip this signal */
167
+ }
168
+ // Latest release of the PRIMARY repo only (repos[] is deliberately not
169
+ // scanned — one release line per card). Same best-effort degradation.
170
+ try {
171
+ const { stdout } = await execFileAsync('gh', ['release', 'list', '-R', def.repo, '-L', '1', '--json', 'tagName,publishedAt'], { timeout: 8000, encoding: 'utf8' });
172
+ const rows = JSON.parse(stdout);
173
+ const first = rows[0];
174
+ if (first?.tagName)
175
+ out.latestRelease = { tag: first.tagName, publishedAt: first.publishedAt ?? '' };
176
+ }
177
+ catch {
178
+ /* gh missing / unauthenticated / repo has no releases — skip this signal */
179
+ }
180
+ }
181
+ return out;
182
+ }