@phnx-labs/agents-cli 1.22.60 → 1.22.61

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 (85) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli/command-registry.d.ts +1 -0
  3. package/dist/cli/command-registry.js +2 -0
  4. package/dist/commands/browser.js +9 -4
  5. package/dist/commands/doctor.js +1 -1
  6. package/dist/commands/exec.js +35 -1
  7. package/dist/commands/harness-hooks.d.ts +55 -0
  8. package/dist/commands/harness-hooks.js +104 -0
  9. package/dist/commands/harness-wizard.d.ts +33 -14
  10. package/dist/commands/harness-wizard.js +53 -23
  11. package/dist/commands/harness.d.ts +14 -0
  12. package/dist/commands/harness.js +86 -5
  13. package/dist/commands/reminders.d.ts +9 -0
  14. package/dist/commands/reminders.js +49 -0
  15. package/dist/commands/run-account-picker.d.ts +14 -0
  16. package/dist/commands/run-account-picker.js +13 -0
  17. package/dist/commands/teams.d.ts +1 -1
  18. package/dist/commands/teams.js +9 -3
  19. package/dist/index.js +9 -0
  20. package/dist/lib/accounting/rotate.d.ts +63 -0
  21. package/dist/lib/accounting/rotate.js +229 -13
  22. package/dist/lib/browser/drivers/local.d.ts +11 -0
  23. package/dist/lib/browser/drivers/local.js +26 -0
  24. package/dist/lib/browser/profiles.js +8 -6
  25. package/dist/lib/browser/service.d.ts +12 -8
  26. package/dist/lib/browser/service.js +38 -10
  27. package/dist/lib/claude-statusline.d.ts +14 -1
  28. package/dist/lib/claude-statusline.js +27 -2
  29. package/dist/lib/daemon/runner.js +17 -2
  30. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  31. package/dist/lib/devices/doctor-findings.js +22 -4
  32. package/dist/lib/doctor-diff.d.ts +21 -5
  33. package/dist/lib/doctor-diff.js +242 -76
  34. package/dist/lib/feed/events.d.ts +1 -1
  35. package/dist/lib/feed/events.js +25 -16
  36. package/dist/lib/github/gh-overload.d.ts +58 -0
  37. package/dist/lib/github/gh-overload.js +246 -0
  38. package/dist/lib/github/rest.d.ts +64 -0
  39. package/dist/lib/github/rest.js +111 -0
  40. package/dist/lib/harness-connection-test.d.ts +57 -0
  41. package/dist/lib/harness-connection-test.js +80 -0
  42. package/dist/lib/heal.js +8 -3
  43. package/dist/lib/installations/shims.d.ts +22 -0
  44. package/dist/lib/installations/shims.js +104 -0
  45. package/dist/lib/linear-project-counts.js +8 -0
  46. package/dist/lib/linear-rate-limit.d.ts +26 -0
  47. package/dist/lib/linear-rate-limit.js +163 -0
  48. package/dist/lib/mcp.d.ts +9 -0
  49. package/dist/lib/mcp.js +37 -1
  50. package/dist/lib/open-url.js +5 -3
  51. package/dist/lib/permissions.d.ts +28 -0
  52. package/dist/lib/permissions.js +156 -1
  53. package/dist/lib/refresh.js +9 -1
  54. package/dist/lib/reminders.d.ts +29 -0
  55. package/dist/lib/reminders.js +88 -0
  56. package/dist/lib/resource-content-diff.d.ts +33 -0
  57. package/dist/lib/resource-content-diff.js +103 -0
  58. package/dist/lib/rules/compile.d.ts +7 -0
  59. package/dist/lib/rules/compile.js +7 -1
  60. package/dist/lib/session/active.d.ts +41 -4
  61. package/dist/lib/session/active.js +58 -7
  62. package/dist/lib/session/host-link.d.ts +22 -0
  63. package/dist/lib/session/host-link.js +40 -4
  64. package/dist/lib/session/trajectory.d.ts +42 -0
  65. package/dist/lib/session/trajectory.js +46 -27
  66. package/dist/lib/ssh-exec.d.ts +30 -0
  67. package/dist/lib/ssh-exec.js +37 -5
  68. package/dist/lib/startup/command-registry.js +1 -1
  69. package/dist/lib/subagents-registry.d.ts +18 -0
  70. package/dist/lib/subagents-registry.js +79 -0
  71. package/dist/lib/teams/agents.d.ts +12 -0
  72. package/dist/lib/teams/agents.js +51 -0
  73. package/dist/lib/traces/schema2-build.d.ts +85 -0
  74. package/dist/lib/traces/schema2-build.js +637 -0
  75. package/dist/lib/traces/schema2-danger.d.ts +36 -0
  76. package/dist/lib/traces/schema2-danger.js +185 -0
  77. package/dist/lib/traces/schema2.d.ts +149 -0
  78. package/dist/lib/traces/schema2.js +20 -0
  79. package/dist/lib/traces/sync.d.ts +93 -0
  80. package/dist/lib/traces/sync.js +75 -22
  81. package/dist/lib/traces/worker-template.js +5 -0
  82. package/dist/lib/uninstall.js +10 -1
  83. package/dist/lib/workflows.d.ts +11 -0
  84. package/dist/lib/workflows.js +67 -8
  85. package/package.json +1 -1
@@ -36,7 +36,7 @@ import { ensureLockTarget } from '../fs-atomic.js';
36
36
  import { walkForFiles } from '../fs-walk.js';
37
37
  import { getBinaryPath, isVersionInstalled, resolveVersion, getVersionHomePath } from '../installations/versions.js';
38
38
  import { resolveClaudeSetupToken } from '../claude-account-token.js';
39
- import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from '../accounting/rotate.js';
39
+ import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, formatNoVerifiedUsageError, } from '../accounting/rotate.js';
40
40
  import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../device-config.js';
41
41
  import { isSelfUpdatingAgent, ROUTINE_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
42
42
  import { isCustomHarnessName, readProfile } from '../profiles.js';
@@ -959,12 +959,21 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {
959
959
  let version;
960
960
  let rotation = null;
961
961
  let exhausted;
962
+ let noVerifiedUsage = false;
962
963
  try {
963
964
  const resolved = await (deps.resolveRunVersion ?? resolveRunVersion)(agent, strategy, cwd);
964
965
  version = resolved.version ?? undefined;
965
966
  rotation = resolved.rotation;
966
967
  exhausted = resolved.exhausted;
967
- if (rotation) {
968
+ noVerifiedUsage = resolved.noVerifiedUsage ?? false;
969
+ if (noVerifiedUsage) {
970
+ // Entirely stale usage (PHNX-2526): a routine is unattended, so there is
971
+ // no picker to divert to — it fails loud below rather than launch on a
972
+ // stale number. Do NOT log `rotation.picked` as a pick; it is the refused
973
+ // stale candidate, kept only for the failover chain.
974
+ process.stderr.write(`[agents] routine ${config.name}: ${strategy} found no ${agent} account with fresh usage — refusing to route on stale data\n`);
975
+ }
976
+ else if (rotation) {
968
977
  const label = rotation.picked.email
969
978
  ? `${rotation.picked.email} · ${agent}@${rotation.picked.version}`
970
979
  : `${agent}@${rotation.picked.version}`;
@@ -996,6 +1005,12 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {
996
1005
  if (exhausted) {
997
1006
  throw new Error(formatNoHealthyAccountError(agent, strategy, exhausted));
998
1007
  }
1008
+ // Entirely stale usage is also NOT a "fall back to the default pin" case — the
1009
+ // default is exactly the account whose stale number can't be trusted. Fail the
1010
+ // run loud (NO_VERIFIED_USAGE) rather than hammer it every tick (PHNX-2526).
1011
+ if (noVerifiedUsage) {
1012
+ throw new Error(formatNoVerifiedUsageError(agent, strategy, rotation?.healthy ?? []));
1013
+ }
999
1014
  if (!version) {
1000
1015
  version = resolveVersion(agent, cwd) ?? undefined;
1001
1016
  }
@@ -6,7 +6,7 @@ import type { OwnerSinkStatus } from '../channels/owner-sink.js';
6
6
  import { type WindowsSshEnrollmentAudit } from './windows-ssh-enrollment.js';
7
7
  import type { SyncStatusRow, OrphanRow } from '../drift.js';
8
8
  import type { FetchStatusMarker } from '../auto-pull.js';
9
- import type { VersionResourceReport } from '../doctor-diff.js';
9
+ import { type VersionResourceReport } from '../doctor-diff.js';
10
10
  import type { FleetDivergence, FleetHookRuntimeState, FleetVersionSignIn } from './fleet-divergence.js';
11
11
  export type FindingSeverity = 'critical' | 'warning';
12
12
  /** A machine-stable class for a finding — drives {@link remediationFor} and lets
@@ -44,6 +44,24 @@ import { loginHint } from '../signin-badge.js';
44
44
  import { CONFIG_ENV_ISOLATED_AGENTS } from '../installations/shims.js';
45
45
  import { padToWidth, stringWidth } from '../text/width.js';
46
46
  import { windowsSshEnrollmentProblem } from './windows-ssh-enrollment.js';
47
+ import { DOCTOR_ALL_KINDS } from '../doctor-diff.js';
48
+ /**
49
+ * Singular noun per resource kind for finding messages. A naive `kind.replace(/s$/,'')`
50
+ * mangles `memory` → `memor` and `mcp` → `mcp` inconsistently, so the mapping is
51
+ * explicit. Keyed by `DoctorKind` so a new kind fails the type check until named.
52
+ */
53
+ const KIND_SINGULAR = {
54
+ commands: 'command',
55
+ skills: 'skill',
56
+ hooks: 'hook',
57
+ rules: 'rule',
58
+ mcp: 'mcp',
59
+ permissions: 'permission',
60
+ subagents: 'subagent',
61
+ plugins: 'plugin',
62
+ workflows: 'workflow',
63
+ memory: 'memory',
64
+ };
47
65
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
48
66
  /** Agents with NO per-version credential isolation: their login is shared across
49
67
  * every installed version, so a "log into THIS version" remediation would be a
@@ -87,8 +105,8 @@ export const ALL_FINDING_KINDS = [
87
105
  'hook-runtime-broken', // a wired hook's generated shim wrapper is missing/unusable
88
106
  'hook-runtime-visibility-unavailable', // remote CLI cannot report generated wrapper health
89
107
  'cli-missing', // a managed agent whose binary won't resolve
90
- 'missing-resource', // a missing command/skill/rule/mcp/permission/subagent
91
- 'content-drift', // a resource diverged from source
108
+ 'missing-resource', // a missing command/skill/rule/mcp/permission/subagent/workflow/memory
109
+ 'content-drift', // a resource (any synced kind) diverged from source
92
110
  'never-synced', // installed but never synced
93
111
  'stale', // sources changed since last sync
94
112
  'repo-behind', // a config repo behind origin
@@ -392,8 +410,8 @@ export function buildLocalFindings(input) {
392
410
  const missingPlugins = [];
393
411
  const missingOther = [];
394
412
  const drifted = [];
395
- for (const kind of ['commands', 'skills', 'hooks', 'rules', 'mcp', 'permissions', 'subagents', 'plugins', 'promptcuts']) {
396
- const singular = kind.replace(/s$/, '');
413
+ for (const kind of DOCTOR_ALL_KINDS) {
414
+ const singular = KIND_SINGULAR[kind];
397
415
  for (const r of report.kinds[kind] ?? []) {
398
416
  if (r.status === 'missing') {
399
417
  if (kind === 'hooks')
@@ -9,21 +9,37 @@
9
9
  * - missing — resolved source exists, not present in home
10
10
  * - extra — present in home, no source in any layer
11
11
  *
12
- * Coverage:
12
+ * Coverage — every kind `syncResourcesToVersion` writes is content-aware, so a
13
+ * byte change under an unchanged name is `diff`, never a false `ok` (PHNX-3504):
13
14
  * commands, skills, hooks, rules — full content compare with source layer.
14
- * mcp, permissions, subagents, plugins, promptcuts — presence-only.
15
+ * mcp — structural compare of the home server def vs resolved source.
16
+ * subagents — re-render source through the registry transform, byte-compare.
17
+ * workflows — layout-aware per-harness content compare (dir tree or file).
18
+ * memory — knowledge facts (~/.agents/memory/*.md), per-fact byte-compare.
19
+ * plugins — per-item content compare of the marketplace mirror.
20
+ * permissions — per-rule compare in the harness's native vocabulary for the
21
+ * representable harnesses (claude/opencode/cursor/droid/openclaw); the lossy
22
+ * TOML/flag harnesses stay presence-only with an honest `detail`, never a
23
+ * faked `ok`.
24
+ * A completeness test binds `DOCTOR_ALL_KINDS` to the writer set so a future
25
+ * synced kind cannot silently become a blind spot. `promptcuts` is NOT a kind —
26
+ * it is not version-scoped, so there is nothing per-home to diff.
15
27
  *
16
28
  * Intentional asymmetries (must mirror sync):
17
29
  * - hooks ignore the project layer (`syncResourcesToVersion` skips
18
30
  * project/.agents/hooks/ for safety).
19
- * - rules/AGENTS.md on agents without native @-import support is compared
20
- * against the compiled artifact, not the raw source file.
31
+ * - rules/AGENTS.md is compared against the preset composition the rules writer
32
+ * emits (`composeRulesFromState`), re-rendered from the current `subrules/`
33
+ * fragments — so a fragment edit is caught even though `rules/AGENTS.md`
34
+ * never changed. A non-@-import home compiled by `agents refresh-rules`
35
+ * carries a leading `COMPILED_HEADER`, which is stripped before comparing so
36
+ * a header-compiled home still reconciles (the header is not source content).
21
37
  */
22
38
  import type { AgentId } from './types.js';
23
39
  import type { DiscoveredPlugin } from './types.js';
24
40
  import { type HookWiringReport } from './hooks/install.js';
25
41
  import { type ResourceInventory } from './resource-inventory.js';
26
- export type DoctorKind = 'commands' | 'skills' | 'hooks' | 'rules' | 'mcp' | 'permissions' | 'subagents' | 'plugins' | 'promptcuts';
42
+ export type DoctorKind = 'commands' | 'skills' | 'hooks' | 'rules' | 'mcp' | 'permissions' | 'subagents' | 'plugins' | 'workflows' | 'memory';
27
43
  export type DiffStatus = 'ok' | 'diff' | 'missing' | 'extra';
28
44
  export type SourceLayer = 'project' | 'user' | 'system' | 'extra';
29
45
  export interface ResourceDiff {
@@ -9,21 +9,44 @@
9
9
  * - missing — resolved source exists, not present in home
10
10
  * - extra — present in home, no source in any layer
11
11
  *
12
- * Coverage:
12
+ * Coverage — every kind `syncResourcesToVersion` writes is content-aware, so a
13
+ * byte change under an unchanged name is `diff`, never a false `ok` (PHNX-3504):
13
14
  * commands, skills, hooks, rules — full content compare with source layer.
14
- * mcp, permissions, subagents, plugins, promptcuts — presence-only.
15
+ * mcp — structural compare of the home server def vs resolved source.
16
+ * subagents — re-render source through the registry transform, byte-compare.
17
+ * workflows — layout-aware per-harness content compare (dir tree or file).
18
+ * memory — knowledge facts (~/.agents/memory/*.md), per-fact byte-compare.
19
+ * plugins — per-item content compare of the marketplace mirror.
20
+ * permissions — per-rule compare in the harness's native vocabulary for the
21
+ * representable harnesses (claude/opencode/cursor/droid/openclaw); the lossy
22
+ * TOML/flag harnesses stay presence-only with an honest `detail`, never a
23
+ * faked `ok`.
24
+ * A completeness test binds `DOCTOR_ALL_KINDS` to the writer set so a future
25
+ * synced kind cannot silently become a blind spot. `promptcuts` is NOT a kind —
26
+ * it is not version-scoped, so there is nothing per-home to diff.
15
27
  *
16
28
  * Intentional asymmetries (must mirror sync):
17
29
  * - hooks ignore the project layer (`syncResourcesToVersion` skips
18
30
  * project/.agents/hooks/ for safety).
19
- * - rules/AGENTS.md on agents without native @-import support is compared
20
- * against the compiled artifact, not the raw source file.
31
+ * - rules/AGENTS.md is compared against the preset composition the rules writer
32
+ * emits (`composeRulesFromState`), re-rendered from the current `subrules/`
33
+ * fragments — so a fragment edit is caught even though `rules/AGENTS.md`
34
+ * never changed. A non-@-import home compiled by `agents refresh-rules`
35
+ * carries a leading `COMPILED_HEADER`, which is stripped before comparing so
36
+ * a header-compiled home still reconciles (the header is not source content).
21
37
  */
22
38
  import * as fs from 'fs';
23
39
  import * as path from 'path';
24
- import { AGENTS, agentConfigDirName } from './agents.js';
25
- import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, getActiveRulesPreset, } from './state.js';
40
+ import { AGENTS, agentConfigDirName, getMcpConfigPathForHome } from './agents.js';
41
+ import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getActiveRulesPreset, } from './state.js';
26
42
  import { composeRulesFromState } from './rules/compose.js';
43
+ import { COMPILED_HEADER, supportsRulesImports } from './rules/compile.js';
44
+ import { dirsContentMatch, filesContentMatch } from './resource-content-diff.js';
45
+ import { subagentContentMatches } from './subagents-registry.js';
46
+ import { getMcpServersByName, mcpServerMatches } from './mcp.js';
47
+ import { permissionsGroupMatches, PERMISSIONS_REPRESENTABLE } from './permissions.js';
48
+ import { workflowContentMatches, resolveWorkflowRef } from './workflows.js';
49
+ import { listMemoryFacts, memoryTargetDir } from './memory.js';
27
50
  import { getAvailableResources, getActuallySyncedResources, getVersionHomePath, compareVersions, } from './installations/versions.js';
28
51
  import { discoverPlugins, marketplaceSpecForName } from './plugins/plugins.js';
29
52
  import { pluginInstallDir, repairableManifestFields } from './plugins/plugin-marketplace.js';
@@ -47,7 +70,8 @@ const ALL_KINDS = [
47
70
  'permissions',
48
71
  'subagents',
49
72
  'plugins',
50
- 'promptcuts',
73
+ 'workflows',
74
+ 'memory',
51
75
  ];
52
76
  function normalize(content) {
53
77
  return content.replace(/\r\n/g, '\n').trim();
@@ -121,6 +145,36 @@ function buildLayerBases(cwd, kind, opts = {}) {
121
145
  out.push({ layer: 'extra', path: path.join(e.dir, kind), alias: e.alias });
122
146
  return out;
123
147
  }
148
+ /**
149
+ * Resolve directory-shaped source resources of `kind` by name across the layers
150
+ * (project > user > system > extra; first layer wins a name collision), keeping
151
+ * only entries whose directory satisfies `predicate` (e.g. holds `AGENT.md`).
152
+ */
153
+ function resolveSourceDirsByName(kind, cwd, excludeProject, predicate) {
154
+ const out = new Map();
155
+ for (const base of buildLayerBases(cwd, kind, { excludeProject })) {
156
+ if (!fs.existsSync(base.path))
157
+ continue;
158
+ let entries;
159
+ try {
160
+ entries = fs.readdirSync(base.path, { withFileTypes: true });
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ for (const entry of entries) {
166
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
167
+ continue;
168
+ if (out.has(entry.name))
169
+ continue;
170
+ const dir = path.join(base.path, entry.name);
171
+ if (!predicate(dir))
172
+ continue;
173
+ out.set(entry.name, { layer: base.layer, path: dir, alias: base.alias });
174
+ }
175
+ }
176
+ return out;
177
+ }
124
178
  // ─── commands ─────────────────────────────────────────────────────────────────
125
179
  function diffCommands(agent, version, cwd, excludeProject = false) {
126
180
  const agentConfig = AGENTS[agent];
@@ -265,55 +319,6 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
265
319
  return rows.sort((a, b) => a.name.localeCompare(b.name));
266
320
  }
267
321
  // ─── skills ───────────────────────────────────────────────────────────────────
268
- function dirsContentMatch(src, dst) {
269
- const ignore = new Set(['.DS_Store', '.git', '.gitignore', '.venv', '__pycache__', 'node_modules']);
270
- const srcEntries = (() => {
271
- try {
272
- return fs.readdirSync(src, { withFileTypes: true });
273
- }
274
- catch {
275
- return null;
276
- }
277
- })();
278
- const dstEntries = (() => {
279
- try {
280
- return fs.readdirSync(dst, { withFileTypes: true });
281
- }
282
- catch {
283
- return null;
284
- }
285
- })();
286
- if (!srcEntries || !dstEntries)
287
- return false;
288
- const filter = (es) => es.filter((e) => !e.isSymbolicLink() && !ignore.has(e.name)).sort((a, b) => a.name.localeCompare(b.name));
289
- const srcF = filter(srcEntries);
290
- const dstF = filter(dstEntries);
291
- if (srcF.length !== dstF.length)
292
- return false;
293
- for (let i = 0; i < srcF.length; i++) {
294
- if (srcF[i].name !== dstF[i].name)
295
- return false;
296
- const a = path.join(src, srcF[i].name);
297
- const b = path.join(dst, dstF[i].name);
298
- if (srcF[i].isDirectory()) {
299
- if (!dstF[i].isDirectory())
300
- return false;
301
- if (!dirsContentMatch(a, b))
302
- return false;
303
- }
304
- else if (srcF[i].isFile()) {
305
- if (!dstF[i].isFile())
306
- return false;
307
- const ac = readSafe(a);
308
- const bc = readSafe(b);
309
- if (ac == null || bc == null)
310
- return false;
311
- if (normalize(ac) !== normalize(bc))
312
- return false;
313
- }
314
- }
315
- return true;
316
- }
317
322
  function diffSkills(agent, version, cwd, excludeProject = false) {
318
323
  // Native ~/.agents/skills consumers (Gemini, …) read central skills directly.
319
324
  // The orchestrator DELETES their version-home skills dir (syncResourcesToVersion)
@@ -523,11 +528,18 @@ function diffRules(agent, version, cwd, excludeProject = false) {
523
528
  continue;
524
529
  }
525
530
  const expected = expectedRuleContent(agent, name, version, src.path);
526
- const actual = readSafe(homePath);
531
+ let actual = readSafe(homePath);
527
532
  if (expected == null || actual == null) {
528
533
  rows.push({ kind: 'rules', name, status: 'diff', source: src.layer, sourcePath: src.path, homePath });
529
534
  continue;
530
535
  }
536
+ // A non-@-import home compiled by `agents refresh-rules` carries a leading
537
+ // COMPILED_HEADER the raw preset composition does not. Strip it before the
538
+ // compare so a header-compiled home still reconciles — the header is
539
+ // agents-cli provenance, not source content (PHNX-3504).
540
+ if (name === 'AGENTS' && !supportsRulesImports(agent) && actual.startsWith(COMPILED_HEADER)) {
541
+ actual = actual.slice(COMPILED_HEADER.length);
542
+ }
531
543
  rows.push({
532
544
  kind: 'rules',
533
545
  name,
@@ -555,22 +567,175 @@ function diffRules(agent, version, cwd, excludeProject = false) {
555
567
  }
556
568
  return rows.sort((a, b) => a.name.localeCompare(b.name));
557
569
  }
558
- // ─── presence-only kinds ──────────────────────────────────────────────────────
559
- function diffPresenceOnly(kind, available, synced) {
570
+ // ─── mcp (content-aware) ────────────────────────────────────────────────────
571
+ function diffMcp(agent, version, cwd, available, synced) {
572
+ const versionHome = getVersionHomePath(agent, version);
573
+ const syncedSet = new Set(synced);
560
574
  const availableSet = new Set(available);
575
+ const homePath = getMcpConfigPathForHome(agent, versionHome);
576
+ // Resolve each available server's source def once (project trust off — this is
577
+ // a read-only diff, and the writer applies the same servers).
578
+ const sourceByName = new Map(getMcpServersByName(available, { cwd, enforceProjectTrust: false }).map((s) => [s.name, s]));
579
+ const rows = [];
580
+ for (const name of available) {
581
+ if (!syncedSet.has(name)) {
582
+ rows.push({ kind: 'mcp', name, status: 'missing', sourcePath: sourceByName.get(name)?.path });
583
+ continue;
584
+ }
585
+ const source = sourceByName.get(name);
586
+ const matches = source ? mcpServerMatches(agent, versionHome, name, source.config) : false;
587
+ rows.push({
588
+ kind: 'mcp',
589
+ name,
590
+ status: matches ? 'ok' : 'diff',
591
+ sourcePath: source?.path,
592
+ homePath,
593
+ });
594
+ }
595
+ for (const name of synced) {
596
+ if (!availableSet.has(name))
597
+ rows.push({ kind: 'mcp', name, status: 'extra', homePath });
598
+ }
599
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
600
+ }
601
+ // ─── permissions (content-aware where the native format allows) ──────────────
602
+ function diffPermissions(agent, version, available, synced) {
603
+ const versionHome = getVersionHomePath(agent, version);
561
604
  const syncedSet = new Set(synced);
605
+ const availableSet = new Set(available);
606
+ const representable = PERMISSIONS_REPRESENTABLE.has(agent);
562
607
  const rows = [];
563
608
  for (const name of available) {
609
+ if (!syncedSet.has(name)) {
610
+ rows.push({ kind: 'permissions', name, status: 'missing' });
611
+ continue;
612
+ }
613
+ if (representable) {
614
+ const matches = permissionsGroupMatches(agent, versionHome, name);
615
+ rows.push({ kind: 'permissions', name, status: matches ? 'ok' : 'diff' });
616
+ }
617
+ else {
618
+ // Lossy TOML/flag harness — the on-disk format carries no faithful
619
+ // per-group rule provenance, so we can only attest presence. Say so
620
+ // explicitly rather than fake a verified `ok` (PHNX-3504).
621
+ rows.push({ kind: 'permissions', name, status: 'ok', detail: 'format cannot verify content' });
622
+ }
623
+ }
624
+ for (const name of synced) {
625
+ if (!availableSet.has(name))
626
+ rows.push({ kind: 'permissions', name, status: 'extra' });
627
+ }
628
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
629
+ }
630
+ // ─── subagents (content-aware) ───────────────────────────────────────────────
631
+ function diffSubagents(agent, version, cwd, available, synced) {
632
+ const versionHome = getVersionHomePath(agent, version);
633
+ const syncedSet = new Set(synced);
634
+ const availableSet = new Set(available);
635
+ // Resolve sources across the SAME layers `available` was built from
636
+ // (getAvailableResources is project-inclusive), so every available name finds
637
+ // its source dir for the content compare rather than false-diffing.
638
+ const sourceByName = resolveSourceDirsByName('subagents', cwd, false, (dir) => fs.existsSync(path.join(dir, 'AGENT.md')));
639
+ const rows = [];
640
+ for (const name of available) {
641
+ const src = sourceByName.get(name);
642
+ if (!syncedSet.has(name)) {
643
+ rows.push({ kind: 'subagents', name, status: 'missing', source: src?.layer, sourcePath: src?.path });
644
+ continue;
645
+ }
646
+ const matches = src ? subagentContentMatches(agent, versionHome, name, src.path) : false;
564
647
  rows.push({
565
- kind,
648
+ kind: 'subagents',
566
649
  name,
567
- status: syncedSet.has(name) ? 'ok' : 'missing',
650
+ status: matches ? 'ok' : 'diff',
651
+ source: src?.layer,
652
+ sourcePath: src?.path,
568
653
  });
569
654
  }
570
655
  for (const name of synced) {
571
- if (availableSet.has(name))
656
+ if (!availableSet.has(name))
657
+ rows.push({ kind: 'subagents', name, status: 'extra' });
658
+ }
659
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
660
+ }
661
+ // ─── workflows (content-aware) ───────────────────────────────────────────────
662
+ function diffWorkflows(agent, version, cwd, available, synced) {
663
+ const versionHome = getVersionHomePath(agent, version);
664
+ const syncedSet = new Set(synced);
665
+ const availableSet = new Set(available);
666
+ const rows = [];
667
+ for (const name of available) {
668
+ const sourcePath = resolveWorkflowRef(name, cwd);
669
+ if (!syncedSet.has(name)) {
670
+ rows.push({ kind: 'workflows', name, status: 'missing', sourcePath: sourcePath ?? undefined });
572
671
  continue;
573
- rows.push({ kind, name, status: 'extra' });
672
+ }
673
+ const matches = sourcePath ? workflowContentMatches(agent, versionHome, name, sourcePath) : false;
674
+ rows.push({
675
+ kind: 'workflows',
676
+ name,
677
+ status: matches ? 'ok' : 'diff',
678
+ sourcePath: sourcePath ?? undefined,
679
+ });
680
+ }
681
+ for (const name of synced) {
682
+ if (!availableSet.has(name))
683
+ rows.push({ kind: 'workflows', name, status: 'extra' });
684
+ }
685
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
686
+ }
687
+ // ─── memory (knowledge facts — content-aware) ────────────────────────────────
688
+ /**
689
+ * Diff synced knowledge-fact memory (~/.agents/memory/*.md fanned into each
690
+ * capable home under `memoryTargetDir`). Distinct from the rules-preset list
691
+ * that overloads `AvailableResources.memory` — this reads the canonical facts
692
+ * (`listMemoryFacts`) and the home copies bounded by the `.agents-cli-memory.json`
693
+ * manifest, so an untracked native memory file the user authored is never
694
+ * mistaken for drift (PHNX-3504).
695
+ */
696
+ function diffMemory(agent, version, cwd) {
697
+ if (!supports(agent, 'memory', version).ok)
698
+ return [];
699
+ const versionHome = getVersionHomePath(agent, version);
700
+ const targetDir = path.join(versionHome, memoryTargetDir(agent));
701
+ const facts = listMemoryFacts(cwd);
702
+ // The manifest names exactly the fact files agents-cli wrote (plus 'MEMORY').
703
+ const managedManifestPath = path.join(targetDir, '.agents-cli-memory.json');
704
+ let managed = [];
705
+ try {
706
+ const raw = JSON.parse(fs.readFileSync(managedManifestPath, 'utf-8'));
707
+ if (Array.isArray(raw.facts))
708
+ managed = raw.facts.filter((f) => typeof f === 'string');
709
+ }
710
+ catch { /* no manifest → nothing managed yet */ }
711
+ const managedSet = new Set(managed);
712
+ const rows = [];
713
+ const factNames = new Set(facts.map((f) => f.name));
714
+ for (const fact of facts) {
715
+ const homePath = path.join(targetDir, `${fact.name}.md`);
716
+ if (!fs.existsSync(homePath)) {
717
+ rows.push({ kind: 'memory', name: fact.name, status: 'missing', source: fact.layer, sourcePath: fact.path });
718
+ continue;
719
+ }
720
+ const matches = filesContentMatch(fact.path, homePath);
721
+ rows.push({
722
+ kind: 'memory',
723
+ name: fact.name,
724
+ status: matches ? 'ok' : 'diff',
725
+ source: fact.layer,
726
+ sourcePath: fact.path,
727
+ homePath,
728
+ });
729
+ }
730
+ // A managed fact still on disk but no longer in the canonical set is an orphan
731
+ // the next sync would prune — surface it as extra. Never flag 'MEMORY' (the
732
+ // index, always regenerated) or an unmanaged native file (not ours to reconcile).
733
+ for (const name of managedSet) {
734
+ if (name === 'MEMORY' || factNames.has(name))
735
+ continue;
736
+ const homePath = path.join(targetDir, `${name}.md`);
737
+ if (fs.existsSync(homePath))
738
+ rows.push({ kind: 'memory', name, status: 'extra', homePath });
574
739
  }
575
740
  return rows.sort((a, b) => a.name.localeCompare(b.name));
576
741
  }
@@ -683,12 +848,6 @@ function diffPlugins(agent, version, cwd) {
683
848
  }
684
849
  return rows.sort((a, b) => a.name.localeCompare(b.name));
685
850
  }
686
- function diffPromptcuts() {
687
- const sourcePath = getEffectivePromptcutsPath();
688
- if (!fs.existsSync(sourcePath))
689
- return [];
690
- return [{ kind: 'promptcuts', name: 'promptcuts.yaml', status: 'ok', sourcePath }];
691
- }
692
851
  export function diffVersionResources(agent, version, options = {}) {
693
852
  const rawCwd = options.cwd ?? process.cwd();
694
853
  const excludeProject = options.excludeProject ?? false;
@@ -709,7 +868,8 @@ export function diffVersionResources(agent, version, options = {}) {
709
868
  permissions: [],
710
869
  subagents: [],
711
870
  plugins: [],
712
- promptcuts: [],
871
+ workflows: [],
872
+ memory: [],
713
873
  };
714
874
  if (requested.has('commands'))
715
875
  empty.commands = diffCommands(agent, version, cwd, excludeProject);
@@ -725,20 +885,26 @@ export function diffVersionResources(agent, version, options = {}) {
725
885
  if (requested.has('rules'))
726
886
  empty.rules = diffRules(agent, version, cwd, excludeProject);
727
887
  if (requested.has('mcp'))
728
- empty.mcp = diffPresenceOnly('mcp', available.mcp, synced.mcp);
729
- if (requested.has('permissions'))
730
- empty.permissions = diffPresenceOnly('permissions', available.permissions, synced.permissions);
888
+ empty.mcp = diffMcp(agent, version, cwd, available.mcp, synced.mcp);
889
+ if (requested.has('permissions')) {
890
+ empty.permissions = diffPermissions(agent, version, supports(agent, 'allowlist', version).ok ? available.permissions : [], synced.permissions);
891
+ }
731
892
  // Subagents are version-gated (e.g. kimi >= 0.29.0). A version below the floor
732
893
  // is never written any subagent by the sync writer, so counting the source
733
894
  // ones as "missing" is phantom drift no sync can clear (PHNX-3186). Zero the
734
895
  // available set when unsupported; a stale installed copy still surfaces `extra`.
735
896
  if (requested.has('subagents')) {
736
- empty.subagents = diffPresenceOnly('subagents', supports(agent, 'subagents', version).ok ? available.subagents : [], synced.subagents);
897
+ empty.subagents = diffSubagents(agent, version, cwd, supports(agent, 'subagents', version).ok ? available.subagents : [], synced.subagents);
737
898
  }
738
899
  if (requested.has('plugins'))
739
900
  empty.plugins = diffPlugins(agent, version, cwd);
740
- if (requested.has('promptcuts'))
741
- empty.promptcuts = diffPromptcuts();
901
+ // Workflows are version-gated too (e.g. grok >= 0.2.111); zero the available
902
+ // set below the floor so a source workflow is not phantom-missing drift.
903
+ if (requested.has('workflows')) {
904
+ empty.workflows = diffWorkflows(agent, version, cwd, supports(agent, 'workflows', version).ok ? available.workflows : [], synced.workflows);
905
+ }
906
+ if (requested.has('memory'))
907
+ empty.memory = diffMemory(agent, version, cwd);
742
908
  let ok = 0, diff = 0, missing = 0, extra = 0;
743
909
  for (const list of Object.values(empty)) {
744
910
  for (const r of list) {
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { ActorKind } from '../actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'rotation.unresolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
18
18
  export declare const EVENT_TYPES: readonly EventType[];
19
19
  /** Runtime guard for an event kind arriving from outside this process. */
@@ -19,25 +19,33 @@ import { gzipSync, gunzipSync } from 'node:zlib';
19
19
  import { ensureLockTarget, withFileLock } from '../fs-atomic.js';
20
20
  import { getUserAgentsDir } from '../state.js';
21
21
  import { stampProvenance, resetEventProvenanceForTest } from '../event-provenance.js';
22
- /** Lazy perf warehouse write — avoids a hard cycle at module load. */
22
+ import { recordSample } from '../perf/spool.js';
23
+ /**
24
+ * Perf warehouse write. SYNCHRONOUS by design (PHNX-3497): `recordSample` is an
25
+ * `appendFileSync` that never opens SQLite (see perf/spool.ts), so calling it
26
+ * directly costs one file append and lands the row before this function returns.
27
+ * It MUST NOT be deferred behind a `void import().then(...)`: the `agent.run`
28
+ * timer ends at the very tail of `agents run`, and the CLI process exits before
29
+ * any deferred microtask runs — so a fire-and-forget spool write was silently
30
+ * lost on every foreground run, leaving the PHNX-3468 startup-phase surface with
31
+ * no data. (Hooks were unaffected only because their shim appends the spool line
32
+ * synchronously in bash.)
33
+ */
23
34
  function recordPerfTiming(payload) {
24
35
  try {
25
36
  const phases = payload.phases && Object.keys(payload.phases).length > 0 ? payload.phases : undefined;
26
37
  const metaJson = phases ? JSON.stringify({ phases }) : undefined;
27
- // Dynamic import keeps events.ts free of a load-time dependency on perf/db.
28
- void import('../perf/spool.js').then(({ recordSample }) => {
29
- recordSample({
30
- kind: 'perf.timing',
31
- label: payload.label,
32
- durationMs: payload.durationMs,
33
- status: payload.status,
34
- agent: payload.agent,
35
- agentVersion: payload.version,
36
- sessionId: payload.sessionId,
37
- cwd: payload.cwd,
38
- metaJson,
39
- });
40
- }).catch(() => { });
38
+ recordSample({
39
+ kind: 'perf.timing',
40
+ label: payload.label,
41
+ durationMs: payload.durationMs,
42
+ status: payload.status,
43
+ agent: payload.agent,
44
+ agentVersion: payload.version,
45
+ sessionId: payload.sessionId,
46
+ cwd: payload.cwd,
47
+ metaJson,
48
+ });
41
49
  }
42
50
  catch {
43
51
  // fail soft
@@ -127,6 +135,7 @@ const EVENT_TYPE_TABLE = {
127
135
  'mcp.add': true, 'mcp.remove': true, 'mcp.register': true,
128
136
  'resource.sync': true,
129
137
  'rotation.resolved': true,
138
+ 'rotation.unresolved': true,
130
139
  'command.start': true, 'command.end': true,
131
140
  'perf.timing': true,
132
141
  'session.start': true, 'session.end': true,
@@ -156,7 +165,7 @@ const AUDIT_EVENTS = new Set([
156
165
  'version.install', 'version.switch', 'version.remove',
157
166
  'skill.install', 'skill.remove',
158
167
  'mcp.add', 'mcp.remove', 'mcp.register',
159
- 'rotation.resolved',
168
+ 'rotation.resolved', 'rotation.unresolved',
160
169
  'session.start', 'session.end',
161
170
  // Daemon lifecycle (always-on process start/stop/error is audit-relevant).
162
171
  // browser.* / computer.action stay info — they are already on the event stream;