auriga-cli 1.5.0 → 1.6.0

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/README.md CHANGED
@@ -14,7 +14,7 @@ This repo itself is a fully configured harness project. You can clone it to see
14
14
  | **Skills** | Development process skills — brainstorming, systematic-debugging, TDD, verification, planning, playwright |
15
15
  | **Recommended Skills** | Optional utility skills (e.g. `ui-ux-pro-max`) you can add on top of the workflow skills |
16
16
  | **Plugins** | Recommended Claude Code plugins — skill-creator, claude-md-management, codex |
17
- | **Hooks** | Claude Code hooks: `notify` (macOS notification), `pr-create-guard` (PostToolUse body snapshot after `gh pr create`), `pr-ready-guard` (PreToolUse block on stray planning docs / unpushed commits before `gh pr ready`) |
17
+ | **Hooks** | Claude Code hooks: `notify` (macOS notification, focus-aware sound-only when terminal is frontmost), `pr-create-guard` (PostToolUse body snapshot after `gh pr create`), `pr-ready-guard` (PreToolUse block on stray planning docs / active specs in `docs/specs/` / unpushed commits before `gh pr ready`) |
18
18
 
19
19
  ## Quick Start
20
20
 
@@ -79,9 +79,9 @@ Installs Claude Code hooks into a chosen scope. Each hook is self-contained unde
79
79
 
80
80
  | Hook | Description |
81
81
  |---|---|
82
- | notify | Native macOS notification when Claude needs your attention. Shows the brand mark in the small app-icon position, with click-to-activate that brings the originating terminal back to the foreground. Auto-installs `alerter` via Homebrew (`vjeantet/tap/alerter`). Customize sound and icon by editing `.claude/hooks/notify/config.json` and replacing `.claude/hooks/notify/icon.png`. macOS-only at runtime; silent no-op on other platforms. |
82
+ | notify | Native macOS notification when Claude needs your attention. Shows the brand mark in the small app-icon position; click brings the originating terminal back to focus. **Focus-aware**: when the launching terminal is already frontmost, drops the banner and plays the sound only (toggle via `soundOnlyWhenFocused` in `config.json`). **Per-project group ID**: new notifications cleanly replace older ones in Notification Center, no process accumulation, no cross-project interference. Auto-installs `alerter` via Homebrew (`vjeantet/tap/alerter`). Customize sound and icon by editing `.claude/hooks/notify/config.json` and `.claude/hooks/notify/icon.png`. macOS-only at runtime; silent no-op on other platforms. |
83
83
  | pr-create-guard | PostToolUse hook for `gh pr create`. Queries the newly-created PR via `gh pr view` and injects a body snapshot (headings found + TODO-checkbox counts) as `additionalContext` for the Agent to self-verify against the step-10 scope / acceptance / risks / TODO contract. Never blocks — PostToolUse runs after the fact. Graceful degradation when gh is unavailable. |
84
- | pr-ready-guard | PreToolUse hook for `gh pr ready`. Blocks on structural signals only: stray planning docs at `findings.md` / `progress.md` / `task_plan.md` / `docs/superpowers/specs/*.md` (must be archived to `docs/worklog-<date>-<branch>/` per the `Document Conventions` in CLAUDE.md), or unpushed commits on the current branch. No text regex of PR content is ever used as a block signal. On pass, injects a PR body snapshot as `additionalContext`. |
84
+ | pr-ready-guard | PreToolUse hook for `gh pr ready`. Blocks on structural signals only: (1) stray planning docs at `findings.md` / `progress.md` / `task_plan.md` / `docs/superpowers/specs/*.md` must be archived to `docs/worklog/worklog-<date>-<branch>/` (or deleted) per CLAUDE.md `Document Conventions`; (2) unfinalized active specs at `docs/specs/*.md` must be promoted to `docs/architecture/`, archived, or deleted; (3) unpushed commits on the current branch. No text regex of PR content is ever used as a block signal. On pass, injects a PR body snapshot as `additionalContext`. |
85
85
 
86
86
  Scope choices:
87
87
 
package/README.zh-CN.md CHANGED
@@ -14,7 +14,7 @@
14
14
  | **Skills** | 开发流程 skills —— brainstorming、systematic-debugging、TDD、verification、planning、playwright |
15
15
  | **Recommended Skills** | 可选的工具类 skills(如 `ui-ux-pro-max`),在 workflow skills 之外按需追加 |
16
16
  | **Plugins** | 推荐的 Claude Code 插件 —— skill-creator、claude-md-management、codex |
17
- | **Hooks** | Claude Code hooks:`notify`(macOS 通知)、`pr-create-guard`(`gh pr create` 后注入 PR body 快照的 PostToolUse)、`pr-ready-guard`(`gh pr ready` 前按游离 planning 文档 / 未 push commits 拦截的 PreToolUse) |
17
+ | **Hooks** | Claude Code hooks:`notify`(macOS 通知,终端在焦点时仅放声不弹横幅)、`pr-create-guard`(`gh pr create` 后注入 PR body 快照的 PostToolUse)、`pr-ready-guard`(`gh pr ready` 前按游离 planning 文档 / `docs/specs/` 内未清理的 spec / 未 push commits 拦截的 PreToolUse) |
18
18
 
19
19
  ## 快速开始
20
20
 
@@ -79,9 +79,9 @@ npx auriga-cli
79
79
 
80
80
  | Hook | 说明 |
81
81
  |---|---|
82
- | notify | 当 Claude 需要你关注时弹一条原生 macOS 通知。在通知小图标位显示品牌图,点击通知可把发起 Claude 的终端拉回前台。会自动通过 Homebrew 安装 `alerter`(`vjeantet/tap/alerter`)。改 `.claude/hooks/notify/config.json` 即可换提示音、替换 `.claude/hooks/notify/icon.png` 即可换图标。仅 macOS 运行时生效,其它平台静默 no-op。 |
82
+ | notify | 当 Claude 需要你关注时弹一条原生 macOS 通知。在通知小图标位显示品牌图,点击通知可把发起 Claude 的终端拉回前台。**焦点感知**:发起 Claude 的终端正处于前台时,仅放提示音不弹横幅(通过 `config.json` 的 `soundOnlyWhenFocused` 切换)。**按项目分组**:新通知会干净地替换通知中心里的旧条目,不会进程堆积,也不会跨项目互相覆盖。会自动通过 Homebrew 安装 `alerter`(`vjeantet/tap/alerter`)。改 `.claude/hooks/notify/config.json` 即可换提示音、替换 `.claude/hooks/notify/icon.png` 即可换图标。仅 macOS 运行时生效,其它平台静默 no-op。 |
83
83
  | pr-create-guard | `gh pr create` 的 PostToolUse hook。创建成功后通过 `gh pr view` 拉真实 PR body,扫 `^##` / `^###` headings 并统计 `- [ ]` / `- [x]`,通过 `additionalContext` 注入快照让 Agent 对照 step 10 的"范围 / 验收标准 / 风险 / 剩余 TODO"四要素。不 block——PostToolUse 发生在动作之后。gh 不可用时静默降级。 |
84
- | pr-ready-guard | `gh pr ready` 的 PreToolUse hook。**只按结构信号**拦截:仓库根存在游离 planning 文档(`findings.md` / `progress.md` / `task_plan.md`),或 `docs/superpowers/specs/*.md` 未归档——这些必须按 CLAUDE.md 的"文档规范"迁到 `docs/worklog-<date>-<branch>/`;或者本地有未 push commits。**不做 PR 正文文本 regex 匹配**。放行时注入 PR body 快照。 |
84
+ | pr-ready-guard | `gh pr ready` 的 PreToolUse hook。**只按结构信号**拦截:(1) 仓库根存在游离 planning 文档(`findings.md` / `progress.md` / `task_plan.md`)或 `docs/superpowers/specs/*.md` 未归档——按 CLAUDE.md 的"文档规范"迁到 `docs/worklog/worklog-<date>-<branch>/` 或删除;(2) `docs/specs/*.md` 内有未结案的活跃 spec——晋升到 `docs/architecture/`、归档或删除;(3) 本地有未 push commits。**不做 PR 正文文本 regex 匹配**。放行时注入 PR body 快照。 |
85
85
 
86
86
  作用域选择:
87
87
 
package/dist/hooks.d.ts CHANGED
@@ -5,7 +5,17 @@ export interface HookDep {
5
5
  }
6
6
  export interface HookSettingsEvent {
7
7
  event: string;
8
+ /** Tool-name / regex filter; mapped onto the container-level
9
+ * `matcher` field in settings.json. Absent → every tool fires. */
8
10
  matcher?: string;
11
+ /** Permission-rule-syntax filter — mapped onto the nested action-level
12
+ * `if` field in settings.json. Format: `<ToolName>(<substring>)`, e.g.
13
+ * `Bash(gh pr create)` — see IF_RE for the exact grammar. Lets the
14
+ * Claude Code runtime skip hook dispatch when the tool input doesn't
15
+ * match, avoiding a Node subprocess spawn per unrelated call.
16
+ * Absent → no registry-level content filter (the hook script runs
17
+ * for every invocation that passed `matcher`). */
18
+ if?: string;
9
19
  }
10
20
  export interface HookDef {
11
21
  name: string;
@@ -32,6 +42,10 @@ export interface SettingsHookAction {
32
42
  type: "command";
33
43
  command: string;
34
44
  _marker?: string;
45
+ /** Per-action permission-rule filter (Claude Code ≥ 2026-04 schema).
46
+ * Format: `<ToolName>(<substring>)`. Older runtimes ignore unknown
47
+ * fields, so emitting this is forward-safe. */
48
+ if?: string;
35
49
  }
36
50
  export interface SettingsHookGroup {
37
51
  matcher?: string;
@@ -52,12 +66,33 @@ export interface SettingsFile {
52
66
  * and never wrote our marker. Without this fallback we would happily
53
67
  * append a duplicate next to it and the hook would fire twice.
54
68
  *
69
+ * `options.matcher` writes to the container-level `matcher` (tool-name
70
+ * filter); `options.ifRule` writes to the action-level `if` (permission-
71
+ * rule substring filter, Claude Code ≥ 2026-04). Either or both may be
72
+ * absent.
73
+ *
74
+ * Upgrade path: if an entry with our marker already exists but its
75
+ * matcher / if disagrees with the desired values, we update those two
76
+ * fields in place (preserving everything else — command, sibling
77
+ * actions, the user's other groups — untouched). This is the path for
78
+ * a user who installed an older registry version and re-runs the
79
+ * installer after hooks.json changed. Pure no-op when the existing
80
+ * fields already match.
81
+ *
82
+ * Inputs are defense-in-depth revalidated here against IF_RE + the
83
+ * event-name regex even though registry callers already passed
84
+ * loadHooksConfig, so a direct library caller can't write malformed
85
+ * values into settings.json by bypassing the registry loader.
86
+ *
55
87
  * Throws if `settings.hooks[event]` exists but is not an array — that
56
88
  * means the user has hand-edited their settings into a shape we do not
57
89
  * recognize, and silently replacing it with an empty array would lose
58
90
  * data. Callers should catch and surface the error to the user.
59
91
  */
60
- export declare function addHookToSettings(settings: SettingsFile, event: string, command: string, marker: string): {
92
+ export declare function addHookToSettings(settings: SettingsFile, event: string, command: string, marker: string, options?: {
93
+ matcher?: string;
94
+ ifRule?: string;
95
+ }): {
61
96
  settings: SettingsFile;
62
97
  mutated: boolean;
63
98
  };
package/dist/hooks.js CHANGED
@@ -32,6 +32,29 @@ const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
32
32
  // the form requires a code change here, intentionally — see the security
33
33
  // review trail in PR #7 for context.
34
34
  const COMMAND_RE = /^(node|python3|bash) "\$HOOK_DIR\/[A-Za-z0-9_-]+\.[A-Za-z0-9]+"$/;
35
+ // Claude Code permission-rule syntax for the `if` field:
36
+ // <ToolName>(<substring>)
37
+ // Tool name: EVENT_NAME-shape identifier, bounded to 64 chars so a
38
+ // malicious registry can't inflate settings.json with a gigantic prefix.
39
+ // Substring body: 1-200 printable-ASCII chars, with parens, backslash,
40
+ // and backtick explicitly excluded so the anchored `\(...\)` wrapper
41
+ // actually delimits a well-formed outer parenthesis pair.
42
+ //
43
+ // The safety argument is NOT that IF_RE strips shell metacharacters —
44
+ // `$ " ' ; | & < > *` are all inside the allowed byte range and left
45
+ // intact. The defense is that this string never reaches a shell: it's
46
+ // written verbatim into settings.json as a JSON string and read by
47
+ // Claude Code's in-process permission-rule matcher. A registry
48
+ // compromise can widen or misdirect the match pattern (causing the hook
49
+ // to fire on unintended inputs or not fire at all) but cannot pivot
50
+ // into command execution from this field.
51
+ //
52
+ // Body range decomposition (what the char class actually covers):
53
+ // 0x20-0x27 space through single-quote (excludes nothing)
54
+ // 0x2A-0x5B asterisk through left-bracket (excludes `(` 0x28, `)` 0x29)
55
+ // 0x5D-0x5F right-bracket through underscore (excludes `\` 0x5C)
56
+ // 0x61-0x7E lowercase through tilde (excludes `` ` `` 0x60)
57
+ const IF_RE = /^[A-Z][A-Za-z0-9_-]{0,63}\([\x20-\x27\x2A-\x5B\x5D-\x5F\x61-\x7E]{1,200}\)$/;
35
58
  function isSafeRelativePath(file) {
36
59
  if (typeof file !== "string" || file.length === 0)
37
60
  return false;
@@ -104,6 +127,10 @@ function validateHookEntry(hook, idx) {
104
127
  if (matcher !== undefined && (typeof matcher !== "string" || !EVENT_NAME_RE.test(matcher))) {
105
128
  throw new Error(`hooks.json: hooks[${idx}].settingsEvents.matcher must match ${EVENT_NAME_RE} (got ${JSON.stringify(matcher)})`);
106
129
  }
130
+ const ifRule = evt.if;
131
+ if (ifRule !== undefined && (typeof ifRule !== "string" || !IF_RE.test(ifRule))) {
132
+ throw new Error(`hooks.json: hooks[${idx}].settingsEvents.if must match ${IF_RE} (got ${JSON.stringify(ifRule)})`);
133
+ }
107
134
  }
108
135
  if (typeof h.command !== "string" || !COMMAND_RE.test(h.command)) {
109
136
  throw new Error(`hooks.json: hooks[${idx}].command must match the safe template ${COMMAND_RE} (got ${JSON.stringify(h.command)})`);
@@ -133,12 +160,40 @@ function validateHookEntry(hook, idx) {
133
160
  * and never wrote our marker. Without this fallback we would happily
134
161
  * append a duplicate next to it and the hook would fire twice.
135
162
  *
163
+ * `options.matcher` writes to the container-level `matcher` (tool-name
164
+ * filter); `options.ifRule` writes to the action-level `if` (permission-
165
+ * rule substring filter, Claude Code ≥ 2026-04). Either or both may be
166
+ * absent.
167
+ *
168
+ * Upgrade path: if an entry with our marker already exists but its
169
+ * matcher / if disagrees with the desired values, we update those two
170
+ * fields in place (preserving everything else — command, sibling
171
+ * actions, the user's other groups — untouched). This is the path for
172
+ * a user who installed an older registry version and re-runs the
173
+ * installer after hooks.json changed. Pure no-op when the existing
174
+ * fields already match.
175
+ *
176
+ * Inputs are defense-in-depth revalidated here against IF_RE + the
177
+ * event-name regex even though registry callers already passed
178
+ * loadHooksConfig, so a direct library caller can't write malformed
179
+ * values into settings.json by bypassing the registry loader.
180
+ *
136
181
  * Throws if `settings.hooks[event]` exists but is not an array — that
137
182
  * means the user has hand-edited their settings into a shape we do not
138
183
  * recognize, and silently replacing it with an empty array would lose
139
184
  * data. Callers should catch and surface the error to the user.
140
185
  */
141
- export function addHookToSettings(settings, event, command, marker) {
186
+ export function addHookToSettings(settings, event, command, marker, options = {}) {
187
+ // Defense-in-depth: registry callers pre-validate via loadHooksConfig,
188
+ // but a direct programmatic caller could bypass that. Refuse values
189
+ // that wouldn't have cleared the registry validator, so settings.json
190
+ // can never receive a malformed string through this function.
191
+ if (options.matcher !== undefined && !EVENT_NAME_RE.test(options.matcher)) {
192
+ throw new Error(`addHookToSettings: options.matcher must match ${EVENT_NAME_RE} (got ${JSON.stringify(options.matcher)})`);
193
+ }
194
+ if (options.ifRule !== undefined && !IF_RE.test(options.ifRule)) {
195
+ throw new Error(`addHookToSettings: options.ifRule must match ${IF_RE} (got ${JSON.stringify(options.ifRule)})`);
196
+ }
142
197
  const next = JSON.parse(JSON.stringify(settings ?? {}));
143
198
  if (next.hooks !== undefined && (typeof next.hooks !== "object" || Array.isArray(next.hooks))) {
144
199
  throw new Error(`settings.hooks exists but is not an object; refusing to clobber it`);
@@ -157,8 +212,21 @@ export function addHookToSettings(settings, event, command, marker) {
157
212
  if (!action)
158
213
  continue;
159
214
  if (action._marker === marker) {
215
+ // Our entry already exists. Upgrade matcher / if in place if
216
+ // they drifted from the desired values; leave command + other
217
+ // fields alone (users may have hand-tweaked; we only own the
218
+ // two fields registry declares).
219
+ let drifted = false;
220
+ if (options.matcher !== undefined && group.matcher !== options.matcher) {
221
+ group.matcher = options.matcher;
222
+ drifted = true;
223
+ }
224
+ if (options.ifRule !== undefined && action.if !== options.ifRule) {
225
+ action.if = options.ifRule;
226
+ drifted = true;
227
+ }
160
228
  next.hooks[event] = list;
161
- return { settings: next, mutated: false };
229
+ return { settings: next, mutated: drifted };
162
230
  }
163
231
  if (action.type === "command" && action.command === command) {
164
232
  // A pre-existing entry (manual or from another tool) already
@@ -170,9 +238,13 @@ export function addHookToSettings(settings, event, command, marker) {
170
238
  }
171
239
  }
172
240
  }
173
- list.push({
174
- hooks: [{ type: "command", command, _marker: marker }],
175
- });
241
+ const action = { type: "command", command, _marker: marker };
242
+ if (options.ifRule !== undefined)
243
+ action.if = options.ifRule;
244
+ const group = { hooks: [action] };
245
+ if (options.matcher !== undefined)
246
+ group.matcher = options.matcher;
247
+ list.push(group);
176
248
  next.hooks[event] = list;
177
249
  return { settings: next, mutated: true };
178
250
  }
@@ -434,7 +506,10 @@ function writeMergedSettings(resolved, hook, parsed) {
434
506
  let next = parsed;
435
507
  for (const evt of hook.settingsEvents) {
436
508
  const cmd = hook.command.replace(/\$HOOK_DIR/g, resolved.commandHookDir);
437
- const result = addHookToSettings(next, evt.event, cmd, hook.marker);
509
+ const result = addHookToSettings(next, evt.event, cmd, hook.marker, {
510
+ matcher: evt.matcher,
511
+ ifRule: evt.if,
512
+ });
438
513
  if (result.mutated)
439
514
  mutated = true;
440
515
  next = result.settings;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auriga-cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Interactive CLI to install Claude Code harness modules (Workflow, Skills, Recommended Skills, Plugins, Hooks)",
5
5
  "type": "module",
6
6
  "bin": {