trackfw 6.6.0 → 6.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "6.6.0",
3
+ "version": "6.7.1",
4
4
  "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
5
5
  "keywords": [
6
6
  "cli",
@@ -85,6 +85,24 @@ function mergeClaudeHookArray(existing, matcher, command) {
85
85
  return arr
86
86
  }
87
87
 
88
+ // migrateClaudeHookCommand rewrites a legacy hook command to a new one, in place, for every entry
89
+ // matching the given matcher inside a Claude PreToolUse/PostToolUse array. Used to fix
90
+ // .claude/settings.json files already written by an older trackfw before a command string changes
91
+ // -- without this, re-running `trackfw init`/`update` only ever appends the new (fixed) command
92
+ // alongside the stale one (merge dedup in mergeClaudeHookArray keys on the exact command string, so
93
+ // it can't tell "same guard, new path" from "a different hook"), leaving the broken entry in place
94
+ // to keep firing and failing forever.
95
+ function migrateClaudeHookCommand(existing, matcher, oldCommand, newCommand) {
96
+ const arr = Array.isArray(existing) ? existing : []
97
+ for (const item of arr) {
98
+ if (!item || item.matcher !== matcher) continue
99
+ const innerHooks = Array.isArray(item.hooks) ? item.hooks : []
100
+ for (const h of innerHooks) {
101
+ if (h && h.command === oldCommand) h.command = newCommand
102
+ }
103
+ }
104
+ }
105
+
88
106
  // ---------------------------------------------------------------------------
89
107
  // Scripts content
90
108
  // ---------------------------------------------------------------------------
@@ -174,8 +192,6 @@ elif printf '%s' "$INPUT" | grep -qE "$AWS_KEY_PATTERN"; then
174
192
  MATCH="AWS access key"
175
193
  fi
176
194
 
177
- [ -n "$MATCH" ] || exit 0
178
-
179
195
  # The raw payload is JSON: any double quote inside the underlying tool_input.command is
180
196
  # escaped as \\" -- unescape those before scanning for redirect targets, or a quoted target
181
197
  # like "$TMPFILE" is seen as starting with a literal backslash instead of a variable
@@ -206,6 +222,57 @@ is_ephemeral_target() {
206
222
 
207
223
  REDIRECTS=$(printf '%s' "$RAW" | grep -oE '[0-9]?>>?[[:space:]]*[^[:space:]|&;,:]+' || true)
208
224
 
225
+ # Second detection layer: only runs when the payload scan above found nothing -- keeps the common
226
+ # case (match already found) cheap and avoids reading files unnecessarily. Files above the size cap
227
+ # are skipped silently.
228
+ scan_file_for_pattern() {
229
+ local path size
230
+ path=$(printf '%s' "$1" | tr -d "\\"'" | sed -E 's/[},]+$//')
231
+ [ -n "$path" ] && [ -f "$path" ] || return 1
232
+ size=$(wc -c < "$path" 2>/dev/null | tr -d '[:space:]')
233
+ size=\${size:-0}
234
+ [ "$size" -lt 1048576 ] || return 1
235
+ if grep -qE "$JWT_PATTERN" "$path" 2>/dev/null; then
236
+ MATCH="JWT"
237
+ return 0
238
+ fi
239
+ if grep -qE "$AWS_KEY_PATTERN" "$path" 2>/dev/null; then
240
+ MATCH="AWS access key"
241
+ return 0
242
+ fi
243
+ return 1
244
+ }
245
+
246
+ if [ -z "$MATCH" ] && [ -n "$REDIRECTS" ]; then
247
+ while IFS= read -r line; do
248
+ if [ -z "$line" ]; then
249
+ continue
250
+ fi
251
+ target=$(printf '%s' "$line" | sed -E 's/^[0-9]?>>?[[:space:]]*//')
252
+ if ! is_ephemeral_target "$target"; then
253
+ scan_file_for_pattern "$target" && break
254
+ fi
255
+ done <<< "$REDIRECTS"
256
+ fi
257
+
258
+ if [ -z "$MATCH" ]; then
259
+ CMD_LINE=$(printf '%s' "$RAW" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
260
+ if [ -n "$CMD_LINE" ]; then
261
+ set -- $CMD_LINE
262
+ cmd_name="\${1:-}"
263
+ case "$cmd_name" in
264
+ cat|head|tail|jq|grep)
265
+ shift
266
+ for token in "$@"; do
267
+ scan_file_for_pattern "$token" && break
268
+ done
269
+ ;;
270
+ esac
271
+ fi
272
+ fi
273
+
274
+ [ -n "$MATCH" ] || exit 0
275
+
209
276
  HAS_REDIRECT=0
210
277
  EXEMPT=1
211
278
  if [ -n "$REDIRECTS" ]; then
@@ -227,10 +294,22 @@ fi
227
294
 
228
295
  `
229
296
 
230
- const CG_PROJECT_TAIL = `MODE=$(grep -A 5 '^credential_guard:' trackfw.yaml 2>/dev/null | grep 'mode:' | head -1 | sed -E 's/^[[:space:]]*mode:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d "\\"'" || true)
297
+ // Resolução de MODE (grep de `credential_guard.mode` em trackfw.yaml + fallback) é inlined,
298
+ // idêntica em CG_PROJECT_TAIL (fallback "warn") e CG_GLOBAL_TAIL (fallback "block") — ao invés de
299
+ // extrair para uma constante JS compartilhada e concatenar (como o Go faz via
300
+ // credentialGuardModeResolution/DEFAULT_MODE), aqui o texto é replicado como literal em cada
301
+ // template literal: o gate de paridade Go/Node/Python
302
+ // (internal/generators/credential_guard_test.go, getNodeSourceBlock) extrai cada constante via
303
+ // regex de um único bloco `` `const NAME = \`...\`` `` sem suportar concatenação de string —
304
+ // concatenar quebraria a extração estática. Nunca editar a lógica de resolução em só um dos dois
305
+ // blocos sem replicar no outro (ML-1B, ADR-2026-08-06 emenda 6). Mirrors Go's
306
+ // credentialGuardModeResolution (semântica idêntica, forma sintática diferente por essa restrição
307
+ // do parser de paridade).
308
+ const CG_PROJECT_TAIL = `DEFAULT_MODE="warn"
309
+ MODE=$(grep -A 5 '^credential_guard:' trackfw.yaml 2>/dev/null | grep 'mode:' | head -1 | sed -E 's/^[[:space:]]*mode:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d "\\"'" || true)
231
310
  case "$MODE" in
232
311
  warn|block) ;;
233
- *) MODE="warn" ;;
312
+ *) MODE="$DEFAULT_MODE" ;;
234
313
  esac
235
314
 
236
315
  if [ "$MODE" = "block" ]; then
@@ -259,10 +338,42 @@ printf '{"tool":"credential-guard","message":"%s","level":"action_required","tim
259
338
  exit 0
260
339
  `
261
340
 
262
- // Escopo global: modo sempre "warn" (ver ADR-2026-08-06/ROADMAP-2026-08-06 Wave 1 — decisão "b",
263
- // sem ~/.trackfw/config.yaml) e ROADMAP_DIR fixo em "docs/roadmaps" relativo ao cwd, só grava o
264
- // attention signal se esse diretório já existir (não cria docs/roadmaps num projeto qualquer).
265
- const CG_GLOBAL_TAIL = `MODE="warn"
341
+ // CG_GLOBAL_TAIL é a contraparte de CG_PROJECT_TAIL para o escopo global
342
+ // (~/.trackfw/scripts/trackfw-credential-guard.sh, instalado via `trackfw update harness`).
343
+ //
344
+ // Decisão (ML-1B, ver ADR-2026-08-06 emenda 6 de 2026-08-08 e ROADMAP-2026-08-08, Wave 1): o modo
345
+ // em escopo global reusa a MESMA leitura de `credential_guard.mode` de trackfw.yaml que
346
+ // CG_PROJECT_TAIL já faz (mesma resolução, replicada aqui — ver o comentário de CG_PROJECT_TAIL
347
+ // sobre por que não é extraída para uma constante compartilhada em Node) — sem exigir trackfw.yaml
348
+ // existir (não há o guard `[ -f trackfw.yaml ] || exit 0` da variante de projeto: o objetivo do
349
+ // escopo global é proteger qualquer projeto, com ou sem trackfw.yaml). Quando o hook global roda a
350
+ // partir do cwd de um projeto com trackfw.yaml e credential_guard.mode explícito, esse valor é
351
+ // respeitado (warn ou block) — nenhuma mudança de comportamento para quem já definiu mode: warn
352
+ // explicitamente. Em qualquer outro caso (sem trackfw.yaml, ou trackfw.yaml sem essa chave), o
353
+ // fallback deixa de ser "warn" e passa a ser "block": um guard opt-in que nunca bloqueia por
354
+ // padrão é uma falsa sensação de proteção — o usuário que rodou `trackfw update harness` já
355
+ // demonstrou intenção explícita de ter o mecanismo ativo. Supersede a decisão original ("modo
356
+ // global sempre warn", opção "b" avaliada na ADR original) — não cria ~/.trackfw/config.yaml nem
357
+ // nenhuma outra segunda fonte de configuração só para isto.
358
+ //
359
+ // ROADMAP_DIR em escopo global: como não há garantia de trackfw.yaml para ler `roadmap_dir:`, o
360
+ // script usa o caminho padrão fixo "docs/roadmaps" relativo ao cwd de onde o hook foi disparado, e
361
+ // só grava o attention signal se esse diretório já existir (e só em modo warn — modo block nunca
362
+ // grava o attention signal, mesma decisão da variante de projeto). Não cria "docs/roadmaps" em um
363
+ // projeto aleatório só para sinalizar isso — isso pareceria ao usuário que o trackfw foi
364
+ // "instalado" nesse projeto, o que não é verdade. O texto de warning/block em stderr acontece
365
+ // sempre (visível no output do CLI/hook), independente de o diretório de attention existir.
366
+ const CG_GLOBAL_TAIL = `DEFAULT_MODE="block"
367
+ MODE=$(grep -A 5 '^credential_guard:' trackfw.yaml 2>/dev/null | grep 'mode:' | head -1 | sed -E 's/^[[:space:]]*mode:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d "\\"'" || true)
368
+ case "$MODE" in
369
+ warn|block) ;;
370
+ *) MODE="$DEFAULT_MODE" ;;
371
+ esac
372
+
373
+ if [ "$MODE" = "block" ]; then
374
+ echo "trackfw-credential-guard: blocked - possible $MATCH detected in tool payload." >&2
375
+ exit 2
376
+ fi
266
377
 
267
378
  echo "trackfw-credential-guard: warning - possible $MATCH detected in tool payload." >&2
268
379
 
@@ -326,6 +437,16 @@ function generateGlobalCredentialGuardScript(home) {
326
437
  const SIGNAL_CMD = 'scripts/trackfw-attention-signal.sh'
327
438
  const CLEANUP_CMD = 'scripts/trackfw-attention-cleanup.sh'
328
439
  const GUARD_CMD = 'scripts/trackfw-credential-guard.sh'
440
+ // Claude Code only (2026-08-09 fix, reported in production against the CMDB project): Claude Code
441
+ // resolves a bare relative hook command against the hook's *dynamic* cwd (tracks `cd`s the agent
442
+ // runs during the session), not the project root -- confirmed against
443
+ // https://code.claude.com/docs/en/hooks: "Handlers run in the current directory... cwd is dynamic".
444
+ // Any Bash/Read/Write/Edit call after the agent `cd`s into a subdirectory (e.g. a monorepo package)
445
+ // made the hook fail with "No such file or directory". $CLAUDE_PROJECT_DIR is the env var Claude
446
+ // Code guarantees stays pinned to the project root regardless of cwd drift (same doc) -- used here
447
+ // instead of GUARD_CMD, matching the pattern this project's own custom hooks already relied on
448
+ // successfully in practice.
449
+ const GUARD_CMD_CLAUDE = '$CLAUDE_PROJECT_DIR/scripts/trackfw-credential-guard.sh'
329
450
 
330
451
  // ---------------------------------------------------------------------------
331
452
  // Global credential-guard dedup (ROADMAP-2026-08-06 Wave 3/ML-3A)
@@ -459,14 +580,30 @@ function injectClaudeHooks(cwd) {
459
580
 
460
581
  if (!data.hooks) data.hooks = {}
461
582
  data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'AskUserQuestion', SIGNAL_CMD)
462
- // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
463
- // credential-guard when the global one is already installed.
583
+
584
+ // Rewrite any stale relative-path credential-guard command from an older trackfw run before
585
+ // merging the fixed one below, so upgrading doesn't just append a second, still-broken entry
586
+ // alongside the new one (see GUARD_CMD_CLAUDE comment for the "No such file or directory" bug).
587
+ for (const matcher of ['Bash', 'Read', 'Write|Edit']) {
588
+ migrateClaudeHookCommand(data.hooks.PreToolUse, matcher, GUARD_CMD, GUARD_CMD_CLAUDE)
589
+ migrateClaudeHookCommand(data.hooks.PostToolUse, matcher, GUARD_CMD, GUARD_CMD_CLAUDE)
590
+ }
591
+
592
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
593
+ // Wave 2 to Read/Write|Edit): skip project-scope credential-guard when the global one is
594
+ // already installed for this CLI.
464
595
  if (!globalCredentialGuardInstalledClaude()) {
465
- data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GUARD_CMD)
596
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GUARD_CMD_CLAUDE)
597
+ // ADR-2026-08-06 emenda 7 (2026-08-08): Read/Write/Edit coverage — extraction via direct
598
+ // file read, or materialization via write/edit, never went through the hook before.
599
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Read', GUARD_CMD_CLAUDE)
600
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Write|Edit', GUARD_CMD_CLAUDE)
466
601
  }
467
602
  data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'AskUserQuestion', CLEANUP_CMD)
468
603
  if (!globalCredentialGuardInstalledClaude()) {
469
- data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash', GUARD_CMD)
604
+ data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash', GUARD_CMD_CLAUDE)
605
+ data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Read', GUARD_CMD_CLAUDE)
606
+ data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Write|Edit', GUARD_CMD_CLAUDE)
470
607
  }
471
608
 
472
609
  writeJSON(filePath, data)
@@ -483,6 +620,12 @@ function injectClaudeHooks(cwd) {
483
620
  // are enabled by default (no `[features] hooks = true`/`codex_hooks` opt-in
484
621
  // needed -- that flag exists only to turn hooks OFF), and PreToolUse blocking
485
622
  // uses exit code 2 + stderr (matching trackfw-credential-guard.sh's "block" mode).
623
+ //
624
+ // Read/Write/Edit coverage (ADR-2026-08-06 emenda 7, ROADMAP-2026-08-08 Wave 2, 2026-08-08):
625
+ // Codex has NO dedicated, interceptable read-tool matcher -- confirmed against
626
+ // https://learn.chatgpt.com/docs/hooks -- so no read matcher is added here; this is a documented
627
+ // limitation (also called out in docs/cli-parity.md), not a workaround. Write/edit materialization
628
+ // IS covered via the `apply_patch` matcher (documented aliases `Edit`/`Write`).
486
629
  // ---------------------------------------------------------------------------
487
630
 
488
631
  function injectCodexHooks(cwd) {
@@ -491,15 +634,18 @@ function injectCodexHooks(cwd) {
491
634
 
492
635
  if (!data.hooks) data.hooks = {}
493
636
  data.hooks.PermissionRequest = mergeClaudeHookArray(data.hooks.PermissionRequest, '.*', SIGNAL_CMD)
494
- // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
495
- // credential-guard when the global one is already installed.
637
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
638
+ // Wave 2 to apply_patch): skip project-scope credential-guard when the global one is already
639
+ // installed for this CLI.
496
640
  const skipCodexCG = globalCredentialGuardInstalledCodex()
497
641
  if (!skipCodexCG) {
498
642
  data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GUARD_CMD)
643
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'apply_patch', GUARD_CMD)
499
644
  }
500
645
  data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, '.*', CLEANUP_CMD)
501
646
  if (!skipCodexCG) {
502
647
  data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash', GUARD_CMD)
648
+ data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'apply_patch', GUARD_CMD)
503
649
  }
504
650
 
505
651
  writeJSON(filePath, data)
@@ -523,6 +669,12 @@ function injectCodexHooks(cwd) {
523
669
  // (matcher "*") is the pre-existing attention-cleanup wiring, unrelated to the new
524
670
  // credential-guard entry added as a separate array entry (different matcher) in the
525
671
  // same event.
672
+ //
673
+ // Read/Write/Edit coverage (ADR-2026-08-06 emenda 7, ROADMAP-2026-08-08 Wave 2, 2026-08-08): the
674
+ // Gemini CLI tools table (https://geminicli.com/docs/reference/tools) documents `read_file`/
675
+ // `read_many_files` as the file-read tools and `write_file`/`replace` as the file-write/edit
676
+ // tools -- matcher below follows the same regex-over-tool_name convention already used for
677
+ // `run_shell_command`.
526
678
  // ---------------------------------------------------------------------------
527
679
 
528
680
  function injectGeminiHooks(cwd) {
@@ -531,15 +683,20 @@ function injectGeminiHooks(cwd) {
531
683
 
532
684
  if (!data.hooks) data.hooks = {}
533
685
  data.hooks.Notification = mergeClaudeHookArray(data.hooks.Notification, 'ToolPermission', SIGNAL_CMD)
534
- // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
686
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
687
+ // Wave 2 to read_file|read_many_files / write_file|replace): skip project-scope
535
688
  // credential-guard when the global one is already installed.
536
689
  const skipGeminiCG = globalCredentialGuardInstalledGemini()
537
690
  if (!skipGeminiCG) {
538
691
  data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'run_shell_command', GUARD_CMD)
692
+ data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'read_file|read_many_files', GUARD_CMD)
693
+ data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'write_file|replace', GUARD_CMD)
539
694
  }
540
695
  data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, '*', CLEANUP_CMD)
541
696
  if (!skipGeminiCG) {
542
697
  data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'run_shell_command', GUARD_CMD)
698
+ data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'read_file|read_many_files', GUARD_CMD)
699
+ data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'write_file|replace', GUARD_CMD)
543
700
  }
544
701
 
545
702
  writeJSON(filePath, data)
@@ -586,8 +743,9 @@ function injectKiroHooks(cwd) {
586
743
  },
587
744
  ]
588
745
 
589
- // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
590
- // credential-guard entries when the global one is already installed.
746
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
747
+ // Wave 2 to read/write): skip project-scope credential-guard entries when the global one is
748
+ // already installed.
591
749
  if (!globalCredentialGuardInstalledKiro()) {
592
750
  hooks.push(
593
751
  {
@@ -604,6 +762,36 @@ function injectKiroHooks(cwd) {
604
762
  matcher: 'shell',
605
763
  action: { type: 'command', command: GUARD_CMD },
606
764
  },
765
+ // Read/Write coverage (ADR-2026-08-06 emenda 7, 2026-08-08): "read" and "write" are the
766
+ // documented Kiro tool-category aliases (fs_read/fs_write), same pattern as "shell" above.
767
+ {
768
+ name: 'trackfw-credential-guard-read-pre',
769
+ description: 'Blocks/warns on possible plaintext credential materialization before a file read',
770
+ trigger: 'PreToolUse',
771
+ matcher: 'read',
772
+ action: { type: 'command', command: GUARD_CMD },
773
+ },
774
+ {
775
+ name: 'trackfw-credential-guard-read-post',
776
+ description: 'Warns on possible plaintext credential materialization after a file read',
777
+ trigger: 'PostToolUse',
778
+ matcher: 'read',
779
+ action: { type: 'command', command: GUARD_CMD },
780
+ },
781
+ {
782
+ name: 'trackfw-credential-guard-write-pre',
783
+ description: 'Blocks/warns on possible plaintext credential materialization before a file write',
784
+ trigger: 'PreToolUse',
785
+ matcher: 'write',
786
+ action: { type: 'command', command: GUARD_CMD },
787
+ },
788
+ {
789
+ name: 'trackfw-credential-guard-write-post',
790
+ description: 'Warns on possible plaintext credential materialization after a file write',
791
+ trigger: 'PostToolUse',
792
+ matcher: 'write',
793
+ action: { type: 'command', command: GUARD_CMD },
794
+ },
607
795
  )
608
796
  }
609
797
 
@@ -638,17 +826,27 @@ function injectKiroHooks(cwd) {
638
826
  // regardless.
639
827
  // ---------------------------------------------------------------------------
640
828
 
829
+ // Read/Write coverage (ADR-2026-08-06 emenda 7, ROADMAP-2026-08-08 Wave 2, 2026-08-08):
830
+ // https://docs.github.com/en/copilot/reference/hooks-reference confirms the camelCase
831
+ // preToolUse/postToolUse toolName mapping `view -> Read`, `create -> Write`, `edit -> Edit` --
832
+ // "view" is the read matcher, "create|edit" the write/edit matcher, same lowercase-runtime-name
833
+ // convention already used for "bash" above.
641
834
  function injectCopilotHooks(cwd) {
642
835
  const filePath = path.join(cwd, '.github', 'hooks', 'trackfw-attention.json')
643
836
 
644
837
  const preToolUse = [{ type: 'command', bash: SIGNAL_CMD, cwd: '.', timeoutSec: 10 }]
645
838
  const postToolUse = [{ type: 'command', bash: CLEANUP_CMD, cwd: '.', timeoutSec: 10 }]
646
839
 
647
- // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
648
- // credential-guard entries when the global one is already installed.
840
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
841
+ // Wave 2 to view / create|edit): skip project-scope credential-guard entries when the global
842
+ // one is already installed.
649
843
  if (!globalCredentialGuardInstalledCopilot()) {
650
844
  preToolUse.push({ type: 'command', matcher: 'bash', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
845
+ preToolUse.push({ type: 'command', matcher: 'view', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
846
+ preToolUse.push({ type: 'command', matcher: 'create|edit', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
651
847
  postToolUse.push({ type: 'command', matcher: 'bash', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
848
+ postToolUse.push({ type: 'command', matcher: 'view', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
849
+ postToolUse.push({ type: 'command', matcher: 'create|edit', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
652
850
  }
653
851
 
654
852
  const data = {
@@ -745,7 +943,8 @@ function injectCursorHooks(cwd) {
745
943
  }
746
944
  removeKnownCommandFromLegacyTopLevelArray(data, 'postToolUse', CLEANUP_CMD)
747
945
 
748
- // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
946
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
947
+ // Wave 2 to Read/Write via the generic preToolUse/postToolUse events): skip project-scope
749
948
  // credential-guard entries when the global one is already installed.
750
949
  if (!globalCredentialGuardInstalledCursor()) {
751
950
  if (!Array.isArray(data.hooks.beforeShellExecution)) data.hooks.beforeShellExecution = []
@@ -757,6 +956,29 @@ function injectCursorHooks(cwd) {
757
956
  if (!hasEntry(data.hooks.afterShellExecution, 'command', GUARD_CMD)) {
758
957
  data.hooks.afterShellExecution.push({ command: GUARD_CMD })
759
958
  }
959
+
960
+ // Read/Write coverage (ADR-2026-08-06 emenda 7, 2026-08-08): wired via the generic
961
+ // preToolUse/postToolUse events (distinct from beforeShellExecution/afterShellExecution,
962
+ // which only ever fire for Shell) with an explicit `matcher`, so these entries never fire
963
+ // for the same tool call the unfiltered attention-signal/cleanup entries already handle
964
+ // above in this same array. hasEntry (command-only) is not enough here -- both the
965
+ // unfiltered signal entry and these matcher-scoped guard entries share the same array, so
966
+ // dedup must also check `matcher`.
967
+ const hasGuardMatcherEntry = (arr, matcher) =>
968
+ Array.isArray(arr) && arr.some(e => e && e.command === GUARD_CMD && e.matcher === matcher)
969
+
970
+ if (!hasGuardMatcherEntry(data.hooks.preToolUse, 'Read')) {
971
+ data.hooks.preToolUse.push({ command: GUARD_CMD, matcher: 'Read' })
972
+ }
973
+ if (!hasGuardMatcherEntry(data.hooks.preToolUse, 'Write')) {
974
+ data.hooks.preToolUse.push({ command: GUARD_CMD, matcher: 'Write' })
975
+ }
976
+ if (!hasGuardMatcherEntry(data.hooks.postToolUse, 'Read')) {
977
+ data.hooks.postToolUse.push({ command: GUARD_CMD, matcher: 'Read' })
978
+ }
979
+ if (!hasGuardMatcherEntry(data.hooks.postToolUse, 'Write')) {
980
+ data.hooks.postToolUse.push({ command: GUARD_CMD, matcher: 'Write' })
981
+ }
760
982
  }
761
983
 
762
984
  writeJSON(filePath, data)