@phnx-labs/agents-cli 1.18.1 → 1.18.2

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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/commands/doctor.js +19 -5
  3. package/dist/commands/exec.js +9 -4
  4. package/dist/index.js +30 -0
  5. package/dist/lib/hooks.js +21 -3
  6. package/dist/lib/migrate.js +35 -12
  7. package/dist/lib/shims.d.ts +3 -1
  8. package/dist/lib/shims.js +81 -7
  9. package/dist/lib/staleness/checkers/commands.d.ts +7 -0
  10. package/dist/lib/staleness/checkers/commands.js +27 -0
  11. package/dist/lib/staleness/checkers/hooks.d.ts +13 -0
  12. package/dist/lib/staleness/checkers/hooks.js +63 -0
  13. package/dist/lib/staleness/checkers/mcp.d.ts +12 -0
  14. package/dist/lib/staleness/checkers/mcp.js +38 -0
  15. package/dist/lib/staleness/checkers/permissions.d.ts +17 -0
  16. package/dist/lib/staleness/checkers/permissions.js +73 -0
  17. package/dist/lib/staleness/checkers/plugins.d.ts +11 -0
  18. package/dist/lib/staleness/checkers/plugins.js +39 -0
  19. package/dist/lib/staleness/checkers/rules.d.ts +19 -0
  20. package/dist/lib/staleness/checkers/rules.js +86 -0
  21. package/dist/lib/staleness/checkers/skills.d.ts +7 -0
  22. package/dist/lib/staleness/checkers/skills.js +34 -0
  23. package/dist/lib/staleness/checkers/subagents.d.ts +12 -0
  24. package/dist/lib/staleness/checkers/subagents.js +39 -0
  25. package/dist/lib/staleness/checkers/types.d.ts +44 -0
  26. package/dist/lib/staleness/checkers/types.js +20 -0
  27. package/dist/lib/staleness/checkers/workflows.d.ts +10 -0
  28. package/dist/lib/staleness/checkers/workflows.js +37 -0
  29. package/dist/lib/staleness/fingerprint.d.ts +38 -0
  30. package/dist/lib/staleness/fingerprint.js +154 -0
  31. package/dist/lib/staleness/index.d.ts +26 -0
  32. package/dist/lib/staleness/index.js +122 -0
  33. package/dist/lib/staleness/layers.d.ts +37 -0
  34. package/dist/lib/staleness/layers.js +100 -0
  35. package/dist/lib/staleness/types.d.ts +56 -0
  36. package/dist/lib/staleness/types.js +6 -0
  37. package/dist/lib/state.d.ts +2 -0
  38. package/dist/lib/state.js +2 -0
  39. package/dist/lib/teams/agents.d.ts +11 -20
  40. package/dist/lib/teams/agents.js +55 -202
  41. package/dist/lib/teams/index.d.ts +3 -2
  42. package/dist/lib/teams/index.js +2 -2
  43. package/dist/lib/teams/persistence.d.ts +0 -38
  44. package/dist/lib/teams/persistence.js +7 -329
  45. package/dist/lib/teams/registry.js +7 -5
  46. package/dist/lib/versions.js +34 -12
  47. package/package.json +1 -1
  48. package/dist/lib/sync-manifest.d.ts +0 -81
  49. package/dist/lib/sync-manifest.js +0 -450
@@ -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
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Contract every resource staleness checker implements. Each checker owns
3
+ * the layer-resolution and entry-shape rules for one resource type. The
4
+ * aggregator in `../index.ts` doesn't know any of those details — it just
5
+ * calls these three methods.
6
+ *
7
+ * Why three methods (not just "isStale"):
8
+ * - `listNames` feeds both the manifest writer (what to record) and the
9
+ * name-set diff (`stored` vs. `current` reveals adds/removes).
10
+ * - `build` produces the entry for a single name; called per name
11
+ * after listing. Pure — no comparison logic.
12
+ * - `isFresh` checks one stored entry against current state; called
13
+ * when the name set already matches and we need content-level certainty.
14
+ *
15
+ * The `unknown` entry type is intentional — each checker round-trips its
16
+ * own concrete shape through JSON (commands/hooks/mcp use FileEntry; skills
17
+ * /subagents/workflows/plugins use DirEntry; rules/permissions use their
18
+ * own composite entries).
19
+ */
20
+ export interface ResourceChecker {
21
+ /** Stable identifier; matches the manifest field name. */
22
+ readonly type: string;
23
+ /** Names of every resource currently available across this checker's layers. */
24
+ listNames(cwd: string): string[];
25
+ /**
26
+ * Build a manifest entry for one name. Returns null when no source file is
27
+ * found — the aggregator drops nulls so name-set diff stays accurate.
28
+ */
29
+ build(name: string, cwd: string): unknown | null;
30
+ /**
31
+ * Check whether a stored entry still reflects current source. Called only
32
+ * after the name-set already matches. Returns true when fresh, false when
33
+ * the entry should trigger a re-sync.
34
+ */
35
+ isFresh(name: string, stored: unknown, cwd: string): boolean;
36
+ }
37
+ /**
38
+ * Helper for checkers whose entry shape varies. Strict-typed convenience
39
+ * wrapper that callers can use to avoid `unknown` casts in their own code.
40
+ */
41
+ export interface TypedResourceChecker<TEntry> extends ResourceChecker {
42
+ build(name: string, cwd: string): TEntry | null;
43
+ isFresh(name: string, stored: TEntry, cwd: string): boolean;
44
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Contract every resource staleness checker implements. Each checker owns
3
+ * the layer-resolution and entry-shape rules for one resource type. The
4
+ * aggregator in `../index.ts` doesn't know any of those details — it just
5
+ * calls these three methods.
6
+ *
7
+ * Why three methods (not just "isStale"):
8
+ * - `listNames` feeds both the manifest writer (what to record) and the
9
+ * name-set diff (`stored` vs. `current` reveals adds/removes).
10
+ * - `build` produces the entry for a single name; called per name
11
+ * after listing. Pure — no comparison logic.
12
+ * - `isFresh` checks one stored entry against current state; called
13
+ * when the name set already matches and we need content-level certainty.
14
+ *
15
+ * The `unknown` entry type is intentional — each checker round-trips its
16
+ * own concrete shape through JSON (commands/hooks/mcp use FileEntry; skills
17
+ * /subagents/workflows/plugins use DirEntry; rules/permissions use their
18
+ * own composite entries).
19
+ */
20
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Workflows staleness — one directory per workflow (must contain
3
+ * WORKFLOW.md), first-wins across project > user > system > extras.
4
+ *
5
+ * Not tracked in v1 manifests; treated as a new section that's empty on old
6
+ * files, which causes one re-sync (filling the field) and then steady-state.
7
+ */
8
+ import type { DirEntry } from '../types.js';
9
+ import type { TypedResourceChecker } from './types.js';
10
+ export declare const workflowsChecker: TypedResourceChecker<DirEntry>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Workflows staleness — one directory per workflow (must contain
3
+ * WORKFLOW.md), first-wins across project > user > system > extras.
4
+ *
5
+ * Not tracked in v1 manifests; treated as a new section that's empty on old
6
+ * files, which causes one re-sync (filling the field) and then steady-state.
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { firstWinsLayers, listAcrossLayers, resolveByName } from '../layers.js';
11
+ import { fingerprintDir, isDirStale } from '../fingerprint.js';
12
+ function isWorkflowDir(full) {
13
+ try {
14
+ return fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, 'WORKFLOW.md'));
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ export const workflowsChecker = {
21
+ type: 'workflows',
22
+ listNames(cwd) {
23
+ return listAcrossLayers(firstWinsLayers(cwd), 'workflows', (_, full) => isWorkflowDir(full));
24
+ },
25
+ build(name, cwd) {
26
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('workflows', name), isWorkflowDir);
27
+ if (!resolved)
28
+ return null;
29
+ return { dirPath: resolved.path, files: fingerprintDir(resolved.path) };
30
+ },
31
+ isFresh(name, stored, cwd) {
32
+ const resolved = resolveByName(firstWinsLayers(cwd), path.join('workflows', name), isWorkflowDir);
33
+ if (!resolved)
34
+ return false;
35
+ return !isDirStale(stored.dirPath, stored.files, resolved.path);
36
+ },
37
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * File and directory fingerprinting primitives shared by every resource
3
+ * checker. Two-tier comparison: stat (mtime+size) first for the hot path,
4
+ * sha256 only on miss.
5
+ */
6
+ /** Fingerprint of a single source file. */
7
+ export interface Fingerprint {
8
+ path: string;
9
+ mtime: number;
10
+ size: number;
11
+ sha256: string;
12
+ }
13
+ export declare function sha256(content: string): string;
14
+ /** Fingerprint a single file. Returns null when the file is unreadable. */
15
+ export declare function fingerprintFile(filePath: string): Fingerprint | null;
16
+ /**
17
+ * Fingerprint all files in a directory recursively. Returned sorted by
18
+ * absolute path so ordering is deterministic regardless of readdir order.
19
+ * Noise entries (see `FINGERPRINT_SKIP`) are excluded.
20
+ */
21
+ export declare function fingerprintDir(dirPath: string): Fingerprint[];
22
+ /** Hot-path file staleness: stat-only when mtime+size match, sha256 on miss. */
23
+ export declare function isFileStale(stored: Fingerprint, currentPath: string): boolean;
24
+ /**
25
+ * Hot-path directory staleness. Compares sorted paths first (catches add /
26
+ * remove / rename), then stat each file (skips reads when mtime+size match),
27
+ * sha256 only on stat mismatch.
28
+ */
29
+ export declare function isDirStale(storedDirPath: string, storedFiles: Fingerprint[], currentDirPath: string): boolean;
30
+ /**
31
+ * Walk a directory and return sorted absolute paths of every regular file.
32
+ * No content reads. Uses the same FINGERPRINT_SKIP allowlist as
33
+ * `fingerprintDir` so both produce the same path set (required for the
34
+ * dir-stale path comparison to work).
35
+ */
36
+ export declare function walkDirPaths(dirPath: string): string[];
37
+ /** True if two sorted-or-unsorted name sets differ. */
38
+ export declare function nameSetDiffers(a: string[], b: string[]): boolean;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * File and directory fingerprinting primitives shared by every resource
3
+ * checker. Two-tier comparison: stat (mtime+size) first for the hot path,
4
+ * sha256 only on miss.
5
+ */
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as crypto from 'crypto';
9
+ export function sha256(content) {
10
+ return crypto.createHash('sha256').update(content).digest('hex');
11
+ }
12
+ /** Fingerprint a single file. Returns null when the file is unreadable. */
13
+ export function fingerprintFile(filePath) {
14
+ try {
15
+ const stat = fs.statSync(filePath);
16
+ const content = fs.readFileSync(filePath, 'utf-8');
17
+ return { path: filePath, mtime: stat.mtimeMs, size: stat.size, sha256: sha256(content) };
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /**
24
+ * Names we never fingerprint: OS metadata, VCS bookkeeping, dep caches,
25
+ * build outputs. Matches the SKILL_COPY_IGNORE set used by the sync writer
26
+ * in `src/lib/versions.ts`.
27
+ *
28
+ * Important: this is an allowlist of noise, NOT a blanket "skip every
29
+ * dot-prefixed entry". Plugins keep their manifest at
30
+ * `.claude-plugin/plugin.json` — a dot-prefix skip would make plugin
31
+ * manifests invisible to the fingerprint and silently break staleness
32
+ * detection for plugins.
33
+ */
34
+ const FINGERPRINT_SKIP = new Set([
35
+ '.DS_Store',
36
+ '.git',
37
+ '.gitignore',
38
+ '.venv',
39
+ '__pycache__',
40
+ 'node_modules',
41
+ ]);
42
+ /**
43
+ * Fingerprint all files in a directory recursively. Returned sorted by
44
+ * absolute path so ordering is deterministic regardless of readdir order.
45
+ * Noise entries (see `FINGERPRINT_SKIP`) are excluded.
46
+ */
47
+ export function fingerprintDir(dirPath) {
48
+ const results = [];
49
+ function walk(dir) {
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(dir, { withFileTypes: true });
53
+ }
54
+ catch {
55
+ return;
56
+ }
57
+ for (const entry of entries) {
58
+ if (FINGERPRINT_SKIP.has(entry.name))
59
+ continue;
60
+ const full = path.join(dir, entry.name);
61
+ if (entry.isDirectory())
62
+ walk(full);
63
+ else if (entry.isFile()) {
64
+ const fp = fingerprintFile(full);
65
+ if (fp)
66
+ results.push(fp);
67
+ }
68
+ }
69
+ }
70
+ walk(dirPath);
71
+ results.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
72
+ return results;
73
+ }
74
+ /** Hot-path file staleness: stat-only when mtime+size match, sha256 on miss. */
75
+ export function isFileStale(stored, currentPath) {
76
+ if (stored.path !== currentPath)
77
+ return true;
78
+ try {
79
+ const stat = fs.statSync(currentPath);
80
+ if (stat.mtimeMs === stored.mtime && stat.size === stored.size)
81
+ return false;
82
+ return sha256(fs.readFileSync(currentPath, 'utf-8')) !== stored.sha256;
83
+ }
84
+ catch {
85
+ return true;
86
+ }
87
+ }
88
+ /**
89
+ * Hot-path directory staleness. Compares sorted paths first (catches add /
90
+ * remove / rename), then stat each file (skips reads when mtime+size match),
91
+ * sha256 only on stat mismatch.
92
+ */
93
+ export function isDirStale(storedDirPath, storedFiles, currentDirPath) {
94
+ if (storedDirPath !== currentDirPath)
95
+ return true;
96
+ const currentPaths = walkDirPaths(currentDirPath);
97
+ if (currentPaths.length !== storedFiles.length)
98
+ return true;
99
+ for (let i = 0; i < currentPaths.length; i++) {
100
+ const stored = storedFiles[i];
101
+ const cur = currentPaths[i];
102
+ if (stored.path !== cur)
103
+ return true;
104
+ try {
105
+ const stat = fs.statSync(cur);
106
+ if (stat.mtimeMs === stored.mtime && stat.size === stored.size)
107
+ continue;
108
+ if (sha256(fs.readFileSync(cur, 'utf-8')) !== stored.sha256)
109
+ return true;
110
+ }
111
+ catch {
112
+ return true;
113
+ }
114
+ }
115
+ return false;
116
+ }
117
+ /**
118
+ * Walk a directory and return sorted absolute paths of every regular file.
119
+ * No content reads. Uses the same FINGERPRINT_SKIP allowlist as
120
+ * `fingerprintDir` so both produce the same path set (required for the
121
+ * dir-stale path comparison to work).
122
+ */
123
+ export function walkDirPaths(dirPath) {
124
+ const results = [];
125
+ function walk(dir) {
126
+ let entries;
127
+ try {
128
+ entries = fs.readdirSync(dir, { withFileTypes: true });
129
+ }
130
+ catch {
131
+ return;
132
+ }
133
+ for (const entry of entries) {
134
+ if (FINGERPRINT_SKIP.has(entry.name))
135
+ continue;
136
+ const full = path.join(dir, entry.name);
137
+ if (entry.isDirectory())
138
+ walk(full);
139
+ else if (entry.isFile())
140
+ results.push(full);
141
+ }
142
+ }
143
+ walk(dirPath);
144
+ results.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
145
+ return results;
146
+ }
147
+ /** True if two sorted-or-unsorted name sets differ. */
148
+ export function nameSetDiffers(a, b) {
149
+ if (a.length !== b.length)
150
+ return true;
151
+ const sortedA = [...a].sort();
152
+ const sortedB = [...b].sort();
153
+ return sortedA.some((n, i) => n !== sortedB[i]);
154
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Staleness library entrypoint. Aggregates per-resource checkers into the
3
+ * two operations the rest of the codebase needs:
4
+ *
5
+ * - `buildManifest(agent, version, cwd)` — snapshot current state.
6
+ * - `isStale(manifest, agent, version, cwd)` — true when any tracked
7
+ * resource has drifted from its stored fingerprint.
8
+ *
9
+ * `loadManifest` / `saveManifest` round-trip the on-disk JSON. The format
10
+ * version stays at 1; new optional fields (workflows, plugins) on old files
11
+ * read as empty maps which forces a single re-sync.
12
+ */
13
+ import type { AgentId } from '../types.js';
14
+ import { type SyncManifest, type FileEntry, type DirEntry, type PluginEntry, type RulesEntry } from './types.js';
15
+ export type { SyncManifest } from './types.js';
16
+ export { MANIFEST_VERSION } from './types.js';
17
+ export declare function loadManifest(agent: AgentId, version: string): SyncManifest | null;
18
+ export declare function saveManifest(agent: AgentId, version: string, manifest: SyncManifest): void;
19
+ export declare function buildManifest(agent: AgentId, version: string, cwd: string): SyncManifest;
20
+ /**
21
+ * True when any tracked resource has drifted from the stored manifest.
22
+ * Walks every resource type in turn and returns true at the first miss —
23
+ * sync detection should be cheap when nothing changed.
24
+ */
25
+ export declare function isStale(manifest: SyncManifest, agent: AgentId, version: string, cwd: string): boolean;
26
+ export type { FileEntry, DirEntry, PluginEntry, RulesEntry };