@phnx-labs/agents-cli 1.20.90 → 1.20.92

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 (112) 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/exec.d.ts +14 -0
  9. package/dist/commands/exec.js +144 -14
  10. package/dist/commands/feed.js +77 -4
  11. package/dist/commands/hooks.js +22 -6
  12. package/dist/commands/perf.d.ts +14 -0
  13. package/dist/commands/perf.js +221 -0
  14. package/dist/commands/projects.d.ts +12 -0
  15. package/dist/commands/projects.js +358 -0
  16. package/dist/commands/routines.js +30 -24
  17. package/dist/commands/secrets.d.ts +43 -4
  18. package/dist/commands/secrets.js +217 -32
  19. package/dist/commands/send.d.ts +5 -1
  20. package/dist/commands/send.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +15 -0
  22. package/dist/commands/sessions-picker.js +107 -13
  23. package/dist/commands/sessions-resume.d.ts +2 -0
  24. package/dist/commands/sessions-resume.js +9 -1
  25. package/dist/commands/sessions.d.ts +10 -5
  26. package/dist/commands/sessions.js +65 -27
  27. package/dist/index.js +19 -3
  28. package/dist/lib/activity.d.ts +80 -13
  29. package/dist/lib/activity.js +418 -74
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/catchup.d.ts +105 -0
  33. package/dist/lib/catchup.js +160 -0
  34. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  35. package/dist/lib/channels/providers/desktop.js +132 -0
  36. package/dist/lib/channels/providers/index.js +2 -0
  37. package/dist/lib/daemon.js +74 -13
  38. package/dist/lib/devices/registry.d.ts +14 -0
  39. package/dist/lib/devices/registry.js +37 -0
  40. package/dist/lib/events.d.ts +12 -0
  41. package/dist/lib/events.js +122 -9
  42. package/dist/lib/exec.js +10 -0
  43. package/dist/lib/feed-broadcast.d.ts +47 -0
  44. package/dist/lib/feed-broadcast.js +65 -1
  45. package/dist/lib/feed-post.d.ts +10 -0
  46. package/dist/lib/feed-post.js +9 -3
  47. package/dist/lib/feed.d.ts +47 -1
  48. package/dist/lib/feed.js +38 -0
  49. package/dist/lib/hooks/cache.d.ts +2 -0
  50. package/dist/lib/hooks/cache.js +24 -4
  51. package/dist/lib/hosts/remote-cmd.js +4 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +14 -4
  54. package/dist/lib/menubar/install-menubar.js +20 -6
  55. package/dist/lib/overdue.d.ts +14 -0
  56. package/dist/lib/overdue.js +37 -1
  57. package/dist/lib/perf/db.d.ts +25 -0
  58. package/dist/lib/perf/db.js +290 -0
  59. package/dist/lib/perf/spool.d.ts +18 -0
  60. package/dist/lib/perf/spool.js +79 -0
  61. package/dist/lib/perf/types.d.ts +45 -0
  62. package/dist/lib/perf/types.js +2 -0
  63. package/dist/lib/project-key.d.ts +44 -0
  64. package/dist/lib/project-key.js +79 -0
  65. package/dist/lib/project-root.js +16 -0
  66. package/dist/lib/project-status.d.ts +69 -0
  67. package/dist/lib/project-status.js +101 -0
  68. package/dist/lib/projects.d.ts +138 -0
  69. package/dist/lib/projects.js +301 -0
  70. package/dist/lib/remote-agents-json.d.ts +9 -0
  71. package/dist/lib/remote-agents-json.js +11 -5
  72. package/dist/lib/routines-project.js +6 -0
  73. package/dist/lib/routines.d.ts +30 -1
  74. package/dist/lib/routines.js +11 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/list-filter.d.ts +94 -0
  78. package/dist/lib/secrets/list-filter.js +245 -0
  79. package/dist/lib/session/bash-command.d.ts +53 -0
  80. package/dist/lib/session/bash-command.js +364 -0
  81. package/dist/lib/session/digest.d.ts +13 -0
  82. package/dist/lib/session/digest.js +48 -1
  83. package/dist/lib/session/discover.d.ts +1 -2
  84. package/dist/lib/session/discover.js +7 -24
  85. package/dist/lib/session/highlights.d.ts +82 -0
  86. package/dist/lib/session/highlights.js +251 -0
  87. package/dist/lib/session/parse.js +23 -1
  88. package/dist/lib/session/relative-time.d.ts +37 -0
  89. package/dist/lib/session/relative-time.js +96 -8
  90. package/dist/lib/session/remote-list.js +5 -2
  91. package/dist/lib/session/render.d.ts +9 -9
  92. package/dist/lib/session/render.js +112 -73
  93. package/dist/lib/session/types.d.ts +4 -1
  94. package/dist/lib/ssh-exec.d.ts +6 -0
  95. package/dist/lib/ssh-exec.js +10 -1
  96. package/dist/lib/startup/command-registry.d.ts +2 -0
  97. package/dist/lib/startup/command-registry.js +4 -0
  98. package/dist/lib/state.d.ts +11 -0
  99. package/dist/lib/state.js +16 -0
  100. package/dist/lib/terminal/backends/index.d.ts +10 -2
  101. package/dist/lib/terminal/backends/index.js +14 -2
  102. package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
  103. package/dist/lib/terminal/backends/terminal-app.js +73 -0
  104. package/dist/lib/terminal/index.d.ts +2 -1
  105. package/dist/lib/terminal/index.js +2 -1
  106. package/dist/lib/terminal/preferred.d.ts +89 -0
  107. package/dist/lib/terminal/preferred.js +87 -0
  108. package/dist/lib/terminal/run-surface.d.ts +82 -0
  109. package/dist/lib/terminal/run-surface.js +146 -0
  110. package/dist/lib/terminal/types.d.ts +1 -1
  111. package/dist/lib/types.d.ts +1 -1
  112. package/package.json +4 -1
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Hot-path perf writers — append-only NDJSON spool, no SQLite.
3
+ *
4
+ * Loaded from the CLI root `postAction` and from `events.ts` timing helpers.
5
+ * Must stay free of `../sqlite.js` so ordinary commands never load node:sqlite
6
+ * (which emits ExperimentalWarning on stderr).
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as os from 'os';
10
+ import * as path from 'path';
11
+ import { getPerfSpoolPath } from '../state.js';
12
+ import { localMachineId } from '../session/origin-machine.js';
13
+ let _spoolOverride = null;
14
+ let _disabled = false;
15
+ /** Test seam — pair with db._resetPerfDbForTest. */
16
+ export function _resetPerfSpoolForTest(spoolPath) {
17
+ _spoolOverride = spoolPath === undefined ? null : spoolPath;
18
+ _disabled = false;
19
+ }
20
+ export function resolveSpoolPath() {
21
+ if (process.env.AGENTS_PERF_SPOOL)
22
+ return process.env.AGENTS_PERF_SPOOL;
23
+ if (_spoolOverride)
24
+ return _spoolOverride;
25
+ return getPerfSpoolPath();
26
+ }
27
+ function isDisabled() {
28
+ if (_disabled)
29
+ return true;
30
+ const v = process.env.AGENTS_DISABLE_PERF;
31
+ return v === '1' || v === 'true';
32
+ }
33
+ /** Short session id: first 8 chars (sessions.short_id shape). */
34
+ export function shortSessionId(sessionId) {
35
+ if (!sessionId)
36
+ return undefined;
37
+ const cleaned = sessionId.replace(/^session_/, '');
38
+ return cleaned.length >= 8 ? cleaned.slice(0, 8) : cleaned || undefined;
39
+ }
40
+ /**
41
+ * Append one sample to the spool. Never throws. Never opens SQLite.
42
+ */
43
+ export function recordSample(sample) {
44
+ if (isDisabled())
45
+ return;
46
+ if (!sample.label || !Number.isFinite(sample.durationMs))
47
+ return;
48
+ try {
49
+ const tsMs = sample.tsMs ?? Date.now();
50
+ const sessionId = sample.sessionId;
51
+ const sessionShort = sample.sessionShort ?? shortSessionId(sessionId);
52
+ const machine = sample.machine ?? localMachineId();
53
+ const hostname = sample.hostname ?? os.hostname();
54
+ const line = JSON.stringify({
55
+ ts_ms: tsMs,
56
+ kind: sample.kind,
57
+ label: sample.label,
58
+ duration_ms: sample.durationMs,
59
+ session_id: sessionId,
60
+ session_short: sessionShort,
61
+ agent: sample.agent,
62
+ agent_version: sample.agentVersion,
63
+ machine,
64
+ hostname,
65
+ actor: sample.actor,
66
+ cwd: sample.cwd,
67
+ cache: sample.cache,
68
+ exit_code: sample.exitCode,
69
+ status: sample.status,
70
+ meta_json: sample.metaJson,
71
+ });
72
+ const spool = resolveSpoolPath();
73
+ fs.mkdirSync(path.dirname(spool), { recursive: true, mode: 0o700 });
74
+ fs.appendFileSync(spool, line + '\n', { mode: 0o600 });
75
+ }
76
+ catch {
77
+ // Fail soft.
78
+ }
79
+ }
@@ -0,0 +1,45 @@
1
+ /** Shared perf sample shape (spool NDJSON + SQLite rows). */
2
+ export type PerfKind = 'hook.fire' | 'perf.timing' | 'command.end' | string;
3
+ export interface PerfSample {
4
+ tsMs?: number;
5
+ kind: PerfKind;
6
+ label: string;
7
+ durationMs: number;
8
+ /** Full session id — same string as sessions.id when known. */
9
+ sessionId?: string;
10
+ /** First 8 chars of sessionId (sessions.short_id shape). */
11
+ sessionShort?: string;
12
+ agent?: string;
13
+ agentVersion?: string;
14
+ /** Fleet registry name (sessions.machine), preferred over raw hostname. */
15
+ machine?: string;
16
+ hostname?: string;
17
+ actor?: string;
18
+ cwd?: string;
19
+ cache?: string;
20
+ exitCode?: number;
21
+ status?: string;
22
+ metaJson?: string;
23
+ }
24
+ export interface PerfAggregateRow {
25
+ kind: string;
26
+ label: string;
27
+ n: number;
28
+ p50Ms: number;
29
+ p99Ms: number;
30
+ meanMs: number;
31
+ maxMs: number;
32
+ minMs: number;
33
+ cacheHitPct?: number;
34
+ cacheStalePct?: number;
35
+ cacheMissPct?: number;
36
+ errorCount?: number;
37
+ }
38
+ export interface AggregateOptions {
39
+ days?: number;
40
+ kinds?: string[];
41
+ label?: string;
42
+ machine?: string;
43
+ agent?: string;
44
+ minN?: number;
45
+ }
@@ -0,0 +1,2 @@
1
+ /** Shared perf sample shape (spool NDJSON + SQLite rows). */
2
+ export {};
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The one worktree-aware cwd -> project fold.
3
+ *
4
+ * Several surfaces bucket work "by project": the `agents sessions` overview,
5
+ * the `agents activity` timeline, and anything else that has a cwd and needs a
6
+ * stable repo-level key. They must agree, or the same session shows up under
7
+ * `agents-cli` in one view and `my-branch-slug` in another — so the rule lives
8
+ * here and every caller delegates.
9
+ *
10
+ * The rule: a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) folds to
11
+ * the REPO directory name, so a worktree groups with the repo it branched from;
12
+ * any other path resolves to its own basename. {@link projectKeyFromCwd} is pure
13
+ * — no filesystem, no git, so it works identically for a remote peer's events as
14
+ * for local ones; {@link resolveProjectKey} adds the filesystem repo-root walk
15
+ * for paths this machine can see.
16
+ */
17
+ /**
18
+ * Resolve a stable project key from a working directory, or `undefined` when
19
+ * the path carries nothing usable (empty, `/`, whitespace).
20
+ */
21
+ export declare function projectKeyFromCwd(cwd?: string | null): string | undefined;
22
+ /**
23
+ * The git working-tree root containing `dir`, by walking up for a `.git` entry
24
+ * — a directory in a normal checkout, a file in a linked worktree, so one
25
+ * `existsSync` covers both. Filesystem-only: no `git` process per lookup, which
26
+ * matters because a timeline can hold dozens of distinct cwds.
27
+ *
28
+ * Returns `undefined` when `dir` is not inside a repo, when it does not exist
29
+ * (a path from another machine), or when the only repo found IS the home
30
+ * directory — a dotfiles repo at `$HOME` would otherwise swallow every
31
+ * non-project directory under it into one bogus "project".
32
+ */
33
+ export declare function repoRootForCwd(dir: string, home?: string): string | undefined;
34
+ /**
35
+ * Resolve the project key for a cwd **on this machine**: the repository it
36
+ * belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
37
+ * groups under `<repo>`, not `cli`), else the directory itself.
38
+ *
39
+ * Each machine resolves its own paths — a peer answering a fan-out stamps the
40
+ * project for its events before they cross the wire — so this is never asked
41
+ * about a path it cannot see. {@link projectKeyFromCwd} is the pure fold for
42
+ * everything else.
43
+ */
44
+ export declare function resolveProjectKey(cwd?: string | null, home?: string): string | undefined;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The one worktree-aware cwd -> project fold.
3
+ *
4
+ * Several surfaces bucket work "by project": the `agents sessions` overview,
5
+ * the `agents activity` timeline, and anything else that has a cwd and needs a
6
+ * stable repo-level key. They must agree, or the same session shows up under
7
+ * `agents-cli` in one view and `my-branch-slug` in another — so the rule lives
8
+ * here and every caller delegates.
9
+ *
10
+ * The rule: a worktree cwd (`…/<repo>/.agents/worktrees/<slug>[/sub]`) folds to
11
+ * the REPO directory name, so a worktree groups with the repo it branched from;
12
+ * any other path resolves to its own basename. {@link projectKeyFromCwd} is pure
13
+ * — no filesystem, no git, so it works identically for a remote peer's events as
14
+ * for local ones; {@link resolveProjectKey} adds the filesystem repo-root walk
15
+ * for paths this machine can see.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as os from 'os';
19
+ import * as path from 'path';
20
+ const WORKTREE_SEGMENT = '/.agents/worktrees/';
21
+ /**
22
+ * Resolve a stable project key from a working directory, or `undefined` when
23
+ * the path carries nothing usable (empty, `/`, whitespace).
24
+ */
25
+ export function projectKeyFromCwd(cwd) {
26
+ if (!cwd)
27
+ return undefined;
28
+ const norm = cwd.replace(/\\/g, '/').replace(/\/+$/, '').trim();
29
+ if (!norm)
30
+ return undefined;
31
+ const wtIdx = norm.indexOf(WORKTREE_SEGMENT);
32
+ if (wtIdx > 0) {
33
+ const repoPath = norm.slice(0, wtIdx);
34
+ const base = repoPath.slice(repoPath.lastIndexOf('/') + 1);
35
+ if (base)
36
+ return base;
37
+ }
38
+ const base = norm.slice(norm.lastIndexOf('/') + 1);
39
+ return base || undefined;
40
+ }
41
+ /**
42
+ * The git working-tree root containing `dir`, by walking up for a `.git` entry
43
+ * — a directory in a normal checkout, a file in a linked worktree, so one
44
+ * `existsSync` covers both. Filesystem-only: no `git` process per lookup, which
45
+ * matters because a timeline can hold dozens of distinct cwds.
46
+ *
47
+ * Returns `undefined` when `dir` is not inside a repo, when it does not exist
48
+ * (a path from another machine), or when the only repo found IS the home
49
+ * directory — a dotfiles repo at `$HOME` would otherwise swallow every
50
+ * non-project directory under it into one bogus "project".
51
+ */
52
+ export function repoRootForCwd(dir, home = os.homedir()) {
53
+ const stop = path.resolve(home);
54
+ let current = path.resolve(dir);
55
+ for (;;) {
56
+ if (fs.existsSync(path.join(current, '.git')))
57
+ return current === stop ? undefined : current;
58
+ const parent = path.dirname(current);
59
+ if (parent === current)
60
+ return undefined;
61
+ current = parent;
62
+ }
63
+ }
64
+ /**
65
+ * Resolve the project key for a cwd **on this machine**: the repository it
66
+ * belongs to when there is one (so a monorepo subdir like `<repo>/apps/cli`
67
+ * groups under `<repo>`, not `cli`), else the directory itself.
68
+ *
69
+ * Each machine resolves its own paths — a peer answering a fan-out stamps the
70
+ * project for its events before they cross the wire — so this is never asked
71
+ * about a path it cannot see. {@link projectKeyFromCwd} is the pure fold for
72
+ * everything else.
73
+ */
74
+ export function resolveProjectKey(cwd, home) {
75
+ if (!cwd)
76
+ return undefined;
77
+ const root = repoRootForCwd(cwd, home);
78
+ return projectKeyFromCwd(root ?? cwd);
79
+ }
@@ -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,69 @@
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 project's live session rollup. */
17
+ export interface ProjectSessionRollup {
18
+ name: string;
19
+ /** Total sessions whose cwd is inside this project. */
20
+ agents: number;
21
+ /** Count per lifecycle status. */
22
+ byStatus: Partial<Record<ActiveStatus, number>>;
23
+ /** Summed checklist progress across this project's sessions. */
24
+ plan: {
25
+ done: number;
26
+ total: number;
27
+ };
28
+ /** Distinct open PRs held by this project's sessions. */
29
+ openPrs: {
30
+ url: string;
31
+ number?: number;
32
+ }[];
33
+ /** Distinct tickets worked or created by this project's sessions. */
34
+ tickets: string[];
35
+ /** Sessions running inside a worktree. */
36
+ worktrees: number;
37
+ }
38
+ /**
39
+ * Roll active sessions up by project. Returns a map keyed by project name,
40
+ * containing only projects with at least one matched session — callers merge
41
+ * with the full definition list to show zero-agent projects.
42
+ */
43
+ export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
44
+ /** Plan completion percentage (0–100), or undefined when nothing is tracked. */
45
+ export declare function planPct(plan: {
46
+ done: number;
47
+ total: number;
48
+ }): number | undefined;
49
+ /** Harvested signals not on the session list: repo-global merged PRs + local artifacts, in a time window. */
50
+ export interface ProjectRemoteSignals {
51
+ windowDays: number;
52
+ /** PRs merged into the primary repo within the window (via `gh`). */
53
+ mergedPrs: number;
54
+ /** Artifacts agents produced within the window (activity.created milestones). */
55
+ artifacts: number;
56
+ /** Basename of the most recent artifact, when any. */
57
+ lastArtifact?: string;
58
+ }
59
+ /**
60
+ * Harvest the signals that don't live on the active-session list: recently
61
+ * merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
62
+ * local activity-milestone log, matched to the project by cwd). Best-effort —
63
+ * a missing `gh`, no auth, or no repo degrades to zero rather than throwing, so
64
+ * `projects status` still renders. `nowMs` is injected for testability.
65
+ */
66
+ export declare function enrichProjectSignals(def: ProjectDef, windowDays: number, nowMs: number, opts?: {
67
+ activityRoot?: string;
68
+ skipRemote?: boolean;
69
+ }): Promise<ProjectRemoteSignals>;
@@ -0,0 +1,101 @@
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 { projectNameForCwd } from './projects.js';
17
+ import { readRecentActivity } from './activity.js';
18
+ const execFileAsync = promisify(execFile);
19
+ function blank(name) {
20
+ return { name, agents: 0, byStatus: {}, plan: { done: 0, total: 0 }, openPrs: [], tickets: [], worktrees: 0 };
21
+ }
22
+ /**
23
+ * Roll active sessions up by project. Returns a map keyed by project name,
24
+ * containing only projects with at least one matched session — callers merge
25
+ * with the full definition list to show zero-agent projects.
26
+ */
27
+ export function rollupSessionsByProject(defs, sessions) {
28
+ const map = new Map();
29
+ const prSeen = new Map();
30
+ const ticketSeen = new Map();
31
+ for (const s of sessions) {
32
+ const name = projectNameForCwd(s.cwd, defs);
33
+ if (!name)
34
+ continue;
35
+ let r = map.get(name);
36
+ if (!r) {
37
+ r = blank(name);
38
+ map.set(name, r);
39
+ prSeen.set(name, new Set());
40
+ ticketSeen.set(name, new Set());
41
+ }
42
+ r.agents++;
43
+ r.byStatus[s.status] = (r.byStatus[s.status] ?? 0) + 1;
44
+ if (s.todos) {
45
+ r.plan.done += s.todos.done;
46
+ r.plan.total += s.todos.total;
47
+ }
48
+ if (s.pr?.url && !prSeen.get(name).has(s.pr.url)) {
49
+ prSeen.get(name).add(s.pr.url);
50
+ r.openPrs.push({ url: s.pr.url, number: s.pr.number });
51
+ }
52
+ const tset = ticketSeen.get(name);
53
+ for (const t of [s.ticket?.id, ...(s.createdTickets ?? [])]) {
54
+ if (t && !tset.has(t)) {
55
+ tset.add(t);
56
+ r.tickets.push(t);
57
+ }
58
+ }
59
+ if (s.worktree)
60
+ r.worktrees++;
61
+ }
62
+ return map;
63
+ }
64
+ /** Plan completion percentage (0–100), or undefined when nothing is tracked. */
65
+ export function planPct(plan) {
66
+ if (plan.total <= 0)
67
+ return undefined;
68
+ return Math.round((plan.done / plan.total) * 100);
69
+ }
70
+ /**
71
+ * Harvest the signals that don't live on the active-session list: recently
72
+ * merged PRs (from GitHub via `gh`) and artifacts agents produced (from the
73
+ * local activity-milestone log, matched to the project by cwd). Best-effort —
74
+ * a missing `gh`, no auth, or no repo degrades to zero rather than throwing, so
75
+ * `projects status` still renders. `nowMs` is injected for testability.
76
+ */
77
+ export async function enrichProjectSignals(def, windowDays, nowMs, opts = {}) {
78
+ const sinceMs = nowMs - windowDays * 86_400_000;
79
+ const out = { windowDays, mergedPrs: 0, artifacts: 0 };
80
+ try {
81
+ const evs = readRecentActivity({ events: ['artifact.created'], sinceMs, root: opts.activityRoot });
82
+ const mine = evs.filter((e) => projectNameForCwd(e.cwd, [def]) === def.name);
83
+ out.artifacts = mine.length;
84
+ if (mine.length && typeof mine[0].detail === 'string')
85
+ out.lastArtifact = mine[0].detail;
86
+ }
87
+ catch {
88
+ /* activity log unreadable — best-effort */
89
+ }
90
+ if (def.repo && !opts.skipRemote) {
91
+ try {
92
+ const { stdout } = await execFileAsync('gh', ['pr', 'list', '--repo', def.repo, '--state', 'merged', '--json', 'number,mergedAt', '--limit', '100'], { timeout: 8000, encoding: 'utf8' });
93
+ const rows = JSON.parse(stdout);
94
+ out.mergedPrs = rows.filter((r) => r.mergedAt && Date.parse(r.mergedAt) >= sinceMs).length;
95
+ }
96
+ catch {
97
+ /* gh missing / unauthenticated / repo not found — skip this signal */
98
+ }
99
+ }
100
+ return out;
101
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Named project definitions — the layer above the `--project <slug>` convention.
3
+ *
4
+ * `agents run --project <slug>` already resolves a bare name to a working
5
+ * directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
6
+ * This module adds editable definitions on top: one YAML file per project under
7
+ * `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
8
+ * `monitors/`, and `teams/` dirs in the user repo (so definitions sync across
9
+ * machines for free via `agents push/pull`). A defined project can name itself
10
+ * independently of its folder, bind more than one repo, pin a monorepo subpath,
11
+ * describe context subdirectories an agent should start from, carry a Linear
12
+ * link and external integrations, and set an explicit default path.
13
+ *
14
+ * Portable by construction: `root`/`defaultPath` are stored home-relative
15
+ * (`~/…`) via `toHomeRelative`, so the same definition re-roots on any machine
16
+ * whose home differs — the exact mechanism `project-root.ts` already relies on.
17
+ *
18
+ * Resolution stays additive: an undefined slug still resolves exactly as today
19
+ * (see `resolveProjectRef`), a defined one overrides it.
20
+ */
21
+ /** A git repo bound to a project, with an optional monorepo subpath. */
22
+ export interface ProjectRepo {
23
+ /** GitHub slug `owner/repo`. */
24
+ slug: string;
25
+ /** Optional path within the repo an agent working this project cares about. */
26
+ subpath?: string;
27
+ }
28
+ /**
29
+ * A described context anchor: a subdirectory plus what it is. Agents starting on
30
+ * the project read `purpose` to know where to look — an indexed starting point,
31
+ * not just a path. This is the richer form of the single monorepo-focus dir.
32
+ */
33
+ export interface ProjectContext {
34
+ /** Path relative to the project root (e.g. `apps/web`). */
35
+ path: string;
36
+ /** One line on how this subtree relates to the project. */
37
+ purpose: string;
38
+ }
39
+ /** An external context source hung off the project (surfaced in `projects show`). */
40
+ export interface ProjectIntegration {
41
+ /** e.g. `gdrive`, `notion`, `figma`, `url`. */
42
+ kind: string;
43
+ url: string;
44
+ label?: string;
45
+ }
46
+ /** The parsed `~/.agents/projects/<name>.yaml`. */
47
+ export interface ProjectDef {
48
+ /** Stable id; matches the filename; what `--project` takes. */
49
+ name: string;
50
+ description?: string;
51
+ /** Repo / monorepo root, home-relative for portability. */
52
+ root?: string;
53
+ /** Where an agent's cwd lands. Defaults to `root` when unset. */
54
+ defaultPath?: string;
55
+ /** Primary GitHub slug (`owner/repo`) — for PR / CI / status roll-up. */
56
+ repo?: string;
57
+ /** All bound repos, each with an optional monorepo subpath. */
58
+ repos?: ProjectRepo[];
59
+ /** Described starting points inside the project. */
60
+ contexts?: ProjectContext[];
61
+ /** External context sources (Drive, docs, …). */
62
+ integrations?: ProjectIntegration[];
63
+ /** Linear project link — reuses the existing GraphQL path. */
64
+ linear?: {
65
+ projectId?: string;
66
+ url?: string;
67
+ };
68
+ /** Free-form doc links surfaced in `projects show`. */
69
+ docs?: string[];
70
+ }
71
+ /** A project name safe to use as a filename: no separators, `..`, or leading dot. */
72
+ export declare function isSafeProjectName(name: string): boolean;
73
+ /** Absolute path to a project's YAML definition. Throws on an unsafe name. */
74
+ export declare function projectDefPath(name: string): string;
75
+ /**
76
+ * Validate a raw parsed object into a `ProjectDef`, throwing an actionable error
77
+ * on the first problem. A malformed document or identity (bad/mismatched name)
78
+ * throws; malformed entries inside the optional lists (`repos`/`contexts`/
79
+ * `integrations`) are dropped so one bad row can't sink an otherwise good def.
80
+ */
81
+ export declare function validateProjectDef(raw: unknown, sourceName?: string): ProjectDef;
82
+ /**
83
+ * Load a single project definition by name. Returns undefined when the file is
84
+ * absent (the common "not a defined project, fall back to convention" case) but
85
+ * throws when a file EXISTS and is malformed — a broken definition is loud.
86
+ */
87
+ export declare function loadProjectDef(name: string): ProjectDef | undefined;
88
+ /**
89
+ * List every defined project, sorted by name. Skips (does not throw on) a
90
+ * malformed file so one bad definition can't break `projects list`; the loader
91
+ * for a single named project stays strict.
92
+ */
93
+ export declare function listProjectDefs(): ProjectDef[];
94
+ /**
95
+ * Persist a project definition, normalizing `root`/`defaultPath` to home-relative
96
+ * so it stays portable across machines. Creates the projects dir on first write.
97
+ */
98
+ export declare function writeProjectDef(def: ProjectDef): string;
99
+ /** Delete a project definition. Returns true if a file was removed. Never touches the repo. */
100
+ export declare function removeProjectDef(name: string): boolean;
101
+ /**
102
+ * The cwd an agent lands in for a defined project: `defaultPath` when set, else
103
+ * `root`. Home-relative when `forRemote` (the remote shell expands `~`), else
104
+ * expanded against the local home. Returns undefined when neither is set.
105
+ */
106
+ export declare function projectBasePath(def: ProjectDef, forRemote: boolean): string | undefined;
107
+ /**
108
+ * Which defined project a session belongs to, derived from its working
109
+ * directory. A session whose `cwd` sits inside a project's repo root (or a
110
+ * worktree under it) is a member; the LONGEST matching root wins so a nested
111
+ * project beats its parent. Returns undefined when no definition contains the
112
+ * path.
113
+ *
114
+ * The comparison is against the LOCAL home: roots and the cwd are both expanded
115
+ * with `expandLocalHome` and resolved, so this matches sessions whose cwd shares
116
+ * this machine's home layout. A session recorded on a different-home machine
117
+ * (`/Users/x/…` vs `/home/x/…`) will not match until the fleet-wide,
118
+ * home-relative variant lands (see the deferred item in docs/11-projects.md).
119
+ */
120
+ export declare function projectNameForCwd(cwd: string | undefined, defs: ProjectDef[]): string | undefined;
121
+ /**
122
+ * The canonical project label for a cwd, for every surface that buckets work by
123
+ * project (the activity timeline, feed posts, the sessions overview): the
124
+ * DEFINED project whose root contains the cwd (longest root wins, so a
125
+ * multi-repo project reads as one bucket), else the repository-level key from
126
+ * {@link resolveProjectKey}. `defs` comes from {@link listProjectDefs}, which is
127
+ * fail-open — with no definitions this degrades to exactly today's behavior.
128
+ */
129
+ export declare function resolveProjectNameForCwd(cwd: string | undefined | null, defs: ProjectDef[]): string | undefined;
130
+ /**
131
+ * Resolve a defined project's ref to a working directory, mirroring
132
+ * `buildProjectPath`'s `forRemote` contract (a home-relative `~/…` for the
133
+ * remote shell to expand, an absolute local path otherwise). A `@worktree`
134
+ * lands under the repo ROOT's `.agents/worktrees/`, not the `defaultPath`
135
+ * subdir — worktrees are per-repo, not per-focus. Returns undefined when the
136
+ * definition carries no `root`/`defaultPath` (caller falls back to convention).
137
+ */
138
+ export declare function resolveDefinedProjectPath(def: ProjectDef, worktree: string | undefined, forRemote: boolean): string | undefined;