@phnx-labs/agents-cli 1.18.1 → 1.18.3

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 (56) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/commands/doctor.js +19 -5
  3. package/dist/commands/exec.js +9 -4
  4. package/dist/commands/plugins.js +58 -14
  5. package/dist/commands/view.js +16 -7
  6. package/dist/index.js +30 -0
  7. package/dist/lib/hooks.js +21 -3
  8. package/dist/lib/migrate.js +35 -12
  9. package/dist/lib/plugin-marketplace.d.ts +93 -0
  10. package/dist/lib/plugin-marketplace.js +239 -0
  11. package/dist/lib/plugins.d.ts +25 -13
  12. package/dist/lib/plugins.js +350 -566
  13. package/dist/lib/shims.d.ts +3 -1
  14. package/dist/lib/shims.js +81 -7
  15. package/dist/lib/staleness/checkers/commands.d.ts +7 -0
  16. package/dist/lib/staleness/checkers/commands.js +27 -0
  17. package/dist/lib/staleness/checkers/hooks.d.ts +13 -0
  18. package/dist/lib/staleness/checkers/hooks.js +63 -0
  19. package/dist/lib/staleness/checkers/mcp.d.ts +12 -0
  20. package/dist/lib/staleness/checkers/mcp.js +38 -0
  21. package/dist/lib/staleness/checkers/permissions.d.ts +17 -0
  22. package/dist/lib/staleness/checkers/permissions.js +73 -0
  23. package/dist/lib/staleness/checkers/plugins.d.ts +11 -0
  24. package/dist/lib/staleness/checkers/plugins.js +39 -0
  25. package/dist/lib/staleness/checkers/rules.d.ts +19 -0
  26. package/dist/lib/staleness/checkers/rules.js +86 -0
  27. package/dist/lib/staleness/checkers/skills.d.ts +7 -0
  28. package/dist/lib/staleness/checkers/skills.js +34 -0
  29. package/dist/lib/staleness/checkers/subagents.d.ts +12 -0
  30. package/dist/lib/staleness/checkers/subagents.js +39 -0
  31. package/dist/lib/staleness/checkers/types.d.ts +44 -0
  32. package/dist/lib/staleness/checkers/types.js +20 -0
  33. package/dist/lib/staleness/checkers/workflows.d.ts +10 -0
  34. package/dist/lib/staleness/checkers/workflows.js +37 -0
  35. package/dist/lib/staleness/fingerprint.d.ts +38 -0
  36. package/dist/lib/staleness/fingerprint.js +154 -0
  37. package/dist/lib/staleness/index.d.ts +26 -0
  38. package/dist/lib/staleness/index.js +122 -0
  39. package/dist/lib/staleness/layers.d.ts +37 -0
  40. package/dist/lib/staleness/layers.js +100 -0
  41. package/dist/lib/staleness/types.d.ts +56 -0
  42. package/dist/lib/staleness/types.js +6 -0
  43. package/dist/lib/state.d.ts +2 -0
  44. package/dist/lib/state.js +2 -0
  45. package/dist/lib/teams/agents.d.ts +11 -20
  46. package/dist/lib/teams/agents.js +55 -202
  47. package/dist/lib/teams/index.d.ts +3 -2
  48. package/dist/lib/teams/index.js +2 -2
  49. package/dist/lib/teams/persistence.d.ts +0 -38
  50. package/dist/lib/teams/persistence.js +7 -329
  51. package/dist/lib/teams/registry.js +7 -5
  52. package/dist/lib/types.d.ts +6 -0
  53. package/dist/lib/versions.js +34 -12
  54. package/package.json +1 -1
  55. package/dist/lib/sync-manifest.d.ts +0 -81
  56. package/dist/lib/sync-manifest.js +0 -450
@@ -50,8 +50,10 @@ export interface ConflictInfo {
50
50
  * (two-repo split: system = shipped defaults, user = operational state).
51
51
  * v9 — claude shim exports CLAUDE_CODE_OAUTH_TOKEN from per-version
52
52
  * .oauth_token file on Linux (keychain-less sandbox fallback).
53
+ * v11 — when no default is set or the configured version is not installed,
54
+ * interactively propose the latest already-installed version.
53
55
  */
54
- export declare const SHIM_SCHEMA_VERSION = 10;
56
+ export declare const SHIM_SCHEMA_VERSION = 11;
55
57
  /**
56
58
  * Generate the full bash shim script for the given agent. The returned string
57
59
  * is written to ~/.agents/shims/{cliCommand} and made executable.
package/dist/lib/shims.js CHANGED
@@ -173,8 +173,10 @@ async function promptConflictStrategy(conflictInfos) {
173
173
  * (two-repo split: system = shipped defaults, user = operational state).
174
174
  * v9 — claude shim exports CLAUDE_CODE_OAUTH_TOKEN from per-version
175
175
  * .oauth_token file on Linux (keychain-less sandbox fallback).
176
+ * v11 — when no default is set or the configured version is not installed,
177
+ * interactively propose the latest already-installed version.
176
178
  */
177
- export const SHIM_SCHEMA_VERSION = 10;
179
+ export const SHIM_SCHEMA_VERSION = 11;
178
180
  /** Internal marker string used to embed the schema version in shim scripts. */
179
181
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
180
182
  /**
@@ -281,6 +283,31 @@ find_project_agents_dir() {
281
283
  return 1
282
284
  }
283
285
 
286
+ # Find the latest installed version by numeric component comparison.
287
+ # Handles both semver (2.1.138) and date-based (2026.5.7) version strings.
288
+ find_latest_installed() {
289
+ local versions_dir="$AGENTS_USER_DIR/.history/versions/$AGENT"
290
+ [ -d "$versions_dir" ] || return
291
+ ls "$versions_dir" 2>/dev/null | awk '
292
+ BEGIN { best="" }
293
+ {
294
+ cur = $0
295
+ n = split(cur, a, /[^0-9]+/)
296
+ m = split(best, b, /[^0-9]+/)
297
+ maxn = (n > m) ? n : m
298
+ winner = cur
299
+ for (i=1; i<=maxn; i++) {
300
+ ai = (i<=n) ? a[i]+0 : 0
301
+ bi = (i<=m) ? b[i]+0 : 0
302
+ if (ai > bi) { winner=cur; break }
303
+ if (ai < bi) { winner=best; break }
304
+ }
305
+ best = winner
306
+ }
307
+ END { print best }
308
+ '
309
+ }
310
+
284
311
  # Try project version first, then global default
285
312
  VERSION=$(find_project_version)
286
313
  VERSION_SOURCE="project"
@@ -290,9 +317,32 @@ if [ -z "$VERSION" ]; then
290
317
  fi
291
318
 
292
319
  if [ -z "$VERSION" ]; then
293
- echo "agents: no version of $AGENT configured" >&2
294
- echo "Run: agents add $AGENT@<version>" >&2
295
- exit 1
320
+ LATEST=$(find_latest_installed)
321
+ if [ -n "$LATEST" ]; then
322
+ echo "agents: no default set for $AGENT — found $AGENT@$LATEST installed" >&2
323
+ if [ -t 2 ]; then
324
+ printf " Set as default and continue? [Y/n] " >&2
325
+ read -r _ans </dev/tty
326
+ case "$_ans" in
327
+ ""|y|Y)
328
+ agents use "$AGENT" "$LATEST" >/dev/null 2>&1
329
+ VERSION="$LATEST"
330
+ VERSION_SOURCE="default"
331
+ ;;
332
+ *)
333
+ echo " Run: agents use $AGENT <version>" >&2
334
+ exit 1
335
+ ;;
336
+ esac
337
+ else
338
+ echo " Run: agents use $AGENT <version>" >&2
339
+ exit 1
340
+ fi
341
+ else
342
+ echo "agents: no version of $AGENT configured" >&2
343
+ echo " Run: agents add $AGENT@<version>" >&2
344
+ exit 1
345
+ fi
296
346
  fi
297
347
 
298
348
  VERSION_DIR="$AGENTS_USER_DIR/.history/versions/$AGENT/$VERSION"
@@ -329,9 +379,33 @@ if [ ! -x "$BINARY" ]; then
329
379
  exit 1
330
380
  fi
331
381
  else
332
- echo "agents: $AGENT@$VERSION not installed" >&2
333
- echo "Run: agents add $AGENT@$VERSION" >&2
334
- exit 1
382
+ LATEST=$(find_latest_installed)
383
+ if [ -n "$LATEST" ] && [ "$LATEST" != "$VERSION" ]; then
384
+ echo "agents: $AGENT@$VERSION not installed — found $AGENT@$LATEST installed" >&2
385
+ if [ -t 2 ]; then
386
+ printf " Switch default to $AGENT@$LATEST and continue? [Y/n] " >&2
387
+ read -r _ans </dev/tty
388
+ case "$_ans" in
389
+ ""|y|Y)
390
+ agents use "$AGENT" "$LATEST" >/dev/null 2>&1
391
+ VERSION="$LATEST"
392
+ VERSION_DIR="$AGENTS_USER_DIR/.history/versions/$AGENT/$VERSION"
393
+ BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
394
+ ;;
395
+ *)
396
+ echo " Run: agents add $AGENT@$VERSION" >&2
397
+ exit 1
398
+ ;;
399
+ esac
400
+ else
401
+ echo " Run: agents add $AGENT@$VERSION" >&2
402
+ exit 1
403
+ fi
404
+ else
405
+ echo "agents: $AGENT@$VERSION not installed" >&2
406
+ echo " Run: agents add $AGENT@$VERSION" >&2
407
+ exit 1
408
+ fi
335
409
  fi
336
410
  fi
337
411
 
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Commands staleness — one `.md` file per command, first-wins across
3
+ * project > user > system > extras.
4
+ */
5
+ import type { FileEntry } from '../types.js';
6
+ import type { TypedResourceChecker } from './types.js';
7
+ export declare const commandsChecker: TypedResourceChecker<FileEntry>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Commands staleness — one `.md` file per command, first-wins across
3
+ * project > user > system > extras.
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { firstWinsLayers, listAcrossLayers, resolveByName } from '../layers.js';
8
+ import { fingerprintFile, isFileStale } from '../fingerprint.js';
9
+ export const commandsChecker = {
10
+ type: 'commands',
11
+ listNames(cwd) {
12
+ return listAcrossLayers(firstWinsLayers(cwd), 'commands', (name) => name.endsWith('.md')).map((n) => n.replace(/\.md$/, ''));
13
+ },
14
+ build(name, cwd) {
15
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('commands', `${name}.md`), (p) => fs.existsSync(p));
16
+ if (!resolved)
17
+ return null;
18
+ const fp = fingerprintFile(resolved.path);
19
+ return fp ? { source: fp } : null;
20
+ },
21
+ isFresh(name, stored, cwd) {
22
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('commands', `${name}.md`), (p) => fs.existsSync(p));
23
+ if (!resolved)
24
+ return false;
25
+ return !isFileStale(stored.source, resolved.path);
26
+ },
27
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Hooks staleness — one executable file per hook. Project layer is EXCLUDED
3
+ * by design: a cloned public repo with `.agents/hooks/foo` must not plant a
4
+ * hook that fires next time the user runs an agent inside it (see
5
+ * `src/lib/versions.ts:1832-1836`). Only user + system + extras count.
6
+ *
7
+ * Auxiliary files (README.md, promptcuts.yaml) live in hooks/ but are not
8
+ * hooks — the executable bit on the source distinguishes them. This matches
9
+ * the filter in `getAvailableResources`.
10
+ */
11
+ import type { FileEntry } from '../types.js';
12
+ import type { TypedResourceChecker } from './types.js';
13
+ export declare const hooksChecker: TypedResourceChecker<FileEntry>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Hooks staleness — one executable file per hook. Project layer is EXCLUDED
3
+ * by design: a cloned public repo with `.agents/hooks/foo` must not plant a
4
+ * hook that fires next time the user runs an agent inside it (see
5
+ * `src/lib/versions.ts:1832-1836`). Only user + system + extras count.
6
+ *
7
+ * Auxiliary files (README.md, promptcuts.yaml) live in hooks/ but are not
8
+ * hooks — the executable bit on the source distinguishes them. This matches
9
+ * the filter in `getAvailableResources`.
10
+ */
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { hookLayers, listAcrossLayers, resolveByName } from '../layers.js';
14
+ import { fingerprintFile, isFileStale } from '../fingerprint.js';
15
+ /** Extensions that are NEVER hooks — docs, configuration, plain data. */
16
+ const NON_SCRIPT_EXTENSIONS = new Set([
17
+ '.md', '.markdown', '.rst', '.txt',
18
+ '.yaml', '.yml', '.json', '.toml', '.ini', '.conf',
19
+ ]);
20
+ /** Extensions that explicitly mark a file as a script regardless of exec bit. */
21
+ const SCRIPT_EXTENSIONS = new Set([
22
+ '.sh', '.bash', '.zsh',
23
+ '.py', '.js', '.ts', '.mjs', '.cjs',
24
+ '.rb', '.pl', '.ps1',
25
+ ]);
26
+ function isHookScript(full) {
27
+ try {
28
+ const stat = fs.statSync(full);
29
+ if (!stat.isFile())
30
+ return false;
31
+ const ext = path.extname(full).toLowerCase();
32
+ if (SCRIPT_EXTENSIONS.has(ext))
33
+ return true;
34
+ // Otherwise require exec bit AND a non-data extension. Older sync runs
35
+ // chmod 0o755'd everything including `promptcuts.yaml` / `README.md`,
36
+ // so exec bit alone can't be trusted.
37
+ if ((stat.mode & 0o111) === 0)
38
+ return false;
39
+ return !NON_SCRIPT_EXTENSIONS.has(ext);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ export const hooksChecker = {
46
+ type: 'hooks',
47
+ listNames(_cwd) {
48
+ return listAcrossLayers(hookLayers(), 'hooks', (_, full) => isHookScript(full));
49
+ },
50
+ build(name, _cwd) {
51
+ const resolved = resolveByName(hookLayers(), path.join('hooks', name), isHookScript);
52
+ if (!resolved)
53
+ return null;
54
+ const fp = fingerprintFile(resolved.path);
55
+ return fp ? { source: fp } : null;
56
+ },
57
+ isFresh(name, stored, _cwd) {
58
+ const resolved = resolveByName(hookLayers(), path.join('hooks', name), isHookScript);
59
+ if (!resolved)
60
+ return false;
61
+ return !isFileStale(stored.source, resolved.path);
62
+ },
63
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * MCP server staleness — one `.yaml`/`.yml` file per server, first-wins
3
+ * across project > user > system > extras. Name is the `name:` field inside
4
+ * the YAML, NOT the filename (per `getAvailableResources`).
5
+ *
6
+ * We delegate name/path discovery to `listMcpServerConfigs(cwd)` which
7
+ * already handles parsing — keeps a single source of truth for "what counts
8
+ * as a discoverable MCP server."
9
+ */
10
+ import type { FileEntry } from '../types.js';
11
+ import type { TypedResourceChecker } from './types.js';
12
+ export declare const mcpChecker: TypedResourceChecker<FileEntry>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * MCP server staleness — one `.yaml`/`.yml` file per server, first-wins
3
+ * across project > user > system > extras. Name is the `name:` field inside
4
+ * the YAML, NOT the filename (per `getAvailableResources`).
5
+ *
6
+ * We delegate name/path discovery to `listMcpServerConfigs(cwd)` which
7
+ * already handles parsing — keeps a single source of truth for "what counts
8
+ * as a discoverable MCP server."
9
+ */
10
+ import { fingerprintFile, isFileStale } from '../fingerprint.js';
11
+ import { listMcpServerConfigs } from '../../mcp.js';
12
+ function indexByName(cwd) {
13
+ const map = new Map();
14
+ for (const cfg of listMcpServerConfigs(cwd)) {
15
+ if (!map.has(cfg.name))
16
+ map.set(cfg.name, cfg.path);
17
+ }
18
+ return map;
19
+ }
20
+ export const mcpChecker = {
21
+ type: 'mcp',
22
+ listNames(cwd) {
23
+ return Array.from(indexByName(cwd).keys());
24
+ },
25
+ build(name, cwd) {
26
+ const src = indexByName(cwd).get(name);
27
+ if (!src)
28
+ return null;
29
+ const fp = fingerprintFile(src);
30
+ return fp ? { source: fp } : null;
31
+ },
32
+ isFresh(name, stored, cwd) {
33
+ const src = indexByName(cwd).get(name);
34
+ if (!src)
35
+ return false;
36
+ return !isFileStale(stored.source, src);
37
+ },
38
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Permissions staleness — every `groups/*.yaml` across user + system
3
+ * contributes to the merged permission set (project layer not consulted by
4
+ * the current sync writer). First-wins on name collision (user > system).
5
+ *
6
+ * The active preset env value (`AGENTS_PERMISSION_PRESET`) is part of the
7
+ * fingerprint too — preset selection changes which groups get applied to
8
+ * the agent config, so a preset switch without a content change still
9
+ * counts as stale.
10
+ */
11
+ import type { PermEntry } from '../types.js';
12
+ /** Walk user + system permissions/groups/. First-wins user > system on names. */
13
+ export declare function collectPermissionGroupFiles(): Record<string, string>;
14
+ /** Build the permissions section of the manifest. */
15
+ export declare function buildPermissions(): PermEntry;
16
+ /** True when the stored permissions section no longer matches current state. */
17
+ export declare function isPermissionsStale(stored: PermEntry): boolean;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Permissions staleness — every `groups/*.yaml` across user + system
3
+ * contributes to the merged permission set (project layer not consulted by
4
+ * the current sync writer). First-wins on name collision (user > system).
5
+ *
6
+ * The active preset env value (`AGENTS_PERMISSION_PRESET`) is part of the
7
+ * fingerprint too — preset selection changes which groups get applied to
8
+ * the agent config, so a preset switch without a content change still
9
+ * counts as stale.
10
+ */
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { getUserPermissionsDir, getPermissionsDir } from '../../state.js';
14
+ import { fingerprintFile, isFileStale } from '../fingerprint.js';
15
+ import { getActivePermissionPresetName } from '../../permissions.js';
16
+ /** Walk user + system permissions/groups/. First-wins user > system on names. */
17
+ export function collectPermissionGroupFiles() {
18
+ const seen = new Map();
19
+ for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
20
+ const groupsDir = path.join(baseDir, 'groups');
21
+ let entries;
22
+ try {
23
+ entries = fs.readdirSync(groupsDir, { withFileTypes: true });
24
+ }
25
+ catch {
26
+ continue;
27
+ }
28
+ for (const entry of entries) {
29
+ if (!entry.isFile())
30
+ continue;
31
+ if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml'))
32
+ continue;
33
+ const name = entry.name.replace(/\.(yaml|yml)$/, '');
34
+ if (!seen.has(name))
35
+ seen.set(name, path.join(groupsDir, entry.name));
36
+ }
37
+ }
38
+ return Object.fromEntries(seen);
39
+ }
40
+ /** Build the permissions section of the manifest. */
41
+ export function buildPermissions() {
42
+ const groupFiles = collectPermissionGroupFiles();
43
+ const groups = {};
44
+ for (const [name, filePath] of Object.entries(groupFiles)) {
45
+ const fp = fingerprintFile(filePath);
46
+ if (fp)
47
+ groups[name] = { source: fp };
48
+ }
49
+ return {
50
+ groups,
51
+ permissionPreset: getActivePermissionPresetName(),
52
+ };
53
+ }
54
+ /** True when the stored permissions section no longer matches current state. */
55
+ export function isPermissionsStale(stored) {
56
+ if (stored.permissionPreset !== getActivePermissionPresetName())
57
+ return true;
58
+ const currentGroups = collectPermissionGroupFiles();
59
+ const storedNames = Object.keys(stored.groups).sort();
60
+ const currentNames = Object.keys(currentGroups).sort();
61
+ if (storedNames.length !== currentNames.length)
62
+ return true;
63
+ for (let i = 0; i < storedNames.length; i++) {
64
+ if (storedNames[i] !== currentNames[i])
65
+ return true;
66
+ }
67
+ for (const [name, filePath] of Object.entries(currentGroups)) {
68
+ const entry = stored.groups[name];
69
+ if (!entry || isFileStale(entry.source, filePath))
70
+ return true;
71
+ }
72
+ return false;
73
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Plugins staleness — one directory per plugin, marker file is
3
+ * `.claude-plugin/plugin.json`. First-wins across project > user > system
4
+ * > extras. Fingerprints the entire plugin root (skills, commands, hooks,
5
+ * etc. live INSIDE the plugin dir, so a content fingerprint covers them).
6
+ *
7
+ * Not tracked in v1 manifests; same one-time re-sync trade-off as workflows.
8
+ */
9
+ import type { PluginEntry } from '../types.js';
10
+ import type { TypedResourceChecker } from './types.js';
11
+ export declare const pluginsChecker: TypedResourceChecker<PluginEntry>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Plugins staleness — one directory per plugin, marker file is
3
+ * `.claude-plugin/plugin.json`. First-wins across project > user > system
4
+ * > extras. Fingerprints the entire plugin root (skills, commands, hooks,
5
+ * etc. live INSIDE the plugin dir, so a content fingerprint covers them).
6
+ *
7
+ * Not tracked in v1 manifests; same one-time re-sync trade-off as workflows.
8
+ */
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import { firstWinsLayers, listAcrossLayers, resolveByName } from '../layers.js';
12
+ import { fingerprintDir, isDirStale } from '../fingerprint.js';
13
+ function isPluginDir(full) {
14
+ try {
15
+ return fs.statSync(full).isDirectory()
16
+ && fs.existsSync(path.join(full, '.claude-plugin', 'plugin.json'));
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ export const pluginsChecker = {
23
+ type: 'plugins',
24
+ listNames(cwd) {
25
+ return listAcrossLayers(firstWinsLayers(cwd), 'plugins', (_, full) => isPluginDir(full));
26
+ },
27
+ build(name, cwd) {
28
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('plugins', name), isPluginDir);
29
+ if (!resolved)
30
+ return null;
31
+ return { dirPath: resolved.path, files: fingerprintDir(resolved.path) };
32
+ },
33
+ isFresh(name, stored, cwd) {
34
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('plugins', name), isPluginDir);
35
+ if (!resolved)
36
+ return false;
37
+ return !isDirStale(stored.dirPath, stored.files, resolved.path);
38
+ },
39
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Rules staleness — composed from a layered `rules.yaml` preset definition
3
+ * plus per-layer `subrules/<name>.md` fragments. Fingerprints exactly the
4
+ * source files that contribute to the active preset's composed output.
5
+ *
6
+ * Bug-fixed from v1: the old `resolveRuleFile` looked for `rules/<preset>.md`,
7
+ * a path that never exists (presets live in `rules.yaml`, fragments live in
8
+ * `subrules/`). That made the rules section always report stale. This module
9
+ * uses `composeRulesFromState` to discover the actual source file set per
10
+ * preset/cwd, so freshness reflects real source changes.
11
+ *
12
+ * Special-cased vs. the other checkers: agent + version are needed to read
13
+ * the active preset, so this module doesn't conform to ResourceChecker. The
14
+ * aggregator wires it up explicitly.
15
+ */
16
+ import type { AgentId } from '../../types.js';
17
+ import type { RulesEntry } from '../types.js';
18
+ export declare function buildRules(agent: AgentId, version: string, cwd: string): RulesEntry;
19
+ export declare function isRulesStale(stored: RulesEntry, agent: AgentId, version: string, cwd: string): boolean;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Rules staleness — composed from a layered `rules.yaml` preset definition
3
+ * plus per-layer `subrules/<name>.md` fragments. Fingerprints exactly the
4
+ * source files that contribute to the active preset's composed output.
5
+ *
6
+ * Bug-fixed from v1: the old `resolveRuleFile` looked for `rules/<preset>.md`,
7
+ * a path that never exists (presets live in `rules.yaml`, fragments live in
8
+ * `subrules/`). That made the rules section always report stale. This module
9
+ * uses `composeRulesFromState` to discover the actual source file set per
10
+ * preset/cwd, so freshness reflects real source changes.
11
+ *
12
+ * Special-cased vs. the other checkers: agent + version are needed to read
13
+ * the active preset, so this module doesn't conform to ResourceChecker. The
14
+ * aggregator wires it up explicitly.
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { fingerprintFile, isFileStale } from '../fingerprint.js';
19
+ import { composeRulesFromState } from '../../rules/compose.js';
20
+ import { getActiveRulesPreset, getUserRulesDir, getResolvedRulesDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../../state.js';
21
+ function rulesDirForLayer(scope, cwd) {
22
+ if (scope === 'project') {
23
+ const proj = getProjectAgentsDir(cwd);
24
+ return proj ? path.join(proj, 'rules') : null;
25
+ }
26
+ if (scope === 'user')
27
+ return getUserRulesDir();
28
+ if (scope === 'system')
29
+ return getResolvedRulesDir();
30
+ // extra: first registered extra repo's rules dir. The composer doesn't
31
+ // disambiguate multi-extra rules.yaml today, so we don't either.
32
+ const extras = getEnabledExtraRepos();
33
+ return extras.length > 0 ? path.join(extras[0].dir, 'rules') : null;
34
+ }
35
+ /**
36
+ * Resolve the set of source files contributing to the active preset's output.
37
+ * Keys are relative paths within the rules dir (stable across machines).
38
+ * Values are absolute current paths.
39
+ */
40
+ function activeSources(agent, version, cwd) {
41
+ const result = {};
42
+ let compose;
43
+ try {
44
+ const preset = getActiveRulesPreset(agent, version);
45
+ compose = composeRulesFromState({ preset, cwd });
46
+ }
47
+ catch {
48
+ return result;
49
+ }
50
+ const yamlDir = rulesDirForLayer(compose.presetLayer, cwd);
51
+ if (yamlDir) {
52
+ const yamlPath = path.join(yamlDir, 'rules.yaml');
53
+ if (fs.existsSync(yamlPath))
54
+ result['rules.yaml'] = yamlPath;
55
+ }
56
+ for (const sub of compose.subrules) {
57
+ result[`subrules/${sub.name}.md`] = sub.sourcePath;
58
+ }
59
+ return result;
60
+ }
61
+ export function buildRules(agent, version, cwd) {
62
+ const files = {};
63
+ for (const [key, srcPath] of Object.entries(activeSources(agent, version, cwd))) {
64
+ const fp = fingerprintFile(srcPath);
65
+ if (fp)
66
+ files[key] = { source: fp };
67
+ }
68
+ return { files };
69
+ }
70
+ export function isRulesStale(stored, agent, version, cwd) {
71
+ const current = activeSources(agent, version, cwd);
72
+ const storedKeys = Object.keys(stored.files).sort();
73
+ const currentKeys = Object.keys(current).sort();
74
+ if (storedKeys.length !== currentKeys.length)
75
+ return true;
76
+ for (let i = 0; i < storedKeys.length; i++) {
77
+ if (storedKeys[i] !== currentKeys[i])
78
+ return true;
79
+ }
80
+ for (const [key, srcPath] of Object.entries(current)) {
81
+ const entry = stored.files[key];
82
+ if (!entry || isFileStale(entry.source, srcPath))
83
+ return true;
84
+ }
85
+ return false;
86
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Skills staleness — one directory per skill (must contain SKILL.md),
3
+ * first-wins across project > user > system > extras.
4
+ */
5
+ import type { DirEntry } from '../types.js';
6
+ import type { TypedResourceChecker } from './types.js';
7
+ export declare const skillsChecker: TypedResourceChecker<DirEntry>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Skills staleness — one directory per skill (must contain SKILL.md),
3
+ * first-wins across project > user > system > extras.
4
+ */
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { firstWinsLayers, listAcrossLayers, resolveByName } from '../layers.js';
8
+ import { fingerprintDir, isDirStale } from '../fingerprint.js';
9
+ function isSkillDir(full) {
10
+ try {
11
+ return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, 'SKILL.md'));
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export const skillsChecker = {
18
+ type: 'skills',
19
+ listNames(cwd) {
20
+ return listAcrossLayers(firstWinsLayers(cwd), 'skills', (_, full) => isSkillDir(full));
21
+ },
22
+ build(name, cwd) {
23
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('skills', name), isSkillDir);
24
+ if (!resolved)
25
+ return null;
26
+ return { dirPath: resolved.path, files: fingerprintDir(resolved.path) };
27
+ },
28
+ isFresh(name, stored, cwd) {
29
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('skills', name), isSkillDir);
30
+ if (!resolved)
31
+ return false;
32
+ return !isDirStale(stored.dirPath, stored.files, resolved.path);
33
+ },
34
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Subagents staleness — one directory per subagent (must contain AGENT.md),
3
+ * first-wins across project > user > system > extras.
4
+ *
5
+ * Bug-fixed from v1: the old manifest derived its name list from
6
+ * `listInstalledSubagents()` which only walks user + system. With project
7
+ * subagents in `available.subagents`, the name-set diff always flipped to
8
+ * "stale". This checker walks all four layers consistently.
9
+ */
10
+ import type { DirEntry } from '../types.js';
11
+ import type { TypedResourceChecker } from './types.js';
12
+ export declare const subagentsChecker: TypedResourceChecker<DirEntry>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Subagents staleness — one directory per subagent (must contain AGENT.md),
3
+ * first-wins across project > user > system > extras.
4
+ *
5
+ * Bug-fixed from v1: the old manifest derived its name list from
6
+ * `listInstalledSubagents()` which only walks user + system. With project
7
+ * subagents in `available.subagents`, the name-set diff always flipped to
8
+ * "stale". This checker walks all four layers consistently.
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { firstWinsLayers, listAcrossLayers, resolveByName } from '../layers.js';
13
+ import { fingerprintDir, isDirStale } from '../fingerprint.js';
14
+ function isSubagentDir(full) {
15
+ try {
16
+ return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, 'AGENT.md'));
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ export const subagentsChecker = {
23
+ type: 'subagents',
24
+ listNames(cwd) {
25
+ return listAcrossLayers(firstWinsLayers(cwd), 'subagents', (_, full) => isSubagentDir(full));
26
+ },
27
+ build(name, cwd) {
28
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('subagents', name), isSubagentDir);
29
+ if (!resolved)
30
+ return null;
31
+ return { dirPath: resolved.path, files: fingerprintDir(resolved.path) };
32
+ },
33
+ isFresh(name, stored, cwd) {
34
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('subagents', name), isSubagentDir);
35
+ if (!resolved)
36
+ return false;
37
+ return !isDirStale(stored.dirPath, stored.files, resolved.path);
38
+ },
39
+ };