@phnx-labs/agents-cli 1.20.24 → 1.20.26

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ **Secrets default policy is now `daily` (one Touch ID per ~24h), not `always`**
6
+
7
+ - The default prompt policy for bundles without an explicit one flipped from `always` (Touch ID on *every* read) to **`daily`** (one prompt, then held ~24h until screen-lock / sleep / logout). This is the fix for the prompt storm: a background reader like sessions-sync hammering a bundle now costs one Touch ID per ~24h instead of one per read.
8
+ - **Auto-cache is on by default.** The secrets-agent is the mechanism that delivers the daily policy, so it self-caches a `daily` bundle on first read with no `secrets.agent.auto: true` needed. Opt out with `secrets.agent.auto: false`.
9
+ - **Configurable, still flexible.** Set the global default in `agents.yaml` (`secrets.policy: always` to restore prompt-every-time), or override per bundle with `agents secrets policy <bundle> always` for high-value keys (signing, SSH) you want to confirm on every read.
10
+ - **Explicit `always` now persists** under the legacy `tier: biometry` token (older CLIs read it as their own always default). Bundles with no stored policy inherit the configured default — so an existing always-by-default bundle quietly becomes `daily` on first read by the new CLI, which is the intended migration.
11
+
12
+ **`agents repos view [name]`: inspect one repo's contents without opening it**
13
+
14
+ - New `agents repo view <name>` (also reachable as `agents repos view`, now a first-class alias of the `repo` command) prints a single repo's git state and per-kind resource counts — `system`, `user`, `project`, or an extra-repo alias. Omit the name for an interactive picker over the registered repos. It reuses the `inspect` repo renderer, so output matches `agents inspect <repo>`; supports `--brief` and `--json`. Source: `src/commands/repo.ts`, `src/commands/inspect.ts`.
15
+
5
16
  **Secrets prompt policy: human-readable `always` / `daily`, and `secrets list` now shows it**
6
17
 
7
18
  - Renamed the secrets-agent `tier` to a **prompt policy** with plain-language names: `biometry` → **`always`** (ask every time), `session` → **`daily`** (ask once, then held ~24h until screen-lock / sleep / logout). The old name `session` was misleading — it never meant "once per login session" — and collided with the half-dozen other "session" concepts in the CLI (`agents sessions`, sessions-sync, pty/browser sessions). Set it with `agents secrets policy <bundle> [always|daily]`.
@@ -32,7 +32,7 @@ interface ResourceItem {
32
32
  /** For plugins: the resource categories (skills, commands, …) the bundle packages. */
33
33
  groups?: PluginResourceGroup[];
34
34
  }
35
- interface InspectOptions {
35
+ export interface InspectOptions {
36
36
  brief?: boolean;
37
37
  json?: boolean;
38
38
  commands?: boolean | string;
@@ -66,6 +66,7 @@ export interface RepoTarget {
66
66
  * Returns null when the target is none of these.
67
67
  */
68
68
  export declare function resolveRepoTarget(target: string, cwd?: string): RepoTarget | null;
69
+ export declare function inspectRepo(repo: RepoTarget, options: InspectOptions): Promise<void>;
69
70
  /** List one resource kind from a single repo root — no layering, no overrides. */
70
71
  export declare function collectRepoKind(repo: RepoTarget, kind: DrillableKind): ResourceItem[];
71
72
  /** Recursive size + file count of a path; symlinks are not followed. */
@@ -227,7 +227,7 @@ function isDotAgentsRoot(dir) {
227
227
  }
228
228
  return false;
229
229
  }
230
- async function inspectRepo(repo, options) {
230
+ export async function inspectRepo(repo, options) {
231
231
  const drill = pickDrillKind(options);
232
232
  const jsonHead = { repo: repo.label, root: repo.root };
233
233
  if (drill) {
@@ -61,9 +61,14 @@ export function registerMenubarCommands(program) {
61
61
  console.log(` running ${yn(s.running)}`);
62
62
  console.log(` service installed ${yn(s.serviceInstalled)}`);
63
63
  console.log(` app installed ${s.installedApp ? chalk.gray(s.installedApp) : chalk.gray('no')}`);
64
+ console.log(` installed version ${s.installedVersion ? chalk.gray(s.installedVersion) : chalk.gray('unknown')}`);
65
+ console.log(` current version ${chalk.gray(s.currentVersion)}`);
64
66
  console.log(` bundle source ${s.source ? chalk.gray(s.source) : chalk.red('missing (cannot enable)')}`);
65
67
  console.log(` disabled by user ${yn(s.disabledByUser)}`);
66
- if (!s.serviceInstalled && !s.disabledByUser) {
68
+ if (s.stale) {
69
+ console.log(chalk.yellow('\n Installed helper is stale — runs on next `agents` startup, or `agents menubar enable` now.'));
70
+ }
71
+ else if (!s.serviceInstalled && !s.disabledByUser) {
67
72
  console.log(chalk.gray('\n Enable it with `agents menubar enable`.'));
68
73
  }
69
74
  });
@@ -7,6 +7,8 @@ import simpleGit from 'simple-git';
7
7
  import { confirm, input } from '@inquirer/prompts';
8
8
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
9
9
  import { setHelpSections } from '../lib/help.js';
10
+ import { itemPicker } from '../lib/picker.js';
11
+ import { inspectRepo, resolveRepoTarget, } from './inspect.js';
10
12
  const HOME = os.homedir();
11
13
  /**
12
14
  * Resolve a target argument to an absolute path.
@@ -162,6 +164,7 @@ async function listRepos(alias) {
162
164
  export function registerRepoCommands(program) {
163
165
  const repoCmd = program
164
166
  .command('repo')
167
+ .alias('repos')
165
168
  .description('Manage extra DotAgent repos alongside ~/.agents/ (for private or team skills).');
166
169
  setHelpSections(repoCmd, {
167
170
  examples: `
@@ -180,6 +183,9 @@ export function registerRepoCommands(program) {
180
183
  # See what's registered
181
184
  agents repo list
182
185
 
186
+ # View one repo's contents (git state + resource counts); omit the name for a picker
187
+ agents repos view system
188
+
183
189
  # Temporarily disable without deleting
184
190
  agents repo disable acme
185
191
  `,
@@ -356,6 +362,40 @@ export function registerRepoCommands(program) {
356
362
  .action(async (alias) => {
357
363
  await listRepos(alias);
358
364
  });
365
+ repoCmd
366
+ .command('view [name]')
367
+ .description("Show one repo's contents: git state and per-kind resource counts. Omit the name for an interactive picker.")
368
+ .option('--brief', 'header + git only; skip resource counts')
369
+ .option('--json', 'machine-readable JSON output')
370
+ .action(async (name, options) => {
371
+ if (name) {
372
+ const repo = resolveRepoTarget(name);
373
+ if (!repo) {
374
+ console.log(chalk.red(`Unknown repo "${name}". Use "system", "user", "project", or a registered extra alias.`));
375
+ process.exitCode = 1;
376
+ return;
377
+ }
378
+ await inspectRepo(repo, options);
379
+ return;
380
+ }
381
+ const targets = collectRepoTargets(undefined) || [];
382
+ if (!isInteractiveTerminal()) {
383
+ console.log(chalk.red('No repo name given and not an interactive terminal.'));
384
+ console.log(chalk.gray('Pass a name (e.g. `agents repos view system`) or run in a TTY for the picker.'));
385
+ process.exitCode = 1;
386
+ return;
387
+ }
388
+ const picked = await itemPicker({
389
+ message: 'Select a repo to view',
390
+ items: targets,
391
+ filter: (q) => targets.filter((t) => t.alias.toLowerCase().includes(q.toLowerCase())),
392
+ labelFor: (t) => `${chalk.cyan(t.alias.padEnd(10))} ${chalk.gray(t.dir)}`,
393
+ });
394
+ if (!picked)
395
+ return;
396
+ const repo = { label: picked.item.alias, root: picked.item.dir };
397
+ await inspectRepo(repo, options);
398
+ });
359
399
  repoCmd
360
400
  .command('remove <alias>')
361
401
  .alias('rm')
@@ -424,14 +424,15 @@ export function registerSecretsCommands(program) {
424
424
  never touch disk in plaintext. Every item is device-local and gated by Touch ID
425
425
  or device passcode; cross-machine sync is handled by 'agents secrets push/pull'.
426
426
 
427
- Touch ID noise: macOS pops a prompt per bundle per process, so concurrent
428
- agents each re-prompt. Each bundle has a prompt policy, shown in the POLICY
429
- column of 'agents secrets list':
430
- always (default) ask for Touch ID every time — never auto-held.
431
- daily ask once, then hold it silently in the local agent up
427
+ Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
428
+ a prompt policy, shown in the POLICY column of 'agents secrets list':
429
+ daily (default) ask once, then hold it silently in the local agent up
432
430
  to ~24h, until screen-lock / sleep / logout or 'lock'.
433
- Set it with 'agents secrets policy <bundle> daily'. 'agents secrets unlock
434
- <bundle>' holds any bundle after one prompt regardless of policy. Nothing on disk.
431
+ always ask for Touch ID every time never auto-held.
432
+ The default is 'daily' (one Touch ID per ~24h); change it globally with
433
+ 'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
434
+ <bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
435
+ prompt regardless of policy. Nothing on disk.
435
436
 
436
437
  See also:
437
438
  agents secrets policy <bundle> daily ask once a day, not every run
@@ -623,7 +624,7 @@ export function registerSecretsCommands(program) {
623
624
  .description('Create an empty bundle')
624
625
  .option('--description <text>', 'Free-form description')
625
626
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
626
- .option('--policy <policy>', 'prompt policy: always (default, ask every time) or daily (ask once a day)')
627
+ .option('--policy <policy>', 'prompt policy: daily (default, ask once a day) or always (ask every time)')
627
628
  .addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
628
629
  .option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
629
630
  .option('--force', 'Overwrite an existing bundle')
@@ -631,7 +632,10 @@ export function registerSecretsCommands(program) {
631
632
  try {
632
633
  const resolvedName = name ?? (await promptBundleName());
633
634
  validateBundleName(resolvedName);
634
- const policy = parsePolicyOpt(opts.policy ?? opts.tier);
635
+ // Leave policy unset unless the user explicitly chose one, so the bundle
636
+ // inherits the configured default (`daily`) instead of being pinned.
637
+ const policyOpt = opts.policy ?? opts.tier;
638
+ const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
635
639
  const backend = parseBackendOpt(opts.backend);
636
640
  if (bundleExists(resolvedName) && !opts.force) {
637
641
  console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
@@ -647,7 +651,7 @@ export function registerSecretsCommands(program) {
647
651
  };
648
652
  writeBundle(bundle);
649
653
  const tags = [
650
- policy === 'daily' ? 'policy: daily' : 'policy: always ask',
654
+ bundlePolicy(bundle) === 'daily' ? 'policy: daily' : 'policy: always ask',
651
655
  backend === 'file' ? 'backend: file' : null,
652
656
  ].filter(Boolean);
653
657
  console.log(chalk.green(`Bundle '${resolvedName}' created (${tags.join(', ')}).`));
@@ -1430,7 +1434,7 @@ Examples:
1430
1434
  cmd
1431
1435
  .command('policy <bundle> [policy]')
1432
1436
  .alias('tier')
1433
- .description("Show or set a bundle's prompt policy: always (default, ask every time) or daily (ask once a day).")
1437
+ .description("Show or set a bundle's prompt policy: daily (default, ask once a day) or always (ask every time).")
1434
1438
  .action((bundleName, policyArg) => {
1435
1439
  try {
1436
1440
  const bundle = readBundle(bundleName);
@@ -1443,7 +1447,7 @@ Examples:
1443
1447
  writeBundle(bundle);
1444
1448
  console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
1445
1449
  if (next === 'daily') {
1446
- console.log(chalk.gray('Held by the secrets-agent after one unlock: run `agents secrets unlock`, or enable auto-cache with `secrets.agent.auto: true` in agents.yaml.'));
1450
+ console.log(chalk.gray('Held by the secrets-agent for ~24h after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
1447
1451
  }
1448
1452
  else {
1449
1453
  console.log(chalk.gray('Asks for Touch ID every time — never auto-held.'));
@@ -12,7 +12,8 @@ export declare function ensureLockTarget(filePath: string, initialContent?: stri
12
12
  export declare function atomicWriteFileSync(filePath: string, content: string): void;
13
13
  /**
14
14
  * Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
15
- * releases the lock. Retries up to LOCK_RETRIES times with linear back-off.
16
- * Breaks stale locks older than LOCK_STALE_MS.
15
+ * releases the lock. Retries with capped linear back-off until either the lock
16
+ * is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
17
+ * LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
17
18
  */
18
19
  export declare function withFileLock<T>(filePath: string, fn: () => T): T;
@@ -3,7 +3,18 @@ import * as path from 'path';
3
3
  import { randomBytes } from 'crypto';
4
4
  import lockfile from 'proper-lockfile';
5
5
  const LOCK_STALE_MS = 5_000;
6
- const LOCK_RETRIES = 5;
6
+ // Wall-clock budget to acquire the lock before giving up. A count-bounded retry
7
+ // (the old 5 attempts / ~750ms ceiling) could expire while a peer legitimately
8
+ // held the lock — under CI/parallel load two `agents` invocations mutating
9
+ // agents.yaml would have one throw and silently drop its write. The budget must
10
+ // comfortably exceed both a normal critical-section hold and the stale-break
11
+ // window (LOCK_STALE_MS): a dead holder's lock turns stale at 5s and is then
12
+ // broken on the next attempt, so this only ever waits out a live, in-progress
13
+ // holder. Bounded (not unbounded) so a truly wedged holder still surfaces an
14
+ // error instead of hanging the CLI forever.
15
+ const LOCK_ACQUIRE_TIMEOUT_MS = 30_000;
16
+ const LOCK_RETRY_MIN_MS = 50;
17
+ const LOCK_RETRY_MAX_MS = 250;
7
18
  // Reused across all sleepSync calls — avoids allocating a new SAB each time.
8
19
  const _sleepBuf = new Int32Array(new SharedArrayBuffer(4));
9
20
  export function sleepSync(ms) {
@@ -46,26 +57,30 @@ export function atomicWriteFileSync(filePath, content) {
46
57
  }
47
58
  /**
48
59
  * Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
49
- * releases the lock. Retries up to LOCK_RETRIES times with linear back-off.
50
- * Breaks stale locks older than LOCK_STALE_MS.
60
+ * releases the lock. Retries with capped linear back-off until either the lock
61
+ * is acquired or LOCK_ACQUIRE_TIMEOUT_MS elapses. Breaks stale locks older than
62
+ * LOCK_STALE_MS, so a crashed holder never blocks past the stale window.
51
63
  */
52
64
  export function withFileLock(filePath, fn) {
53
65
  let release = null;
54
66
  let lastError;
55
- for (let attempt = 0; attempt <= LOCK_RETRIES; attempt++) {
67
+ const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
68
+ for (let attempt = 0;; attempt++) {
56
69
  try {
57
70
  release = lockfile.lockSync(filePath, { stale: LOCK_STALE_MS });
58
71
  break;
59
72
  }
60
73
  catch (err) {
61
74
  lastError = err;
62
- if (attempt < LOCK_RETRIES)
63
- sleepSync(50 * (attempt + 1));
75
+ if (Date.now() >= deadline)
76
+ break;
77
+ const backoff = Math.min(LOCK_RETRY_MIN_MS * (attempt + 1), LOCK_RETRY_MAX_MS);
78
+ sleepSync(Math.min(backoff, Math.max(0, deadline - Date.now())));
64
79
  }
65
80
  }
66
81
  if (!release) {
67
82
  const message = lastError instanceof Error ? lastError.message : String(lastError);
68
- throw new Error(`Could not acquire lock for ${filePath}: ${message}`);
83
+ throw new Error(`Could not acquire lock for ${filePath} after ${LOCK_ACQUIRE_TIMEOUT_MS}ms: ${message}`);
69
84
  }
70
85
  try {
71
86
  return fn();
package/dist/lib/hooks.js CHANGED
@@ -15,7 +15,8 @@ import * as TOML from 'smol-toml';
15
15
  import { AGENTS, agentConfigDirName } from './agents.js';
16
16
  import { supports, explainSkip, capableAgents } from './capabilities.js';
17
17
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
18
- import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos } from './state.js';
18
+ import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir } from './state.js';
19
+ import { collectSubruleHooksFromState } from './rules/compose.js';
19
20
  function getCentralHooksDir() { return getUserHooksDir(); }
20
21
  /**
21
22
  * Resolve a hook script's absolute path. Checks user dir first, then enabled
@@ -52,6 +53,12 @@ function getManagedHookPrefixes() {
52
53
  path.join(getUserAgentsDir(), 'hooks') + path.sep,
53
54
  ...extraDirs.map(d => path.join(d, 'hooks') + path.sep),
54
55
  path.join(getSystemAgentsDir(), 'hooks') + path.sep,
56
+ // Subrule-dir hook scripts register by their absolute source path under a
57
+ // rules `subrules/` tree. Cover those trees so a removed subrule/hook's
58
+ // stale settings entry gets garbage-collected like any other managed hook.
59
+ path.join(getUserRulesDir(), 'subrules') + path.sep,
60
+ ...extraDirs.map(d => path.join(d, 'rules', 'subrules') + path.sep),
61
+ path.join(getResolvedRulesDir(), 'subrules') + path.sep,
55
62
  ];
56
63
  }
57
64
  /**
@@ -159,6 +166,19 @@ const SCRIPT_EXTENSIONS = new Set([
159
166
  function isExecutable(mode) {
160
167
  return (mode & 0o111) !== 0;
161
168
  }
169
+ /**
170
+ * Ensure a script file carries an exec bit. Subrule-dir hook scripts are
171
+ * registered by their source path (not copied), so they must be executable in
172
+ * place. Best-effort: a chmod failure (read-only fs, foreign owner) is ignored.
173
+ */
174
+ function ensureExecutable(scriptPath) {
175
+ try {
176
+ const mode = fs.statSync(scriptPath).mode;
177
+ if (!isExecutable(mode))
178
+ fs.chmodSync(scriptPath, mode | 0o755);
179
+ }
180
+ catch { /* best effort */ }
181
+ }
162
182
  function getHooksDir(agentId) {
163
183
  const agent = AGENTS[agentId];
164
184
  const home = getEffectiveHome(agentId);
@@ -666,6 +686,15 @@ export function parseHookManifest(opts = {}) {
666
686
  const warn = opts.warn !== false;
667
687
  const merged = {};
668
688
  const systemHooks = {};
689
+ // Lowest-precedence layer: hooks declared inside active subrule directories.
690
+ // Seeded first so any same-key entry from system/user agents.yaml wins.
691
+ // Gated so a malformed hooks.yaml never breaks rule sync.
692
+ try {
693
+ const subruleHooks = collectSubruleHooksFromState();
694
+ for (const [name, def] of Object.entries(subruleHooks))
695
+ merged[name] = def;
696
+ }
697
+ catch { /* subrule hook collection is best-effort */ }
669
698
  // System layer: hooks: section of agents.yaml (npm-shipped, separate repo).
670
699
  const systemPath = path.join(getSystemAgentsDir(), 'agents.yaml');
671
700
  if (fs.existsSync(systemPath)) {
@@ -780,6 +809,12 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
780
809
  ? path.join(versionHome, agentConfigDirName(agentId), AGENTS[agentId].hooksDir)
781
810
  : null;
782
811
  const resolveScript = (script) => {
812
+ // Subrule-dir hooks declare an already-absolute script path. Use it
813
+ // directly (made executable) — these are not copied into the version home.
814
+ if (path.isAbsolute(script) && fs.existsSync(script)) {
815
+ ensureExecutable(script);
816
+ return script;
817
+ }
783
818
  if (overrideRoots) {
784
819
  return resolveContainedHookPath(path.join(overrideRoots[0], 'hooks'), script);
785
820
  }
@@ -35,21 +35,45 @@ export declare function ensureMenubarAppInstalled(opts?: {
35
35
  export declare function enableMenubarService(opts?: {
36
36
  clearOptOut?: boolean;
37
37
  }): boolean;
38
+ /**
39
+ * Pure staleness decision (no I/O) so the truth table is unit-testable. The
40
+ * installed service is stale when the helper binary is gone, or when it was
41
+ * installed by a different CLI version than the one now running — a version
42
+ * change is the signal that the plist's baked interpreter/entry/bundle paths
43
+ * and the helper binary itself may have drifted. A null installedVersion
44
+ * (pre-stamp install) counts as stale so old installs get re-stamped once.
45
+ */
46
+ export declare function isMenubarStale(opts: {
47
+ installedVersion: string | null;
48
+ currentVersion: string;
49
+ execExists: boolean;
50
+ }): boolean;
38
51
  /**
39
52
  * Stop + remove the menu-bar service and write the sticky opt-out so the
40
53
  * upgrade migration won't re-enable it.
41
54
  */
42
55
  export declare function disableMenubarService(): void;
43
56
  /**
44
- * Upgrade-time auto-enable. Runs from runMigration() once per sentinel bump.
45
- * No-ops if: not darwin, the user opted out, no helper bundle ships, or the
46
- * service is already installed. Best-effort — never throws into migration.
57
+ * Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
58
+ * No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
59
+ * needed:
60
+ * - fresh install (no service yet) -> enable
61
+ * - upgrade (version stamp changed) or -> re-enable: recopy the new helper
62
+ * the App Support helper went missing binary + rewrite the plist + kick
63
+ *
64
+ * Without the staleness re-enable, `npm update` refreshed the CLI but left the
65
+ * menu bar running the previous release's helper binary on a possibly-stale
66
+ * plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
67
+ * Best-effort — never throws into startup.
47
68
  */
48
69
  export declare function installMenubarLaunchAgentOnUpgrade(): void;
49
70
  export interface MenubarStatus {
50
71
  platform: string;
51
72
  source: string | null;
52
73
  installedApp: string | null;
74
+ installedVersion: string | null;
75
+ currentVersion: string;
76
+ stale: boolean;
53
77
  serviceInstalled: boolean;
54
78
  running: boolean;
55
79
  disabledByUser: boolean;
@@ -20,15 +20,38 @@ import * as fs from 'fs';
20
20
  import * as os from 'os';
21
21
  import * as path from 'path';
22
22
  import { getRuntimeStateDir, getHelpersDir } from '../state.js';
23
+ import { getCliVersion } from '../version.js';
23
24
  const APP_BUNDLE_NAME = 'MenubarHelper.app';
24
25
  const INSTALL_DIR_NAME = 'agents-cli';
25
26
  const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
26
27
  function onDarwin() {
27
28
  return process.platform === 'darwin';
28
29
  }
30
+ /** ~/Library/Application Support/agents-cli */
31
+ function installDir() {
32
+ return path.join(os.homedir(), 'Library', 'Application Support', INSTALL_DIR_NAME);
33
+ }
29
34
  /** ~/Library/Application Support/agents-cli/MenubarHelper.app */
30
35
  function installedAppPath() {
31
- return path.join(os.homedir(), 'Library', 'Application Support', INSTALL_DIR_NAME, APP_BUNDLE_NAME);
36
+ return path.join(installDir(), APP_BUNDLE_NAME);
37
+ }
38
+ /**
39
+ * Version stamp written next to the installed bundle. The upgrade self-heal
40
+ * compares this against the running CLI's version to decide whether the App
41
+ * Support copy + plist need to be rebuilt — without it, a `npm update` refreshes
42
+ * dist/index.js but leaves the menu bar running the OLD helper binary and a
43
+ * plist whose baked paths may have drifted.
44
+ */
45
+ function installedVersionMarkerPath() {
46
+ return path.join(installDir(), '.menubar-version');
47
+ }
48
+ function readInstalledMenubarVersion() {
49
+ try {
50
+ return fs.readFileSync(installedVersionMarkerPath(), 'utf-8').trim() || null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
32
55
  }
33
56
  /** Executable inside the installed bundle. */
34
57
  function installedExecutablePath() {
@@ -222,8 +245,34 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
222
245
  execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
223
246
  }
224
247
  catch { /* best effort */ }
248
+ // Stamp the version we just installed so the upgrade self-heal can tell when
249
+ // a later release ships a newer helper that needs reinstalling.
250
+ try {
251
+ fs.writeFileSync(installedVersionMarkerPath(), getCliVersion());
252
+ }
253
+ catch { /* best effort */ }
225
254
  return true;
226
255
  }
256
+ /**
257
+ * Pure staleness decision (no I/O) so the truth table is unit-testable. The
258
+ * installed service is stale when the helper binary is gone, or when it was
259
+ * installed by a different CLI version than the one now running — a version
260
+ * change is the signal that the plist's baked interpreter/entry/bundle paths
261
+ * and the helper binary itself may have drifted. A null installedVersion
262
+ * (pre-stamp install) counts as stale so old installs get re-stamped once.
263
+ */
264
+ export function isMenubarStale(opts) {
265
+ if (!opts.execExists)
266
+ return true;
267
+ return opts.installedVersion !== opts.currentVersion;
268
+ }
269
+ function menubarSetupStale() {
270
+ return isMenubarStale({
271
+ installedVersion: readInstalledMenubarVersion(),
272
+ currentVersion: getCliVersion(),
273
+ execExists: fs.existsSync(installedExecutablePath()),
274
+ });
275
+ }
227
276
  /**
228
277
  * Stop + remove the menu-bar service and write the sticky opt-out so the
229
278
  * upgrade migration won't re-enable it.
@@ -253,9 +302,17 @@ export function disableMenubarService() {
253
302
  catch { /* best effort */ }
254
303
  }
255
304
  /**
256
- * Upgrade-time auto-enable. Runs from runMigration() once per sentinel bump.
257
- * No-ops if: not darwin, the user opted out, no helper bundle ships, or the
258
- * service is already installed. Best-effort — never throws into migration.
305
+ * Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
306
+ * No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
307
+ * needed:
308
+ * - fresh install (no service yet) -> enable
309
+ * - upgrade (version stamp changed) or -> re-enable: recopy the new helper
310
+ * the App Support helper went missing binary + rewrite the plist + kick
311
+ *
312
+ * Without the staleness re-enable, `npm update` refreshed the CLI but left the
313
+ * menu bar running the previous release's helper binary on a possibly-stale
314
+ * plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
315
+ * Best-effort — never throws into startup.
259
316
  */
260
317
  export function installMenubarLaunchAgentOnUpgrade() {
261
318
  try {
@@ -263,14 +320,18 @@ export function installMenubarLaunchAgentOnUpgrade() {
263
320
  return;
264
321
  if (menubarDisabledByUser())
265
322
  return;
266
- if (menubarServiceInstalled())
267
- return;
268
323
  if (!sourceAppPath())
269
324
  return;
270
- enableMenubarService({ clearOptOut: false });
325
+ if (!menubarServiceInstalled()) {
326
+ enableMenubarService({ clearOptOut: false });
327
+ return;
328
+ }
329
+ if (menubarSetupStale()) {
330
+ enableMenubarService({ clearOptOut: false });
331
+ }
271
332
  }
272
333
  catch {
273
- /* never block migration on the menu bar */
334
+ /* never block startup on the menu bar */
274
335
  }
275
336
  }
276
337
  export function getMenubarStatus() {
@@ -280,11 +341,15 @@ export function getMenubarStatus() {
280
341
  const r = spawnSync('pgrep', ['-f', 'MenubarHelper'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
281
342
  running = r.status === 0 && (r.stdout || '').trim().length > 0;
282
343
  }
344
+ const serviceInstalled = menubarServiceInstalled();
283
345
  return {
284
346
  platform: process.platform,
285
347
  source: sourceAppPath(),
286
348
  installedApp: fs.existsSync(dest) ? dest : null,
287
- serviceInstalled: menubarServiceInstalled(),
349
+ installedVersion: readInstalledMenubarVersion(),
350
+ currentVersion: getCliVersion(),
351
+ stale: onDarwin() && serviceInstalled && menubarSetupStale(),
352
+ serviceInstalled,
288
353
  running,
289
354
  disabledByUser: menubarDisabledByUser(),
290
355
  };
@@ -22,8 +22,11 @@ export interface RulesLayerDir {
22
22
  dir: string;
23
23
  }
24
24
  /**
25
- * List subrule markdown files in a directory.
26
- * Returns names without the .md extension.
25
+ * List subrule names in a directory.
26
+ *
27
+ * A name comes from either the flat form `<name>.md` OR the dir form
28
+ * `<name>/rule.md`; a directory without `rule.md` is not a subrule. Returns
29
+ * names without the .md extension.
27
30
  */
28
31
  export declare function listSubrulesInDir(subrulesDir: string): string[];
29
32
  /**
@@ -14,19 +14,46 @@ import * as path from 'path';
14
14
  import { getSystemRulesDir, getUserRulesDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../state.js';
15
15
  const SUBRULES_DIR = 'subrules';
16
16
  const SUBRULES_README = 'README.md';
17
+ /** Inside a dir-form subrule, the prose file. */
18
+ const SUBRULE_RULE_FILE = 'rule.md';
17
19
  /**
18
- * List subrule markdown files in a directory.
19
- * Returns names without the .md extension.
20
+ * Resolve the prose file path for a subrule named `name` under `subrulesDir`.
21
+ *
22
+ * Dir form `<name>/rule.md` wins when present; otherwise the flat `<name>.md`.
23
+ * Returns null when neither exists.
24
+ */
25
+ function resolveSubruleFile(subrulesDir, name) {
26
+ const dirForm = path.join(subrulesDir, name, SUBRULE_RULE_FILE);
27
+ if (fs.existsSync(dirForm))
28
+ return dirForm;
29
+ const flatForm = path.join(subrulesDir, `${name}.md`);
30
+ if (fs.existsSync(flatForm))
31
+ return flatForm;
32
+ return null;
33
+ }
34
+ /**
35
+ * List subrule names in a directory.
36
+ *
37
+ * A name comes from either the flat form `<name>.md` OR the dir form
38
+ * `<name>/rule.md`; a directory without `rule.md` is not a subrule. Returns
39
+ * names without the .md extension.
20
40
  */
21
41
  export function listSubrulesInDir(subrulesDir) {
22
42
  if (!fs.existsSync(subrulesDir))
23
43
  return [];
24
44
  try {
25
- return fs
26
- .readdirSync(subrulesDir)
27
- .filter((f) => f.endsWith('.md') && f !== SUBRULES_README)
28
- .map((f) => f.slice(0, -3))
29
- .sort();
45
+ const names = new Set();
46
+ for (const entry of fs.readdirSync(subrulesDir, { withFileTypes: true })) {
47
+ if (entry.isDirectory()) {
48
+ if (fs.existsSync(path.join(subrulesDir, entry.name, SUBRULE_RULE_FILE))) {
49
+ names.add(entry.name);
50
+ }
51
+ }
52
+ else if (entry.name.endsWith('.md') && entry.name !== SUBRULES_README) {
53
+ names.add(entry.name.slice(0, -3));
54
+ }
55
+ }
56
+ return [...names].sort();
30
57
  }
31
58
  catch {
32
59
  return [];
@@ -77,7 +104,9 @@ export function listAllRules(layers) {
77
104
  if (seen.has(name))
78
105
  continue;
79
106
  seen.add(name);
80
- const filePath = path.join(subrulesDir, `${name}.md`);
107
+ const filePath = resolveSubruleFile(subrulesDir, name);
108
+ if (!filePath)
109
+ continue;
81
110
  let content = '';
82
111
  try {
83
112
  content = fs.readFileSync(filePath, 'utf-8');
@@ -101,8 +130,8 @@ export function listAllRules(layers) {
101
130
  */
102
131
  export function resolveRule(name, layers) {
103
132
  for (const { layer, dir } of layers) {
104
- const filePath = path.join(dir, SUBRULES_DIR, `${name}.md`);
105
- if (fs.existsSync(filePath)) {
133
+ const filePath = resolveSubruleFile(path.join(dir, SUBRULES_DIR), name);
134
+ if (filePath) {
106
135
  let content = '';
107
136
  try {
108
137
  content = fs.readFileSync(filePath, 'utf-8');
@@ -18,6 +18,7 @@
18
18
  * No filesystem writes happen here — callers (`syncResourcesToVersion`,
19
19
  * project-rules compile) decide where to land the composed output.
20
20
  */
21
+ import type { ManifestHook } from '../types.js';
21
22
  export type LayerScope = 'project' | 'user' | 'extra' | 'system';
22
23
  export interface RulesLayer {
23
24
  scope: LayerScope;
@@ -43,6 +44,8 @@ export interface ComposedSubrule {
43
44
  sourcePath: string;
44
45
  layerScope: LayerScope;
45
46
  layerAlias?: string;
47
+ /** Set when the subrule is dir-form (`subrules/<name>/`); the dir itself. */
48
+ subruleDir?: string;
46
49
  }
47
50
  export interface ComposeResult {
48
51
  /** Fully concatenated, no @-imports. */
@@ -76,3 +79,22 @@ export declare function composeRulesFromState(opts?: {
76
79
  preset?: string;
77
80
  cwd?: string;
78
81
  }): ComposeResult;
82
+ /**
83
+ * Collect hooks declared inside the active subrule directories.
84
+ *
85
+ * Resolves the same composed subrule set as {@link composeRules} (preset-named
86
+ * plus auto-append, highest-layer-wins per name). For each dir-form subrule
87
+ * that ships a `hooks.yaml`, parses it, rewrites each hook's `script` to an
88
+ * ABSOLUTE path under the subrule dir, and namespaces the key as
89
+ * `<subruleName>__<hookName>` to avoid collisions across subrules.
90
+ *
91
+ * Returns an empty map for flat subrules and dir-form subrules without a
92
+ * `hooks.yaml`. A malformed hooks.yaml is skipped (try/catch) so a bad file
93
+ * never breaks rule composition or hook registration.
94
+ */
95
+ export declare function collectSubruleHooks(layers: RulesLayer[], presetName?: string): Record<string, ManifestHook>;
96
+ /** Convenience wrapper — discovers layers from state, then collects hooks. */
97
+ export declare function collectSubruleHooksFromState(opts?: {
98
+ preset?: string;
99
+ cwd?: string;
100
+ }): Record<string, ManifestHook>;
@@ -26,6 +26,28 @@ const SUBRULES_DIR_NAME = 'subrules';
26
26
  const RULES_YAML_NAME = 'rules.yaml';
27
27
  const DEFAULT_PRESET = 'default';
28
28
  const SUBRULES_README = 'README.md';
29
+ /** Inside a dir-form subrule, the prose file. */
30
+ const SUBRULE_RULE_FILE = 'rule.md';
31
+ /** Inside a dir-form subrule, the optional hook manifest. */
32
+ const SUBRULE_HOOKS_FILE = 'hooks.yaml';
33
+ /**
34
+ * Resolve the prose file for a subrule named `name` under `<rulesDir>/subrules/`.
35
+ *
36
+ * A subrule resolves to the DIRECTORY form `subrules/<name>/rule.md` when that
37
+ * file exists, otherwise the flat form `subrules/<name>.md`. Returns the
38
+ * markdown path plus the dir-form subrule directory when applicable (callers
39
+ * that fold hooks need the dir to resolve `hooks.yaml` and relative scripts).
40
+ */
41
+ function resolveSubrulePath(rulesDir, name) {
42
+ const dirForm = path.join(rulesDir, SUBRULES_DIR_NAME, name, SUBRULE_RULE_FILE);
43
+ if (fs.existsSync(dirForm)) {
44
+ return { sourcePath: dirForm, subruleDir: path.join(rulesDir, SUBRULES_DIR_NAME, name) };
45
+ }
46
+ const flatForm = path.join(rulesDir, SUBRULES_DIR_NAME, `${name}.md`);
47
+ if (fs.existsSync(flatForm))
48
+ return { sourcePath: flatForm };
49
+ return null;
50
+ }
29
51
  function readRulesYaml(rulesDir) {
30
52
  const p = path.join(rulesDir, RULES_YAML_NAME);
31
53
  if (!fs.existsSync(p))
@@ -51,22 +73,33 @@ function resolvePreset(layers, preset) {
51
73
  }
52
74
  function findSubrule(layers, name) {
53
75
  for (const layer of layers) {
54
- const p = path.join(layer.rulesDir, SUBRULES_DIR_NAME, `${name}.md`);
55
- if (fs.existsSync(p))
56
- return { sourcePath: p, layer };
76
+ const found = resolveSubrulePath(layer.rulesDir, name);
77
+ if (found)
78
+ return { ...found, layer };
57
79
  }
58
80
  return null;
59
81
  }
82
+ /**
83
+ * List subrule names in a layer. A name is contributed by either the flat
84
+ * form `subrules/<name>.md` OR the dir form `subrules/<name>/rule.md`; a
85
+ * directory without `rule.md` is not a subrule and is skipped.
86
+ */
60
87
  function listLayerSubruleNames(layer) {
61
88
  const dir = path.join(layer.rulesDir, SUBRULES_DIR_NAME);
62
89
  if (!fs.existsSync(dir))
63
90
  return [];
64
91
  try {
65
- return fs
66
- .readdirSync(dir)
67
- .filter((f) => f.endsWith('.md') && f !== SUBRULES_README)
68
- .map((f) => f.slice(0, -3))
69
- .sort();
92
+ const names = new Set();
93
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
94
+ if (entry.isDirectory()) {
95
+ if (fs.existsSync(path.join(dir, entry.name, SUBRULE_RULE_FILE)))
96
+ names.add(entry.name);
97
+ }
98
+ else if (entry.name.endsWith('.md') && entry.name !== SUBRULES_README) {
99
+ names.add(entry.name.slice(0, -3));
100
+ }
101
+ }
102
+ return [...names].sort();
70
103
  }
71
104
  catch {
72
105
  return [];
@@ -98,6 +131,7 @@ export function composeRules(opts) {
98
131
  sourcePath: found.sourcePath,
99
132
  layerScope: found.layer.scope,
100
133
  layerAlias: found.layer.alias,
134
+ subruleDir: found.subruleDir,
101
135
  });
102
136
  seen.add(name);
103
137
  }
@@ -109,11 +143,15 @@ export function composeRules(opts) {
109
143
  for (const name of listLayerSubruleNames(layer)) {
110
144
  if (seen.has(name))
111
145
  continue;
146
+ const resolved = resolveSubrulePath(layer.rulesDir, name);
147
+ if (!resolved)
148
+ continue;
112
149
  composed.push({
113
150
  name,
114
- sourcePath: path.join(layer.rulesDir, SUBRULES_DIR_NAME, `${name}.md`),
151
+ sourcePath: resolved.sourcePath,
115
152
  layerScope: layer.scope,
116
153
  layerAlias: layer.alias,
154
+ subruleDir: resolved.subruleDir,
117
155
  });
118
156
  seen.add(name);
119
157
  }
@@ -168,3 +206,70 @@ export function composeRulesFromState(opts = {}) {
168
206
  const layers = discoverRulesLayers({ cwd: opts.cwd });
169
207
  return composeRules({ preset: opts.preset, layers });
170
208
  }
209
+ /**
210
+ * hooks.yaml shape (the bare-map form, chosen for brevity):
211
+ *
212
+ * <hookName>:
213
+ * script: enforce.sh # relative to the subrule dir
214
+ * events: [PreToolUse]
215
+ * matcher: "Edit|Write" # optional
216
+ * timeout: 30 # optional
217
+ *
218
+ * A wrapped `{ hooks: { <hookName>: {...} } }` form is also accepted so a
219
+ * hooks.yaml can carry sibling keys without confusing the parser.
220
+ */
221
+ function parseSubruleHooksFile(file) {
222
+ const parsed = yaml.parse(fs.readFileSync(file, 'utf-8'));
223
+ if (!parsed || typeof parsed !== 'object')
224
+ return {};
225
+ const map = parsed.hooks ?? parsed;
226
+ return map || {};
227
+ }
228
+ /**
229
+ * Collect hooks declared inside the active subrule directories.
230
+ *
231
+ * Resolves the same composed subrule set as {@link composeRules} (preset-named
232
+ * plus auto-append, highest-layer-wins per name). For each dir-form subrule
233
+ * that ships a `hooks.yaml`, parses it, rewrites each hook's `script` to an
234
+ * ABSOLUTE path under the subrule dir, and namespaces the key as
235
+ * `<subruleName>__<hookName>` to avoid collisions across subrules.
236
+ *
237
+ * Returns an empty map for flat subrules and dir-form subrules without a
238
+ * `hooks.yaml`. A malformed hooks.yaml is skipped (try/catch) so a bad file
239
+ * never breaks rule composition or hook registration.
240
+ */
241
+ export function collectSubruleHooks(layers, presetName) {
242
+ const result = {};
243
+ let composed;
244
+ try {
245
+ composed = composeRules({ preset: presetName, layers });
246
+ }
247
+ catch {
248
+ return result;
249
+ }
250
+ for (const sub of composed.subrules) {
251
+ if (!sub.subruleDir)
252
+ continue; // flat subrule — no hooks
253
+ const hooksFile = path.join(sub.subruleDir, SUBRULE_HOOKS_FILE);
254
+ if (!fs.existsSync(hooksFile))
255
+ continue;
256
+ try {
257
+ const hooks = parseSubruleHooksFile(hooksFile);
258
+ for (const [hookName, def] of Object.entries(hooks)) {
259
+ if (!def || typeof def !== 'object' || typeof def.script !== 'string')
260
+ continue;
261
+ const absScript = path.resolve(sub.subruleDir, def.script);
262
+ result[`${sub.name}__${hookName}`] = { ...def, script: absScript };
263
+ }
264
+ }
265
+ catch {
266
+ // Malformed hooks.yaml — skip this subrule's hooks, keep the rest.
267
+ }
268
+ }
269
+ return result;
270
+ }
271
+ /** Convenience wrapper — discovers layers from state, then collects hooks. */
272
+ export function collectSubruleHooksFromState(opts = {}) {
273
+ const layers = discoverRulesLayers({ cwd: opts.cwd });
274
+ return collectSubruleHooks(layers, opts.preset);
275
+ }
@@ -140,8 +140,10 @@ export declare function agentGetSync(name: string): {
140
140
  bundle: SecretsBundle;
141
141
  env: Record<string, string>;
142
142
  } | null;
143
- /** True when `secrets.agent.auto` is enabled in agents.yaml. Best-effort; a
144
- * missing/unreadable meta reads as off. */
143
+ /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
144
+ * broker is the mechanism that delivers the `daily` default policy (one Touch ID
145
+ * per ~24h), so auto-caching is ON by default; opt out with
146
+ * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
145
147
  export declare function secretsAgentAutoEnabled(): boolean;
146
148
  /**
147
149
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
@@ -529,14 +529,16 @@ export function agentGetSync(name) {
529
529
  return null;
530
530
  }
531
531
  }
532
- /** True when `secrets.agent.auto` is enabled in agents.yaml. Best-effort; a
533
- * missing/unreadable meta reads as off. */
532
+ /** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
533
+ * broker is the mechanism that delivers the `daily` default policy (one Touch ID
534
+ * per ~24h), so auto-caching is ON by default; opt out with
535
+ * `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
534
536
  export function secretsAgentAutoEnabled() {
535
537
  try {
536
- return readMeta().secrets?.agent?.auto === true;
538
+ return readMeta().secrets?.agent?.auto !== false;
537
539
  }
538
540
  catch {
539
- return false;
541
+ return true;
540
542
  }
541
543
  }
542
544
  /**
@@ -40,18 +40,19 @@ export interface VarMeta {
40
40
  }
41
41
  /**
42
42
  * A bundle's prompt policy — how often macOS asks for Touch ID to read it:
43
- * - `always` (default): asks every time. Only an explicit `agents secrets
44
- * unlock` ever holds it in the secrets-agent; every other read pops Touch ID.
45
- * Use for high-value bundles you want to confirm every time.
46
- * - `daily`: ask once, then hold it silently. Eligible for the secrets-agent
47
- * `unlock` it, or (when `secrets.agent.auto` is enabled) the first real
48
- * keychain read auto-loads it so concurrent runs read it silently. Held up to
49
- * ~24h from that unlock (not refreshed on use); re-asks sooner after
50
- * screen-lock, sleep, logout, or `agents secrets lock`.
43
+ * - `daily` (default): ask once, then hold it silently for up to ~24h. Eligible
44
+ * for the secrets-agent the first real keychain read auto-loads it (auto-cache
45
+ * is on by default) so concurrent runs read it silently, or `unlock` it
46
+ * explicitly. Held from that unlock (not refreshed on use); re-asks sooner
47
+ * after screen-lock, sleep, logout, or `agents secrets lock`.
48
+ * - `always`: asks every time. Never auto-held only an explicit `agents
49
+ * secrets unlock` ever holds it; every other read pops Touch ID. Opt a
50
+ * high-value bundle into this when you want to confirm every single read.
51
51
  *
52
- * Stored on disk under the legacy `tier` key (`session` == `daily`; absent ==
53
- * `always`) so bundles stay readable across mixed CLI versions on synced
54
- * machines. The in-memory and user-facing vocabulary is `policy`/`always`/`daily`.
52
+ * The default is configurable via `secrets.policy` in agents.yaml. Stored on disk
53
+ * under the legacy `tier` key (`session` == `daily`, `biometry` == explicit
54
+ * `always`, absent == inherit the default) so bundles stay readable across mixed
55
+ * CLI versions on synced machines. The user-facing vocabulary is `policy`/`always`/`daily`.
55
56
  */
56
57
  export type SecretsPolicy = 'always' | 'daily';
57
58
  /** A named set of environment variable definitions backed by various secret providers. */
@@ -61,8 +62,8 @@ export interface SecretsBundle {
61
62
  allow_exec?: boolean;
62
63
  /** Which store carries this bundle's items. Absent ⇒ `keychain` (the default). */
63
64
  backend?: SecretsBackend;
64
- /** Prompt policy. Absent ⇒ `always` (the safe default). Serialized under the
65
- * legacy `tier` key — see SecretsPolicy. */
65
+ /** Prompt policy. Absent ⇒ the configured default (`daily`). Serialized under
66
+ * the legacy `tier` key — see SecretsPolicy. */
66
67
  policy?: SecretsPolicy;
67
68
  /** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
68
69
  created_at?: string;
@@ -97,7 +98,12 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
97
98
  export declare function validateExpiresFutureDated(iso: string): void;
98
99
  export declare function bundleExists(name: string): boolean;
99
100
  export declare function readBundle(name: string): SecretsBundle;
100
- /** The effective prompt policy of a bundle (absent `always`). */
101
+ /** The default prompt policy applied to bundles without an explicit per-bundle
102
+ * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
103
+ * ID per ~24h) unless the user explicitly opts back into prompt-every-time with
104
+ * `always`. Best-effort: an unreadable config falls back to the `daily` default. */
105
+ export declare function secretsDefaultPolicy(): SecretsPolicy;
106
+ /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
101
107
  export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
102
108
  export declare function writeBundle(bundle: SecretsBundle): void;
103
109
  export declare function deleteBundle(name: string): boolean;
@@ -24,6 +24,7 @@ import * as yaml from 'yaml';
24
24
  import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
25
25
  import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
+ import { readMeta } from '../state.js';
27
28
  import { agentGetSync, agentAutoLoadSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
28
29
  const keychainStore = {
29
30
  has: hasKeychainToken,
@@ -244,16 +245,34 @@ export function readBundle(name) {
244
245
  }
245
246
  return bundle;
246
247
  }
247
- /** Normalize the persisted prompt policy. The on-disk `tier` key uses the
248
- * legacy `session` token for `daily` (and `biometry`/absent for the default),
249
- * so accept both the legacy and current tokens. Anything but `daily`/`session`
250
- * undefined (resolves to the `always` default). */
248
+ /** Normalize the persisted prompt policy. The on-disk `tier` key uses legacy
249
+ * tokens for cross-version compatibility: `session` `daily`, `biometry` an
250
+ * explicit `always`. An absent token undefined, which resolves to the
251
+ * configured default policy (`daily`). Persisting an explicit `always` as the
252
+ * legacy `biometry` token keeps older CLIs correct — they don't know `daily`,
253
+ * read `biometry` as undefined, and fall back to their own always default. */
251
254
  function parsePolicy(raw) {
252
- return raw === 'daily' || raw === 'session' ? 'daily' : undefined;
255
+ if (raw === 'daily' || raw === 'session')
256
+ return 'daily';
257
+ if (raw === 'always' || raw === 'biometry')
258
+ return 'always';
259
+ return undefined;
253
260
  }
254
- /** The effective prompt policy of a bundle (absent `always`). */
261
+ /** The default prompt policy applied to bundles without an explicit per-bundle
262
+ * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
263
+ * ID per ~24h) unless the user explicitly opts back into prompt-every-time with
264
+ * `always`. Best-effort: an unreadable config falls back to the `daily` default. */
265
+ export function secretsDefaultPolicy() {
266
+ try {
267
+ return readMeta().secrets?.policy === 'always' ? 'always' : 'daily';
268
+ }
269
+ catch {
270
+ return 'daily';
271
+ }
272
+ }
273
+ /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
255
274
  export function bundlePolicy(bundle) {
256
- return bundle.policy ?? 'always';
275
+ return bundle.policy ?? secretsDefaultPolicy();
257
276
  }
258
277
  export function writeBundle(bundle) {
259
278
  validateBundleName(bundle.name);
@@ -292,9 +311,11 @@ export function writeBundle(bundle) {
292
311
  description: bundle.description,
293
312
  allow_exec: bundle.allow_exec ? true : undefined,
294
313
  backend: backend === 'file' ? 'file' : undefined,
295
- // Wire format: persist `daily` under the legacy `tier`/`session` token so
296
- // older CLI versions on other synced machines keep reading the policy.
297
- tier: bundle.policy === 'daily' ? 'session' : undefined,
314
+ // Wire format: persist the policy under the legacy `tier` token so older CLI
315
+ // versions on other synced machines keep reading it — `daily`⇒`session`,
316
+ // explicit `always`⇒`biometry`. An absent policy omits the token entirely
317
+ // and resolves to the configured default (`daily`) on read.
318
+ tier: bundle.policy === 'daily' ? 'session' : bundle.policy === 'always' ? 'biometry' : undefined,
298
319
  created_at: bundle.created_at,
299
320
  updated_at: bundle.updated_at,
300
321
  last_used: bundle.last_used,
@@ -54,7 +54,19 @@ function activeSources(agent, version, cwd) {
54
54
  result['rules.yaml'] = yamlPath;
55
55
  }
56
56
  for (const sub of compose.subrules) {
57
- result[`subrules/${sub.name}.md`] = sub.sourcePath;
57
+ // Key off the actual source path so dir-form subrules (subrules/<name>/rule.md)
58
+ // and flat ones (subrules/<name>.md) both fingerprint the file that really
59
+ // contributes to the output, not a hard-coded `.md` that may not exist.
60
+ if (sub.subruleDir) {
61
+ result[`subrules/${sub.name}/rule.md`] = sub.sourcePath;
62
+ // Fingerprint hooks.yaml too so editing a hook re-syncs the rules section.
63
+ const hooksFile = path.join(sub.subruleDir, 'hooks.yaml');
64
+ if (fs.existsSync(hooksFile))
65
+ result[`subrules/${sub.name}/hooks.yaml`] = hooksFile;
66
+ }
67
+ else {
68
+ result[`subrules/${sub.name}.md`] = sub.sourcePath;
69
+ }
58
70
  }
59
71
  return result;
60
72
  }
@@ -563,9 +563,13 @@ export interface ExtraRepoConfig {
563
563
  export interface Meta {
564
564
  agents?: Partial<Record<AgentId, string>>;
565
565
  run?: RunConfig;
566
- /** macOS secrets-agent config. `auto` makes the first real keychain read of a
567
- * `session`-tier bundle populate the broker so concurrent runs read silently. */
566
+ /** macOS secrets-agent config. `policy` is the default prompt policy for
567
+ * bundles without an explicit per-bundle policy: `daily` (the default) asks
568
+ * once per ~24h, `always` asks every time. `auto` (default on) lets the first
569
+ * real keychain read of a `daily` bundle populate the broker so concurrent
570
+ * runs read silently — set it `false` to force a prompt on every read. */
568
571
  secrets?: {
572
+ policy?: 'always' | 'daily';
569
573
  agent?: {
570
574
  auto?: boolean;
571
575
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.24",
3
+ "version": "1.20.26",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",