@phnx-labs/agents-cli 1.20.77 → 1.20.82

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 (212) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +27 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +74 -0
  12. package/dist/commands/doctor.js +718 -40
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +53 -8
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +195 -11
  29. package/dist/commands/routines.js +16 -4
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +108 -34
  33. package/dist/commands/sessions-browser.d.ts +45 -3
  34. package/dist/commands/sessions-browser.js +118 -12
  35. package/dist/commands/sessions-export.d.ts +3 -0
  36. package/dist/commands/sessions-export.js +13 -10
  37. package/dist/commands/sessions-inject.d.ts +7 -4
  38. package/dist/commands/sessions-inject.js +18 -18
  39. package/dist/commands/sessions-picker.d.ts +15 -1
  40. package/dist/commands/sessions-picker.js +90 -7
  41. package/dist/commands/sessions-resume.d.ts +16 -0
  42. package/dist/commands/sessions-resume.js +14 -13
  43. package/dist/commands/sessions.d.ts +126 -1
  44. package/dist/commands/sessions.js +365 -42
  45. package/dist/commands/set.d.ts +15 -0
  46. package/dist/commands/set.js +79 -0
  47. package/dist/commands/setup-fleet.d.ts +21 -0
  48. package/dist/commands/setup-fleet.js +201 -0
  49. package/dist/commands/setup-secrets.d.ts +20 -0
  50. package/dist/commands/setup-secrets.js +223 -0
  51. package/dist/commands/setup.js +17 -3
  52. package/dist/commands/skills.js +1 -1
  53. package/dist/commands/ssh.js +9 -1
  54. package/dist/commands/sync.js +7 -2
  55. package/dist/commands/teams-picker.js +0 -1
  56. package/dist/commands/teams.d.ts +33 -1
  57. package/dist/commands/teams.js +208 -44
  58. package/dist/commands/versions.js +10 -5
  59. package/dist/commands/view.d.ts +1 -0
  60. package/dist/commands/view.js +11 -0
  61. package/dist/index.js +8 -13
  62. package/dist/lib/acp/harnesses.js +0 -7
  63. package/dist/lib/activity.d.ts +156 -0
  64. package/dist/lib/activity.js +282 -0
  65. package/dist/lib/agents.d.ts +9 -23
  66. package/dist/lib/agents.js +72 -100
  67. package/dist/lib/auto-pull.d.ts +16 -8
  68. package/dist/lib/auto-pull.js +23 -28
  69. package/dist/lib/browser/service.js +3 -0
  70. package/dist/lib/browser/types.d.ts +7 -0
  71. package/dist/lib/capabilities.js +6 -2
  72. package/dist/lib/crabbox/lease.js +2 -2
  73. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  74. package/dist/lib/crabbox/setup-copy.js +4 -4
  75. package/dist/lib/daemon.d.ts +26 -40
  76. package/dist/lib/daemon.js +63 -123
  77. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  78. package/dist/lib/devices/fleet-divergence.js +188 -0
  79. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  80. package/dist/lib/devices/fleet-inventory.js +57 -0
  81. package/dist/lib/devices/fleet.d.ts +1 -1
  82. package/dist/lib/devices/fleet.js +1 -1
  83. package/dist/lib/devices/health-report.d.ts +10 -2
  84. package/dist/lib/devices/health-report.js +32 -1
  85. package/dist/lib/doctor-diff.d.ts +20 -0
  86. package/dist/lib/doctor-diff.js +6 -1
  87. package/dist/lib/drift.d.ts +31 -11
  88. package/dist/lib/drift.js +58 -7
  89. package/dist/lib/events.d.ts +8 -1
  90. package/dist/lib/events.js +10 -1
  91. package/dist/lib/exec.js +35 -8
  92. package/dist/lib/feed-post.d.ts +28 -1
  93. package/dist/lib/feed-post.js +110 -2
  94. package/dist/lib/fleet/apply.js +8 -1
  95. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  96. package/dist/lib/fleet/auth-sync.js +25 -11
  97. package/dist/lib/fleet/remote-login.js +5 -0
  98. package/dist/lib/git.d.ts +27 -14
  99. package/dist/lib/git.js +138 -43
  100. package/dist/lib/hooks.d.ts +42 -0
  101. package/dist/lib/hooks.js +137 -93
  102. package/dist/lib/hosts/dispatch.d.ts +28 -1
  103. package/dist/lib/hosts/dispatch.js +33 -5
  104. package/dist/lib/hosts/option.js +2 -2
  105. package/dist/lib/hosts/passthrough.d.ts +22 -1
  106. package/dist/lib/hosts/passthrough.js +238 -13
  107. package/dist/lib/hosts/remote-cmd.js +2 -0
  108. package/dist/lib/hosts/run-target.d.ts +2 -0
  109. package/dist/lib/hosts/run-target.js +1 -0
  110. package/dist/lib/hq/floor.d.ts +1 -1
  111. package/dist/lib/hq/floor.js +6 -1
  112. package/dist/lib/mcp.js +3 -71
  113. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  116. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  117. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  118. package/dist/lib/menubar/notify-desktop.js +41 -5
  119. package/dist/lib/merged-resources.d.ts +11 -0
  120. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  121. package/dist/lib/permissions.d.ts +0 -32
  122. package/dist/lib/permissions.js +4 -157
  123. package/dist/lib/picker.js +1 -1
  124. package/dist/lib/platform/process.d.ts +16 -5
  125. package/dist/lib/platform/process.js +54 -0
  126. package/dist/lib/plugins.d.ts +0 -8
  127. package/dist/lib/plugins.js +4 -110
  128. package/dist/lib/project-resources.js +4 -1
  129. package/dist/lib/refresh.d.ts +5 -3
  130. package/dist/lib/refresh.js +7 -5
  131. package/dist/lib/resources/commands.js +4 -1
  132. package/dist/lib/resources/mcp.js +0 -4
  133. package/dist/lib/resources/permissions.d.ts +1 -1
  134. package/dist/lib/resources/permissions.js +1 -5
  135. package/dist/lib/resources/rules.js +4 -1
  136. package/dist/lib/resources/skills.js +4 -1
  137. package/dist/lib/resources/subagents.js +4 -0
  138. package/dist/lib/resources/types.d.ts +1 -1
  139. package/dist/lib/routines.d.ts +20 -0
  140. package/dist/lib/routines.js +6 -0
  141. package/dist/lib/run-defaults.d.ts +1 -0
  142. package/dist/lib/run-defaults.js +9 -0
  143. package/dist/lib/runner.d.ts +0 -12
  144. package/dist/lib/runner.js +71 -29
  145. package/dist/lib/sandbox.js +0 -16
  146. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  147. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  149. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  151. package/dist/lib/secrets/agent.d.ts +18 -0
  152. package/dist/lib/secrets/agent.js +131 -17
  153. package/dist/lib/secrets/audit.d.ts +46 -0
  154. package/dist/lib/secrets/audit.js +56 -0
  155. package/dist/lib/secrets/bundles.d.ts +12 -9
  156. package/dist/lib/secrets/bundles.js +47 -31
  157. package/dist/lib/secrets/index.d.ts +2 -1
  158. package/dist/lib/secrets/index.js +8 -2
  159. package/dist/lib/secrets/remote.d.ts +75 -2
  160. package/dist/lib/secrets/remote.js +139 -5
  161. package/dist/lib/secrets/scope.d.ts +26 -0
  162. package/dist/lib/secrets/scope.js +29 -0
  163. package/dist/lib/secrets/session-store.d.ts +10 -0
  164. package/dist/lib/secrets/session-store.js +64 -11
  165. package/dist/lib/secrets/sync.js +3 -3
  166. package/dist/lib/session/active.d.ts +117 -26
  167. package/dist/lib/session/active.js +199 -53
  168. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  169. package/dist/lib/session/actor-sidecar.js +101 -0
  170. package/dist/lib/session/db.d.ts +41 -1
  171. package/dist/lib/session/db.js +173 -8
  172. package/dist/lib/session/digest.js +126 -21
  173. package/dist/lib/session/discover.d.ts +19 -2
  174. package/dist/lib/session/discover.js +32 -11
  175. package/dist/lib/session/hook-sessions.d.ts +9 -0
  176. package/dist/lib/session/hook-sessions.js +57 -8
  177. package/dist/lib/session/origin-machine.d.ts +18 -0
  178. package/dist/lib/session/origin-machine.js +34 -0
  179. package/dist/lib/session/pid-registry.d.ts +13 -0
  180. package/dist/lib/session/render.d.ts +2 -0
  181. package/dist/lib/session/render.js +1 -1
  182. package/dist/lib/session/state.d.ts +1 -0
  183. package/dist/lib/session/state.js +1 -1
  184. package/dist/lib/session/sync/config.js +2 -2
  185. package/dist/lib/session/types.d.ts +16 -0
  186. package/dist/lib/smart-launch.d.ts +86 -0
  187. package/dist/lib/smart-launch.js +172 -0
  188. package/dist/lib/staleness/detectors/commands.js +2 -2
  189. package/dist/lib/staleness/detectors/permissions.js +1 -45
  190. package/dist/lib/staleness/writers/commands.js +4 -5
  191. package/dist/lib/staleness/writers/hooks.js +1 -1
  192. package/dist/lib/startup/command-registry.d.ts +1 -2
  193. package/dist/lib/startup/command-registry.js +2 -4
  194. package/dist/lib/subagents-registry.js +5 -12
  195. package/dist/lib/subagents.d.ts +0 -10
  196. package/dist/lib/subagents.js +0 -12
  197. package/dist/lib/teams/agents.d.ts +0 -2
  198. package/dist/lib/teams/agents.js +10 -27
  199. package/dist/lib/teams/index.d.ts +1 -1
  200. package/dist/lib/teams/index.js +1 -1
  201. package/dist/lib/types.d.ts +17 -9
  202. package/dist/lib/usage.d.ts +28 -5
  203. package/dist/lib/usage.js +271 -8
  204. package/dist/lib/versions.js +7 -1
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +25 -13
  207. package/package.json +1 -2
  208. package/dist/commands/check.d.ts +0 -15
  209. package/dist/commands/check.js +0 -180
  210. package/dist/commands/resources.d.ts +0 -5
  211. package/dist/lib/secrets/account-token.d.ts +0 -20
  212. package/dist/lib/secrets/account-token.js +0 -64
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Cross-device harness divergence for `agents doctor` (RUSH-2027).
3
+ *
4
+ * `agents fleet status` already fans out `agents doctor --json` per device and
5
+ * builds a {@link FleetHealthReport} of coarse device health (reachable, CLIs
6
+ * installed, agents-cli version skew, local sync drift). It deliberately does
7
+ * NOT compare fine-grained *resource presence* across devices — so a plugin like
8
+ * `swarm` installed on one box but missing on another is silent until a user
9
+ * types `/swarm:run` on the wrong machine and gets `Unknown command`.
10
+ *
11
+ * This module is the missing comparison. Each device self-reports its installed
12
+ * inventory (resource kinds, per-agent version sets, `.agents`/`.system` repo
13
+ * state) in its `doctor --json` payload; {@link compareFleetInventories} takes
14
+ * the collected per-device inventories and, treating the local machine as the
15
+ * baseline, flags:
16
+ *
17
+ * 1. Resource presence gaps — a resource present on one device, missing on
18
+ * another (either direction relative to the local baseline).
19
+ * 2. Agent version gaps — an agent version installed on one device but
20
+ * not another (e.g. `yosemite-s0 missing claude@2.1.220`).
21
+ * 3. `.agents`/`.system` repo drift — a device whose config-repo HEAD, branch,
22
+ * or dirty state diverges from the local baseline.
23
+ *
24
+ * Pure and SSH-free: the SSH fan-out lives in the doctor command; this module
25
+ * only consumes the already-collected payloads, so the divergence logic is
26
+ * unit-tested against fixture inventories with no live fleet.
27
+ */
28
+ export const FLEET_RESOURCE_KINDS = [
29
+ 'commands',
30
+ 'skills',
31
+ 'hooks',
32
+ 'rules',
33
+ 'mcp',
34
+ 'permissions',
35
+ 'subagents',
36
+ 'plugins',
37
+ 'promptcuts',
38
+ 'workflows',
39
+ ];
40
+ function sortedUnique(list) {
41
+ return Array.from(new Set(list)).sort();
42
+ }
43
+ function repoLabel(repo) {
44
+ return repo === 'agents' ? '.agents' : '.system';
45
+ }
46
+ /** Describe how a remote repo state diverges from the local baseline, or null
47
+ * when they match. Compares HEAD first (the load-bearing difference), then
48
+ * branch, then a dirty tree on either side (naming which side is dirty). */
49
+ function describeRepoDrift(local, remote) {
50
+ if (local.head && remote.head && local.head !== remote.head) {
51
+ return `HEAD ${remote.head} != local ${local.head}`;
52
+ }
53
+ if (local.branch !== remote.branch) {
54
+ return `branch ${remote.branch ?? 'detached'} != local ${local.branch ?? 'detached'}`;
55
+ }
56
+ // Flag a dirty tree on EITHER side (symmetric with HEAD/branch above), and name
57
+ // the side that has the uncommitted changes — the remote, or the local baseline.
58
+ if (remote.dirty !== local.dirty) {
59
+ return remote.dirty
60
+ ? 'remote tree has uncommitted changes'
61
+ : 'local tree has uncommitted changes';
62
+ }
63
+ return null;
64
+ }
65
+ /**
66
+ * Compare a set of per-device inventories against the local baseline and emit
67
+ * every cross-device divergence. The baseline is the device whose name equals
68
+ * {@link baselineName} (the local machine); if that device has no inventory the
69
+ * report is empty (nothing to compare against). Devices with no inventory are
70
+ * recorded under `skippedDevices` and never produce false "missing" findings.
71
+ *
72
+ * Ordering is deterministic (device, then category, then name) so the human and
73
+ * JSON output — and the tests — are stable.
74
+ */
75
+ export function compareFleetInventories(devices, baselineName) {
76
+ const baseline = devices.find((d) => d.name === baselineName)?.inventory ?? null;
77
+ const remotes = devices.filter((d) => d.name !== baselineName);
78
+ const comparedDevices = [];
79
+ const skippedDevices = [];
80
+ const divergences = [];
81
+ if (!baseline) {
82
+ // No local baseline to compare against — record every remote as skipped so
83
+ // the caller can say so, but emit no divergences (we can't know the truth).
84
+ for (const d of remotes)
85
+ skippedDevices.push(d.name);
86
+ return {
87
+ baseline: baselineName,
88
+ divergences: [],
89
+ comparedDevices: [],
90
+ skippedDevices: skippedDevices.sort(),
91
+ hasDivergence: false,
92
+ };
93
+ }
94
+ for (const remote of remotes) {
95
+ if (!remote.inventory) {
96
+ skippedDevices.push(remote.name);
97
+ continue;
98
+ }
99
+ comparedDevices.push(remote.name);
100
+ const inv = remote.inventory;
101
+ // 1) Resource presence, both directions.
102
+ for (const kind of FLEET_RESOURCE_KINDS) {
103
+ const localSet = new Set(baseline.resources[kind] ?? []);
104
+ const remoteSet = new Set(inv.resources[kind] ?? []);
105
+ for (const name of sortedUnique(baseline.resources[kind] ?? [])) {
106
+ if (!remoteSet.has(name)) {
107
+ divergences.push({
108
+ kind: 'resource-missing-remote',
109
+ device: remote.name,
110
+ category: kind,
111
+ name,
112
+ message: `${remote.name} is missing ${kind.replace(/s$/, '')} '${name}' (present on ${baselineName})`,
113
+ });
114
+ }
115
+ }
116
+ for (const name of sortedUnique(inv.resources[kind] ?? [])) {
117
+ if (!localSet.has(name)) {
118
+ divergences.push({
119
+ kind: 'resource-missing-local',
120
+ device: remote.name,
121
+ category: kind,
122
+ name,
123
+ message: `${baselineName} is missing ${kind.replace(/s$/, '')} '${name}' (present on ${remote.name})`,
124
+ });
125
+ }
126
+ }
127
+ }
128
+ // 2) Agent version parity, both directions, per agent id.
129
+ const agentIds = sortedUnique([
130
+ ...Object.keys(baseline.agentVersions),
131
+ ...Object.keys(inv.agentVersions),
132
+ ]);
133
+ for (const agent of agentIds) {
134
+ const localVers = new Set(baseline.agentVersions[agent] ?? []);
135
+ const remoteVers = new Set(inv.agentVersions[agent] ?? []);
136
+ for (const v of sortedUnique(baseline.agentVersions[agent] ?? [])) {
137
+ if (!remoteVers.has(v)) {
138
+ divergences.push({
139
+ kind: 'agent-version-missing-remote',
140
+ device: remote.name,
141
+ category: agent,
142
+ name: v,
143
+ message: `${remote.name} is missing ${agent}@${v} (installed on ${baselineName})`,
144
+ });
145
+ }
146
+ }
147
+ for (const v of sortedUnique(inv.agentVersions[agent] ?? [])) {
148
+ if (!localVers.has(v)) {
149
+ divergences.push({
150
+ kind: 'agent-version-missing-local',
151
+ device: remote.name,
152
+ category: agent,
153
+ name: v,
154
+ message: `${baselineName} is missing ${agent}@${v} (installed on ${remote.name})`,
155
+ });
156
+ }
157
+ }
158
+ }
159
+ // 3) `.agents` / `.system` repo drift vs the local baseline.
160
+ for (const repo of ['agents', 'system']) {
161
+ const localRepo = baseline.repos[repo];
162
+ const remoteRepo = inv.repos[repo];
163
+ if (!localRepo || !remoteRepo)
164
+ continue; // one side isn't a readable repo
165
+ const drift = describeRepoDrift(localRepo, remoteRepo);
166
+ if (drift) {
167
+ divergences.push({
168
+ kind: 'repo-drift',
169
+ device: remote.name,
170
+ category: repo,
171
+ name: repoLabel(repo),
172
+ message: `${remote.name} ${repoLabel(repo)} repo diverged: ${drift}`,
173
+ });
174
+ }
175
+ }
176
+ }
177
+ divergences.sort((a, b) => a.device.localeCompare(b.device) ||
178
+ a.kind.localeCompare(b.kind) ||
179
+ a.category.localeCompare(b.category) ||
180
+ a.name.localeCompare(b.name));
181
+ return {
182
+ baseline: baselineName,
183
+ divergences,
184
+ comparedDevices: comparedDevices.sort(),
185
+ skippedDevices: skippedDevices.sort(),
186
+ hasDivergence: divergences.length > 0,
187
+ };
188
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Build the self-reported harness inventory a device emits in `doctor --json`
3
+ * for cross-device divergence detection (RUSH-2027).
4
+ *
5
+ * Kept separate from {@link ../devices/fleet-divergence.js} — which stays a pure,
6
+ * SSH-free comparator — because this reads the live install (resource dirs,
7
+ * installed version homes, config repos). One call produces the {@link
8
+ * FleetInventory} that both the local baseline and every remote box serialize
9
+ * into their doctor payload; the comparator then diffs those payloads.
10
+ */
11
+ import { type FleetInventory } from './fleet-divergence.js';
12
+ /**
13
+ * Collect this machine's harness inventory: installed resources per kind,
14
+ * installed version ids per agent, and `.agents`/`.system` repo state. Pure
15
+ * reads — never mutates the install. `promptcuts` (a single present/absent bit
16
+ * in {@link getAvailableResources}) is surfaced as a one-element list so it
17
+ * compares like any other named resource.
18
+ */
19
+ export declare function collectLocalFleetInventory(cwd?: string): FleetInventory;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Build the self-reported harness inventory a device emits in `doctor --json`
3
+ * for cross-device divergence detection (RUSH-2027).
4
+ *
5
+ * Kept separate from {@link ../devices/fleet-divergence.js} — which stays a pure,
6
+ * SSH-free comparator — because this reads the live install (resource dirs,
7
+ * installed version homes, config repos). One call produces the {@link
8
+ * FleetInventory} that both the local baseline and every remote box serialize
9
+ * into their doctor payload; the comparator then diffs those payloads.
10
+ */
11
+ import { getAvailableResources, listInstalledVersions } from '../versions.js';
12
+ import { getUserAgentsDir, getSystemAgentsDir } from '../state.js';
13
+ import { readRepoState } from '../git.js';
14
+ import { ALL_AGENT_IDS } from '../agents.js';
15
+ import { FLEET_RESOURCE_KINDS } from './fleet-divergence.js';
16
+ function toRepoState(snap) {
17
+ if (!snap)
18
+ return null;
19
+ return { branch: snap.branch, head: snap.head, dirty: snap.dirty };
20
+ }
21
+ /**
22
+ * Collect this machine's harness inventory: installed resources per kind,
23
+ * installed version ids per agent, and `.agents`/`.system` repo state. Pure
24
+ * reads — never mutates the install. `promptcuts` (a single present/absent bit
25
+ * in {@link getAvailableResources}) is surfaced as a one-element list so it
26
+ * compares like any other named resource.
27
+ */
28
+ export function collectLocalFleetInventory(cwd = process.cwd()) {
29
+ const available = getAvailableResources(cwd);
30
+ const resources = {};
31
+ for (const kind of FLEET_RESOURCE_KINDS) {
32
+ if (kind === 'promptcuts') {
33
+ resources[kind] = available.promptcuts ? ['promptcuts.yaml'] : [];
34
+ }
35
+ else if (kind === 'rules') {
36
+ // getAvailableResources exposes top-level rules under `memory`.
37
+ resources[kind] = [...available.memory].sort();
38
+ }
39
+ else {
40
+ resources[kind] = [...(available[kind] ?? [])].sort();
41
+ }
42
+ }
43
+ const agentVersions = {};
44
+ for (const agent of ALL_AGENT_IDS) {
45
+ const versions = listInstalledVersions(agent);
46
+ if (versions.length > 0)
47
+ agentVersions[agent] = [...versions].sort();
48
+ }
49
+ return {
50
+ resources,
51
+ agentVersions,
52
+ repos: {
53
+ agents: toRepoState(readRepoState(getUserAgentsDir())),
54
+ system: toRepoState(readRepoState(getSystemAgentsDir())),
55
+ },
56
+ };
57
+ }
@@ -48,7 +48,7 @@ export interface FanOutDeviceResult<T> {
48
48
  export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
49
49
  /**
50
50
  * Remote fan-out targets for the fleet health/drift gates (`fleet status`,
51
- * `check --devices`): every planned device except this machine and control-only
51
+ * `doctor --check --devices`): every planned device except this machine and control-only
52
52
  * cockpits. A control device never runs agents (mirrors doctor's fan-out, which
53
53
  * drops it via `isControlDevice`), so counting it as unreachable/drift would make
54
54
  * the CI gate fail on every run for a fleet that merely has a cockpit registered.
@@ -44,7 +44,7 @@ export function planFleetTargets(reg) {
44
44
  }
45
45
  /**
46
46
  * Remote fan-out targets for the fleet health/drift gates (`fleet status`,
47
- * `check --devices`): every planned device except this machine and control-only
47
+ * `doctor --check --devices`): every planned device except this machine and control-only
48
48
  * cockpits. A control device never runs agents (mirrors doctor's fan-out, which
49
49
  * drops it via `isControlDevice`), so counting it as unreachable/drift would make
50
50
  * the CI gate fail on every run for a fleet that merely has a cockpit registered.
@@ -1,6 +1,7 @@
1
1
  import { type DeviceStats } from './health.js';
2
2
  import { type HostAuthSummary } from '../auth-health.js';
3
3
  import type { OnlineState } from './reachability.js';
4
+ import { type FleetInventory } from './fleet-divergence.js';
4
5
  export interface FleetCliStatus {
5
6
  installed: boolean;
6
7
  path: string | null;
@@ -40,9 +41,14 @@ export interface FleetHealthRow {
40
41
  * an offline row. Sourced from the registry's tailscale snapshot / reachability
41
42
  * verdict. Undefined when never recorded. */
42
43
  lastSeen?: string;
44
+ /** This host's self-reported harness inventory (resources / agent versions /
45
+ * repo state) from its `doctor --json` `fleet` field, for cross-device
46
+ * divergence detection (RUSH-2027). Undefined for an unreachable box or an
47
+ * older CLI that doesn't emit it. */
48
+ inventory?: FleetInventory;
43
49
  }
44
50
  export interface FleetWarning {
45
- kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
51
+ kind: 'unreachable' | 'drift' | 'cli' | 'version-skew' | 'divergence';
46
52
  devices: string[];
47
53
  message: string;
48
54
  }
@@ -53,7 +59,9 @@ export interface FleetHealthReport {
53
59
  hasWarnings: boolean;
54
60
  hasDrift: boolean;
55
61
  }
56
- export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date): FleetHealthReport;
62
+ export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date, opts?: {
63
+ self?: string;
64
+ }): FleetHealthReport;
57
65
  export declare function renderFleetWarnings(report: FleetHealthReport): string[];
58
66
  export declare function renderFleetMatrix(report: FleetHealthReport): string[];
59
67
  /**
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { padToWidth, stringWidth, terminalWidth, truncateToWidth } from '../session/width.js';
3
3
  import { fmtBytes, headroom } from './health.js';
4
4
  import { formatCheckedAge } from '../auth-health.js';
5
+ import { compareFleetInventories } from './fleet-divergence.js';
5
6
  function driftRows(row) {
6
7
  return row.sync.filter((s) => s.status !== 'fresh');
7
8
  }
@@ -12,7 +13,7 @@ function installedCliCount(row) {
12
13
  total: statuses.length,
13
14
  };
14
15
  }
15
- export function buildFleetHealthReport(rows, now = new Date()) {
16
+ export function buildFleetHealthReport(rows, now = new Date(), opts = {}) {
16
17
  const warnings = [];
17
18
  const unreachable = rows
18
19
  .filter((r) => r.error || r.skipped)
@@ -60,6 +61,25 @@ export function buildFleetHealthReport(rows, now = new Date()) {
60
61
  message: `agents-cli version skew: ${Array.from(versions.keys()).sort().join(', ')}`,
61
62
  });
62
63
  }
64
+ // Cross-device harness divergence (RUSH-2027): compare every device's
65
+ // self-reported inventory against the local baseline and roll the findings up
66
+ // into one warning per affected device. Only runs when a baseline (`self`) is
67
+ // named and at least one device carries an inventory — an older-CLI fleet with
68
+ // no `fleet` field simply produces no divergence warning.
69
+ if (opts.self && rows.some((r) => r.inventory)) {
70
+ const divergence = compareFleetInventories(rows.map((r) => ({ name: r.name, inventory: r.inventory ?? null })), opts.self);
71
+ const byDevice = new Map();
72
+ for (const d of divergence.divergences)
73
+ byDevice.set(d.device, (byDevice.get(d.device) ?? 0) + 1);
74
+ for (const device of Array.from(byDevice.keys()).sort()) {
75
+ const n = byDevice.get(device);
76
+ warnings.push({
77
+ kind: 'divergence',
78
+ devices: [device],
79
+ message: `${device} diverges from ${opts.self} (${n} resource/version/repo gap${n === 1 ? '' : 's'})`,
80
+ });
81
+ }
82
+ }
63
83
  return {
64
84
  generatedAt: now.toISOString(),
65
85
  devices: rows,
@@ -326,6 +346,17 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
326
346
  .join(' · ');
327
347
  items.push({ glyph: 'warn', subject: 'version skew', detail: summary, fix: 'agents upgrade --fleet' });
328
348
  }
349
+ // 4) Cross-device harness divergence — one item per diverged box (RUSH-2027).
350
+ for (const w of report.warnings) {
351
+ if (w.kind !== 'divergence')
352
+ continue;
353
+ items.push({
354
+ glyph: 'warn',
355
+ subject: w.devices[0] ?? 'fleet',
356
+ detail: w.message.replace(`${w.devices[0]} `, ''),
357
+ fix: `agents apply ${w.devices[0] ?? ''}`.trim(),
358
+ });
359
+ }
329
360
  return items;
330
361
  }
331
362
  /** Right-aligned `<content>` on the same line as `left`, clamped so it never
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import type { AgentId } from './types.js';
23
23
  import type { DiscoveredPlugin } from './types.js';
24
+ import { type HookWiringReport } from './hooks.js';
24
25
  export type DoctorKind = 'commands' | 'skills' | 'hooks' | 'rules' | 'mcp' | 'permissions' | 'subagents' | 'plugins' | 'promptcuts';
25
26
  export type DiffStatus = 'ok' | 'diff' | 'missing' | 'extra';
26
27
  export type SourceLayer = 'project' | 'user' | 'system' | 'extra';
@@ -37,6 +38,19 @@ export interface ResourceDiff {
37
38
  * "0.6.1→0.7.0, missing skills: ship, learn". Currently set for plugins. */
38
39
  detail?: string;
39
40
  }
41
+ /** A source layer (user / system / an extra repo) that is behind its upstream —
42
+ * reconciled against stale truth. Attached to the report by the doctor command
43
+ * (needs a git probe, kept out of the pure per-version diff). */
44
+ export interface SourceLayerBehind {
45
+ layer: 'user' | 'system' | 'extra';
46
+ /** Human label for the layer's on-disk root (e.g. `~/.agents`). */
47
+ label: string;
48
+ /** `agents repo pull` alias for the remediation hint. */
49
+ alias: string;
50
+ behind: number;
51
+ /** Upstream ref the layer trails (e.g. `origin/main`). */
52
+ branch: string;
53
+ }
40
54
  export interface VersionResourceReport {
41
55
  agent: AgentId;
42
56
  version: string;
@@ -58,6 +72,12 @@ export interface VersionResourceReport {
58
72
  missing: number;
59
73
  extra: number;
60
74
  };
75
+ /** Wiring inspection for the version's native settings.json (claude/droid).
76
+ * Populated only when the `hooks` kind is in scope. */
77
+ hookWiring?: HookWiringReport;
78
+ /** Source layers behind their upstream. Filled by the doctor command (a git
79
+ * probe), not the pure diff — undefined when uncomputed. */
80
+ sourceBehind?: SourceLayerBehind[];
61
81
  }
62
82
  /**
63
83
  * Describe how a version's marketplace MIRROR of a plugin diverges from its
@@ -33,7 +33,7 @@ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } fr
33
33
  import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
34
34
  import { supports } from './capabilities.js';
35
35
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
36
- import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
36
+ import { listHooksInVersionHome, listHookEntriesFromDir, checkVersionHookWiring } from './hooks.js';
37
37
  const RULES_DOC_FILENAME = 'README.md';
38
38
  const ALL_KINDS = [
39
39
  'commands',
@@ -611,6 +611,10 @@ export function diffVersionResources(agent, version, options = {}) {
611
611
  empty.skills = diffSkills(agent, version, cwd, excludeProject);
612
612
  if (requested.has('hooks'))
613
613
  empty.hooks = diffHooks(agent, version, cwd);
614
+ // Wiring check: a hook FILE can be present and byte-identical to source (ok
615
+ // above) yet never referenced in settings.json, so it never fires. Only
616
+ // meaningful when hooks are in scope.
617
+ const hookWiring = requested.has('hooks') ? checkVersionHookWiring(agent, version) : undefined;
614
618
  if (requested.has('rules'))
615
619
  empty.rules = diffRules(agent, version, cwd, excludeProject);
616
620
  if (requested.has('mcp'))
@@ -649,6 +653,7 @@ export function diffVersionResources(agent, version, options = {}) {
649
653
  },
650
654
  kinds: empty,
651
655
  summary: { ok, diff, missing, extra },
656
+ ...(hookWiring ? { hookWiring } : {}),
652
657
  };
653
658
  }
654
659
  export const DOCTOR_ALL_KINDS = ALL_KINDS;
@@ -1,21 +1,27 @@
1
1
  /**
2
2
  * Shared drift-detection internals for `agents doctor` (overview mode) and
3
- * `agents check` (the scriptable, CI-friendly gate).
3
+ * `agents doctor --check` (the scriptable, CI-friendly gate).
4
4
  *
5
5
  * This is the single source of truth for "is the install out of sync?": the
6
6
  * per-version sync status (fresh / stale / never-synced) and the orphan census.
7
- * `agents doctor` renders it as a human report; `agents check` reduces it to an
8
- * exit code. Neither reimplements the diagnostic — both call `computeDrift`.
7
+ * `agents doctor` renders it as a human report; `agents doctor --check` reduces it
8
+ * to an exit code. Neither reimplements the diagnostic — both call `computeDrift`.
9
9
  */
10
10
  import type { AgentId } from './types.js';
11
- import { type VersionResourceReport } from './doctor-diff.js';
11
+ import { type VersionResourceReport, type SourceLayerBehind } from './doctor-diff.js';
12
12
  export interface SyncStatusRow {
13
13
  agent: AgentId;
14
14
  version: string;
15
15
  status: 'fresh' | 'stale' | 'never-synced';
16
16
  isDefault: boolean;
17
- /** For stale rows: prioritized lines naming exactly what diverged (plugins first). */
17
+ /** For stale rows: prioritized lines naming exactly what diverged (plugins first).
18
+ * Also carries hook-wiring divergence for a version whose hooks are present but
19
+ * not wired into settings.json — independent of manifest staleness. */
18
20
  divergence?: string[];
21
+ /** Count of hooks present on disk but NOT wired into the version's settings.json
22
+ * (claude/droid). A non-zero value makes the version out-of-sync even when the
23
+ * manifest reads fresh — the yosemite-s1 blind spot the CI gate must catch. */
24
+ unwiredHooks?: number;
19
25
  }
20
26
  export interface OrphanRow {
21
27
  agent: AgentId;
@@ -27,6 +33,14 @@ export interface OrphanRow {
27
33
  export declare function divergenceLines(report: VersionResourceReport): string[];
28
34
  export declare function checkSyncStatus(cwd: string): SyncStatusRow[];
29
35
  export declare function countOrphans(): OrphanRow[];
36
+ /**
37
+ * Probe each source layer (user, system, enabled extras) for how far it trails
38
+ * its upstream, from the LAST-FETCHED remote-tracking ref (no network). A layer
39
+ * behind origin means every version home is reconciled against stale truth — a
40
+ * drift signal `agents doctor --check` must fail on, not a buried preamble. Returns
41
+ * only the behind layers. Canonical home for both `agents doctor` and `agents doctor --check`.
42
+ */
43
+ export declare function computeSourceBehind(): SourceLayerBehind[];
30
44
  export interface DriftSummary {
31
45
  syncRows: SyncStatusRow[];
32
46
  orphanRows: OrphanRow[];
@@ -36,17 +50,23 @@ export interface DriftSummary {
36
50
  neverSyncedCount: number;
37
51
  /** Versions carrying orphan resources (informational — not a drift signal). */
38
52
  orphanVersionCount: number;
53
+ /** Versions with hooks present on disk but not wired into settings.json. */
54
+ unwiredHookVersions: number;
55
+ /** Source layers behind their upstream (reconciled against stale truth). */
56
+ sourceBehind: SourceLayerBehind[];
39
57
  /**
40
- * True when any installed version is stale or never-synced the exact signal
41
- * `agents doctor` surfaces as "run `agents status` to review what has drifted".
42
- * Orphans are a `prune` concern, not sync drift, so they do NOT set this flag
43
- * (mirrors the sync-status engine: an orphan alone never flags needsSync).
58
+ * True when the install is out of sync: any installed version is stale,
59
+ * never-synced, or carries unwired hooks, OR a source layer is behind origin.
60
+ * `agents doctor` surfaces it as "run `agents status`"; `agents doctor --check`
61
+ * maps it to a non-zero exit. Orphans are a `prune` concern, not sync drift, so they do
62
+ * NOT set this flag (mirrors the sync-status engine: an orphan alone never
63
+ * flags needsSync).
44
64
  */
45
65
  hasDrift: boolean;
46
66
  }
47
67
  /**
48
68
  * Compute the same drift/divergence diagnostic `agents doctor` prints, reduced
49
- * to a summary with a single `hasDrift` boolean. The gate `agents check` maps
50
- * to an exit code; the readout `agents doctor` renders in full.
69
+ * to a summary with a single `hasDrift` boolean. The gate `agents doctor --check`
70
+ * maps to an exit code; the readout `agents doctor` renders in full.
51
71
  */
52
72
  export declare function computeDrift(cwd: string): DriftSummary;
package/dist/lib/drift.js CHANGED
@@ -4,7 +4,9 @@ import { loadManifest, isStale } from './staleness/index.js';
4
4
  import { diffVersionResources } from './doctor-diff.js';
5
5
  import { diffVersionCommands, iterCommandsCapableVersions } from './commands.js';
6
6
  import { diffVersionSkills, iterSkillsCapableVersions } from './skills.js';
7
- import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome } from './hooks.js';
7
+ import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome, checkVersionHookWiring } from './hooks.js';
8
+ import { commitsBehindUpstream } from './git.js';
9
+ import { getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos } from './state.js';
8
10
  // Lines naming exactly what's out of sync for a version, plugins prioritized:
9
11
  // each divergent plugin gets its own line with specifics (stale mirror version,
10
12
  // invalid manifest, or the bundled skills/commands missing from the mirror —
@@ -46,14 +48,37 @@ export function checkSyncStatus(cwd) {
46
48
  ? 'never-synced'
47
49
  : isStale(manifest, agent, version, cwd) ? 'stale' : 'fresh';
48
50
  const row = { agent, version, status, isDefault: version === def };
51
+ const divergence = [];
49
52
  if (status === 'stale') {
50
53
  // Resolve the specifics against non-project layers (the global home is
51
54
  // never reconciled against per-cwd project resources).
52
55
  const report = diffVersionResources(agent, version, { cwd, excludeProject: true });
53
- const lines = divergenceLines(report);
54
- if (lines.length)
55
- row.divergence = lines;
56
+ divergence.push(...divergenceLines(report));
56
57
  }
58
+ // Hook WIRING is independent of manifest staleness: a hook file can be
59
+ // byte-identical to source (fresh) yet absent from settings.json, so it
60
+ // never fires. Surface that for every version, fresh or stale, so overview
61
+ // AND `agents doctor --check` flag it (claude/droid; other agents report unsupported).
62
+ const wiring = checkVersionHookWiring(agent, version);
63
+ if (wiring.supported) {
64
+ const expected = wiring.expected ?? 0;
65
+ if (wiring.settingsMissing && expected > 0) {
66
+ row.unwiredHooks = expected;
67
+ divergence.push(`hooks settings.json missing — ${expected} declared hook(s) never fire`);
68
+ }
69
+ else if (wiring.settingsUnparseable) {
70
+ row.unwiredHooks = Math.max(1, expected);
71
+ divergence.push(`hooks settings.json unparseable — wiring cannot be verified`);
72
+ }
73
+ else if (wiring.unwired.length > 0) {
74
+ row.unwiredHooks = wiring.unwired.length;
75
+ const names = wiring.unwired.map((u) => u.name);
76
+ const shown = names.slice(0, 3).join(', ');
77
+ divergence.push(`hooks ${wiring.unwired.length} unwired (${shown}${names.length > 3 ? ', …' : ''})`);
78
+ }
79
+ }
80
+ if (divergence.length)
81
+ row.divergence = divergence;
57
82
  rows.push(row);
58
83
  }
59
84
  }
@@ -91,22 +116,48 @@ export function countOrphans() {
91
116
  }
92
117
  return Array.from(byKey.values()).filter((r) => r.commands + r.skills + r.hooks > 0);
93
118
  }
119
+ /**
120
+ * Probe each source layer (user, system, enabled extras) for how far it trails
121
+ * its upstream, from the LAST-FETCHED remote-tracking ref (no network). A layer
122
+ * behind origin means every version home is reconciled against stale truth — a
123
+ * drift signal `agents doctor --check` must fail on, not a buried preamble. Returns
124
+ * only the behind layers. Canonical home for both `agents doctor` and `agents doctor --check`.
125
+ */
126
+ export function computeSourceBehind() {
127
+ const out = [];
128
+ const probe = (layer, dir, label, alias) => {
129
+ const r = commitsBehindUpstream(dir);
130
+ if (r && r.behind > 0)
131
+ out.push({ layer, label, alias, behind: r.behind, branch: r.branch });
132
+ };
133
+ probe('user', getUserAgentsDir(), '~/.agents', 'user');
134
+ probe('system', getSystemAgentsDir(), '~/.agents/.system', 'system');
135
+ for (const e of getEnabledExtraRepos())
136
+ probe('extra', e.dir, e.alias, e.alias);
137
+ return out;
138
+ }
94
139
  /**
95
140
  * Compute the same drift/divergence diagnostic `agents doctor` prints, reduced
96
- * to a summary with a single `hasDrift` boolean. The gate `agents check` maps
97
- * to an exit code; the readout `agents doctor` renders in full.
141
+ * to a summary with a single `hasDrift` boolean. The gate `agents doctor --check`
142
+ * maps to an exit code; the readout `agents doctor` renders in full.
98
143
  */
99
144
  export function computeDrift(cwd) {
100
145
  const syncRows = checkSyncStatus(cwd);
101
146
  const orphanRows = countOrphans();
102
147
  const staleCount = syncRows.filter((r) => r.status === 'stale').length;
103
148
  const neverSyncedCount = syncRows.filter((r) => r.status === 'never-synced').length;
149
+ const unwiredHookVersions = syncRows.filter((r) => (r.unwiredHooks ?? 0) > 0).length;
150
+ const sourceBehind = computeSourceBehind();
104
151
  return {
105
152
  syncRows,
106
153
  orphanRows,
107
154
  staleCount,
108
155
  neverSyncedCount,
109
156
  orphanVersionCount: orphanRows.length,
110
- hasDrift: syncRows.some((r) => r.status !== 'fresh'),
157
+ unwiredHookVersions,
158
+ sourceBehind,
159
+ hasDrift: syncRows.some((r) => r.status !== 'fresh') ||
160
+ unwiredHookVersions > 0 ||
161
+ sourceBehind.length > 0,
111
162
  };
112
163
  }