@mmerterden/multi-agent-pipeline 20.8.0 → 20.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/docs/facts.json +3 -3
  3. package/index.js +1 -0
  4. package/install/_codex-agents.mjs +1 -1
  5. package/install/_common.mjs +486 -53
  6. package/install/_mcp-register.mjs +173 -117
  7. package/install/claude.mjs +281 -220
  8. package/install/codex.mjs +7 -7
  9. package/install/copilot.mjs +13 -11
  10. package/install/index.mjs +92 -27
  11. package/install/templates/claude-hooks.json +9 -9
  12. package/manifest.json +112 -114
  13. package/package.json +5 -2
  14. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  15. package/pipeline/lib/confusables.json +79 -33
  16. package/pipeline/lib/extract-conventions.sh +3 -3
  17. package/pipeline/lib/json-file-lock.mjs +27 -7
  18. package/pipeline/lib/normalize-text.mjs +86 -17
  19. package/pipeline/lib/outbound-gate.mjs +13 -4
  20. package/pipeline/lib/redact.mjs +87 -13
  21. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  22. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  23. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  24. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  25. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  26. package/pipeline/scripts/agent-guard.py +150 -25
  27. package/pipeline/scripts/audit-log.sh +3 -4
  28. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  29. package/pipeline/scripts/doctor.mjs +8 -2
  30. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  31. package/pipeline/scripts/log-metric.sh +9 -3
  32. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  33. package/pipeline/scripts/pre-commit-check.sh +119 -27
  34. package/pipeline/scripts/scan-agent-config.sh +9 -9
  35. package/pipeline/scripts/unattended_policy.py +12 -3
  36. package/pipeline/scripts/uninstall.mjs +88 -1
  37. package/pipeline/scripts/usage-identity.mjs +1 -1
  38. package/pipeline/scripts/usage-register.mjs +1 -1
  39. package/pipeline/skills/.skill-manifest.json +30 -30
  40. package/pipeline/skills/shared/README.md +64 -64
  41. package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
  42. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
  43. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
  44. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
  45. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
  46. package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
  47. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
  48. package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
  49. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
  50. package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
  51. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
  52. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  53. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  54. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +4 -2
  55. package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
  56. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  57. package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
  58. package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
  59. package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
  60. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  61. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  62. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +6 -6
  63. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +3 -2
  64. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +20 -18
  65. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +4 -4
  66. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +12 -9
  67. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +201 -0
  68. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +64 -30
  69. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  70. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +20 -12
  71. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -7
  72. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +26 -12
  73. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  74. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +51 -24
  75. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +19 -9
  76. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +26 -19
  77. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -46
  78. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +37 -63
  79. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
  80. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  81. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  82. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  83. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  84. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  85. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  86. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  87. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  88. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  89. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  90. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  91. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  92. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
  93. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
  94. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
  95. package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
  96. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
  97. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
  98. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
  99. package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
  100. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
  101. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
  102. package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
  103. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
  104. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
  105. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
  106. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  107. package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
  108. package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
  109. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
  110. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  111. package/pipeline/scripts/make-manifest.mjs +0 -199
  112. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -7,6 +7,13 @@
7
7
  * `[redacted:<rule>]`; the text around it is kept, because a log line is read
8
8
  * to debug a failure and the message is what makes it useful.
9
9
  *
10
+ * The rules run on the folded copy of the text (normalize-text.mjs), so a token
11
+ * carrying an invisible character or a look-alike letter is found, and the
12
+ * span of the ORIGINAL text it came from is the one replaced. Everything
13
+ * outside a match is written back as it was: a Persian zero-width non-joiner,
14
+ * a direction mark, an emoji joined by U+200D and a soft hyphen all survive,
15
+ * and a text with no match is returned unchanged.
16
+ *
10
17
  * As a CLI it reads stdin and writes the redacted text to stdout. It never
11
18
  * fails its caller: on an internal error it writes the input unchanged.
12
19
  *
@@ -15,26 +22,93 @@
15
22
 
16
23
  import { RULES } from "./outbound-gate.mjs";
17
24
  import { invokedDirectly } from "./invoked-directly.mjs";
18
- import { stripInvisible } from "./normalize-text.mjs";
25
+ import { foldWithIndex, normalizeForMatch } from "./normalize-text.mjs";
19
26
 
20
27
  const CERTAIN = RULES.filter((r) => r.certain);
21
28
 
29
+ const globalCopy = (re) =>
30
+ new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
31
+ const marker = (name) =>
32
+ name === "url-with-credentials" ? `[redacted:${name}]@` : `[redacted:${name}]`;
33
+
34
+ // Where two rules match overlapping text, the one with the longer literal
35
+ // prefix names the span: `sk-ant-...` is an Anthropic key even though the
36
+ // shorter `sk-` OpenAI rule matches it too. Equal prefixes keep rule order.
37
+ const literalPrefix = (re) => (re.source.replace(/^\\b/, "").match(/^[A-Za-z0-9_-]*/) || [""])[0];
38
+ const RANK = new Map(
39
+ CERTAIN.map((r, i) => [r.name, { specificity: literalPrefix(r.re).length, order: i }]),
40
+ );
41
+ const moreSpecific = (a, b) => {
42
+ const x = RANK.get(a);
43
+ const y = RANK.get(b);
44
+ return x.specificity > y.specificity || (x.specificity === y.specificity && x.order < y.order);
45
+ };
46
+
47
+ /**
48
+ * Spans of `text` a certain rule matches, as [from, to, rule], merged and in
49
+ * order. The rules run on the raw text and on the folded copy: a letter that
50
+ * folds to a word character (the Turkish dotless i, a ligature, a
51
+ * mathematical letter) right before a token removes the word boundary the
52
+ * rule needs in the folded copy only, and a look-alike inside a token hides it
53
+ * from the raw text only. Raw spans are already in original indices; folded
54
+ * ones are mapped back through the fold's index.
55
+ */
56
+ function findSpans(text) {
57
+ const spans = [];
58
+ for (const rule of CERTAIN) {
59
+ for (const m of text.matchAll(globalCopy(rule.re))) {
60
+ if (m[0]) spans.push([m.index, m.index + m[0].length, rule.name]);
61
+ }
62
+ }
63
+ const { folded, start, end } = foldWithIndex(text);
64
+ if (folded !== text) {
65
+ for (const rule of CERTAIN) {
66
+ for (const m of folded.matchAll(globalCopy(rule.re))) {
67
+ if (!m[0]) continue;
68
+ spans.push([start[m.index], end[m.index + m[0].length - 1], rule.name]);
69
+ }
70
+ }
71
+ }
72
+ spans.sort((x, y) => x[0] - y[0] || y[1] - x[1]);
73
+ const merged = [];
74
+ for (const s of spans) {
75
+ const last = merged.at(-1);
76
+ if (last && s[0] < last[1]) {
77
+ if (s[1] > last[1]) last[1] = s[1];
78
+ if (moreSpecific(s[2], last[2])) last[2] = s[2];
79
+ } else {
80
+ merged.push([...s]);
81
+ }
82
+ }
83
+ return merged;
84
+ }
85
+
86
+ function stillMatches(text) {
87
+ const folded = normalizeForMatch(text);
88
+ return CERTAIN.find((r) => globalCopy(r.re).test(text) || globalCopy(r.re).test(folded));
89
+ }
90
+
22
91
  /** @param {string} text */
23
92
  export function redact(text) {
24
93
  if (typeof text !== "string" || !text) return text;
25
- // Invisible characters are removed first: they change nothing a reader sees,
26
- // and a token split by one would otherwise slip past every rule.
27
- let out = stripInvisible(text);
28
- for (const rule of CERTAIN) {
29
- const re = new RegExp(
30
- rule.re.source,
31
- rule.re.flags.includes("g") ? rule.re.flags : `${rule.re.flags}g`,
32
- );
33
- out = out.replace(re, () =>
34
- rule.name === "url-with-credentials" ? `[redacted:${rule.name}]@` : `[redacted:${rule.name}]`,
35
- );
94
+ const spans = findSpans(text);
95
+ let out = text;
96
+ for (let k = spans.length - 1; k >= 0; k--) {
97
+ const [from, to, name] = spans[k];
98
+ out = out.slice(0, from) + marker(name) + out.slice(to);
36
99
  }
37
- return out;
100
+ // The per-code-point fold that locates spans can differ from the whole-text
101
+ // fold the gate uses where NFKC composes across characters. A line the gate
102
+ // would still flag after both the raw and the folded spans are replaced is
103
+ // replaced whole rather than written with a credential.
104
+ if (!stillMatches(out)) return out;
105
+ return out
106
+ .split("\n")
107
+ .map((line) => {
108
+ const rule = stillMatches(line);
109
+ return rule ? marker(rule.name) : line;
110
+ })
111
+ .join("\n");
38
112
  }
39
113
 
40
114
  if (invokedDirectly(import.meta.url)) {
@@ -107,7 +107,7 @@ the bucket patterns use (`docs/adr/0010-own-code-graph.md`).
107
107
  | domainEntities | `Domain/Entities/*.swift` | `domain/entities/*.kt` / `entities/*.py` |
108
108
  | routes | Public `enum *Route` matching `DomainRoute` | `*Route` sealed / NavRoute / `routes.*` modules |
109
109
  | coordinators | `*Coordinator.swift` + `CoordinatorProtocol`/`DomainRouter` | Navigator / NavController helpers |
110
- | diConfigurators | `*DependencyConfigurator.swift`, `Module.kt` (Hilt) | DI container registrations |
110
+ | diConfigurators | `*DependencyRegistrar.swift`, `*Assembly.swift`, `Module.kt` (Hilt) | DI container registrations |
111
111
  | uiComponents | `Common/UIComponents/.../Components/**/*.swift` triplet (Configuration + View + +Modifiers) | Compose @Composable functions / React components |
112
112
  | tokens | `*Token` enums under `UIAssetTokens/Generated/` | `Theme.kt` / `tokens.ts` / `tailwind.config.*` |
113
113
  | localizationKeys | `LocalizationStringKeys.swift` public enum + cases | `strings.xml` keys / `i18n/*.json` keys |
@@ -49,7 +49,7 @@ Convention preview - Pass B will render with:
49
49
  | Test naming | @Test func scenario_expected() ^[C4 medium: 4 examples] | fun scenario_expectedBehavior() ^[C4 high] |
50
50
  | Identifier | userProfile.continueButton ^[C5 high] | userProfileContinueButton ^[C5 medium] |
51
51
  | Localization key | UserProfile.ContinueButton ^[C6 high] | user_profile_continue_button ^[C6 high] |
52
- | DI | UserProfileDependencyConfigurator ^[C7 high] | UserProfileModule (Hilt) ^[C7 fallback: defaults] |
52
+ | DI | UserProfileDependencyRegistrar ^[C7 high] | UserProfileModule (Hilt) ^[C7 fallback: defaults] |
53
53
 
54
54
  Confidence summary:
55
55
  iOS: 5/5 high, 0 medium, 0 low, 0 fallback
@@ -47,7 +47,7 @@ run produced entities and a mapper but left the screen half-wired.
47
47
 
48
48
  | `state.componentScope` | Meaning | iOS skill | Android skill |
49
49
  |---|---|---|---|
50
- | `screen` (default when the frame is a full screen, or the task names a screen) | Full clean-architecture vertical: Entity → Repository → Mapper → UseCase → LocalizedText → AnalyticsTracking → CoordinatorEvent → ViewModel → Scene → Preview, then verify | `ai-ios-toolkit:create-screen` | `ai-android-toolkit:create-screen` |
50
+ | `screen` (default when the frame is a full screen, or the task names a screen) | Full clean-architecture vertical: Entity → Repository → Mapper → UseCase → Copy → AnalyticsTracking → CoordinatorEvent → ViewModel → Scene → Preview, then verify | `ai-ios-toolkit:create-screen` | `ai-android-toolkit:create-screen` |
51
51
  | `component` | One reusable UI component (Configuration / View / +Modifiers / Code Connect) | `ai-ios-toolkit:create-component` (fallback `create-ui-component`) | `ai-android-toolkit:create-component` (fallback `create-ui-component`) |
52
52
  | `evolve` | Change an existing component | `evolve-component` (fallback `evolve-ui-component`) | same |
53
53
 
@@ -160,7 +160,7 @@ Detection: scan up to 10 SwiftUI view files in the candidate set; majority pick
160
160
 
161
161
  | Platform | Default | Example | Rationale |
162
162
  |---|---|---|---|
163
- | iOS | Manual configurator | `<Domain>DependencyConfigurator.register<Feature>()` | Avoids framework lock-in; testable |
163
+ | iOS | Manual configurator | `<Domain>DependencyRegistrar.register<Feature>()` | Avoids framework lock-in; testable |
164
164
  | Android | Hilt module | `@Module @InstallIn(SingletonComponent::class) class <Feature>Module` | Standard Android DI |
165
165
  | Backend FastAPI | `Depends(get_<feature>_service)` | `service: OrderSummaryService = Depends(get_order_summary_service)` | Native FastAPI pattern |
166
166
  | Web | hook factory | `useOrderSummaryApi()` returns a memoized client | No framework DI needed |
@@ -287,8 +287,8 @@ Before every launch - the dev run, a research pass, a resume - the runner:
287
287
  settings, read the way `install/claude.mjs` writes it: a matcher is a regex
288
288
  over the tool name (the legacy `Bash(git push:*)` covers nothing, and the web
289
289
  entry has to cover `agent_run_steps` and both `open_url` tools), the hook
290
- command is exactly the installer's `bash $HOME/.claude/scripts/agent-guard.sh`
291
- (or the same path with `$HOME` expanded) and that script exists. The
290
+ command is exactly the installer's `bash "$HOME/.claude/scripts/agent-guard.sh"`
291
+ (quoted or not, or the same path with `$HOME` expanded) and that script exists. The
292
292
  checkout's `.claude/` settings never count toward the registration - the repo
293
293
  under work is not where the guard may come from - but `disableAllHooks` in
294
294
  any of these files, the checkout's included, turns every hook off. It also
@@ -51,12 +51,24 @@ ATTRIBUTION = re.compile(
51
51
  )
52
52
 
53
53
 
54
+ def _strip_set(ranges):
55
+ out = set()
56
+ for spec in ranges:
57
+ first, _, last = str(spec).partition("-")
58
+ try:
59
+ lo, hi = int(first, 16), int(last or first, 16)
60
+ except ValueError:
61
+ continue
62
+ out.update(chr(cp) for cp in range(lo, hi + 1))
63
+ return out
64
+
65
+
54
66
  def _load_confusables():
55
67
  here = os.path.dirname(os.path.abspath(__file__))
56
68
  try:
57
69
  with open(os.path.join(here, "..", "lib", "confusables.json"), encoding="utf-8") as fh:
58
70
  table = json.load(fh)
59
- return set(table.get("strip", [])), dict(table.get("map", {}))
71
+ return _strip_set(table.get("stripRanges", [])), dict(table.get("map", {}))
60
72
  except (OSError, ValueError):
61
73
  return set(), {}
62
74
 
@@ -65,12 +77,14 @@ _STRIP, _MAP = _load_confusables()
65
77
 
66
78
 
67
79
  def normalize_for_match(text: str) -> str:
68
- """NFKC, invisible characters removed, look-alike letters mapped to ASCII.
80
+ """NFKC, Default_Ignorable_Code_Point removed, look-alikes mapped to ASCII.
69
81
 
70
82
  The same fold lib/normalize-text.mjs applies, from the same table. Rules
71
83
  that match text content run on the raw command and on this copy, so a
72
84
  trailer written with a zero-width space or a Cyrillic letter still matches.
73
- Without the table only NFKC applies.
85
+ The table's map also carries the compatibility letters newer than this
86
+ Python's unicodedata, which NFKC here would leave alone. Without the table
87
+ only NFKC applies.
74
88
  """
75
89
  return "".join(
76
90
  _MAP.get(ch, ch) for ch in unicodedata.normalize("NFKC", text) if ch not in _STRIP
@@ -130,10 +144,23 @@ GIT_FLAG_OPTIONS = {
130
144
  SEPARATORS = re.compile(r"&&|\|\||;|\||\n")
131
145
 
132
146
 
147
+ # shlex grows each token one character at a time, which is quadratic in the
148
+ # token's length: a megabyte argument takes longer than the deadline. A segment
149
+ # longer than this is split on whitespace with its quotes and backslashes
150
+ # removed instead, which keeps every word the segment names in order.
151
+ SHLEX_MAX_CHARS = 16384
152
+
153
+
154
+ def coarse_tokens(cmd: str) -> list:
155
+ return [re.sub(r"[\"'\\]", "", t) for t in cmd.split()]
156
+
157
+
133
158
  def tokenize(cmd: str) -> list:
134
159
  """shlex tokens, or whitespace tokens when shlex cannot parse (an unbalanced
135
- quote). Never returns None: a guard has to reason about a segment it cannot
136
- parse cleanly, not skip it."""
160
+ quote) or the segment is too long for it. Never returns None: a guard has to
161
+ reason about a segment it cannot parse cleanly, not skip it."""
162
+ if len(cmd) > SHLEX_MAX_CHARS:
163
+ return coarse_tokens(cmd)
137
164
  try:
138
165
  return shlex.split(cmd)
139
166
  except Exception:
@@ -401,11 +428,12 @@ def _decide_push(cmd: str, toks=None, cwd=None) -> str:
401
428
  force_flag = bool(FORCE.search(cmd))
402
429
  if not force_flag and not FORCED_REFSPEC.search(cmd) and not PUSH_GATE.search(cmd):
403
430
  return "OK"
404
- try:
405
- shlex.split(cmd)
406
- except Exception:
407
- # A force-push we can't tokenize -> can't prove it's safe -> block.
408
- return "BLOCK_FORCE"
431
+ if len(cmd) <= SHLEX_MAX_CHARS:
432
+ try:
433
+ shlex.split(cmd)
434
+ except Exception:
435
+ # A force-push we can't tokenize -> can't prove it's safe -> block.
436
+ return "BLOCK_FORCE"
409
437
  git_dir, opts, positionals = _parse_push(toks)
410
438
  keys = {k for k, _ in opts}
411
439
  force_flag = any(
@@ -451,17 +479,97 @@ def _decide_push(cmd: str, toks=None, cwd=None) -> str:
451
479
  return "OK"
452
480
 
453
481
 
482
+ FALLBACK_SPLIT = re.compile(r"&&|\|\||[;|\n()`]|\\n")
483
+ FALLBACK_FORCE_WORDS = {"-d", "--force", "--force-with-lease", "--force-if-includes", "--mirror", "--delete", "--prune"}
484
+ FALLBACK_TARGET_WORDS = {"head", "@", "--all", "--mirror", "--branches"}
485
+
486
+
487
+ def _fallback_forced(tok: str) -> bool:
488
+ key = tok.split("=", 1)[0]
489
+ if key in FALLBACK_FORCE_WORDS or tok.startswith("+") or ":+" in tok or tok.startswith(":"):
490
+ return True
491
+ return tok.startswith("-") and not tok.startswith("--") and tok[1:].isalpha() and "f" in tok
492
+
493
+
494
+ def _fallback_protected(tok: str) -> bool:
495
+ name = tok.rsplit(":", 1)[-1].lstrip("+")
496
+ if name.startswith("refs/heads/"):
497
+ name = name[len("refs/heads/"):]
498
+ return name in PROTECTED or tok in FALLBACK_TARGET_WORDS
499
+
500
+
501
+ def fallback_verdict(text: str) -> str:
502
+ """The always-on rules as a linear scan, for input the tokenizer could not
503
+ finish in time. Each piece between shell separators is split on whitespace
504
+ with its quotes and backslashes removed, and no regex that can backtrack
505
+ runs over it. A push piece with a force indicator is blocked when it names a
506
+ protected branch, HEAD / @ or every ref, or when the checkout's own branch
507
+ is protected or unknown; a line with a git commit is blocked when the
508
+ attribution pattern matches the raw or the folded text. Stricter than
509
+ decide() by design: it only runs when decide() could not."""
510
+ try:
511
+ folded = normalize_for_match(text)
512
+ except Exception:
513
+ folded = text
514
+ current = os.environ.get("CUR_BRANCH", "")
515
+ commits = False
516
+ for t in (text, folded):
517
+ for piece in FALLBACK_SPLIT.split(t.lower()):
518
+ toks = coarse_tokens(piece)
519
+ if not any(os.path.basename(x) == "git" for x in toks):
520
+ continue
521
+ words = set(toks)
522
+ if "commit" in words:
523
+ commits = True
524
+ if "push" not in words or not any(_fallback_forced(x) for x in toks):
525
+ continue
526
+ if any(_fallback_protected(x) for x in toks) or current in PROTECTED or current in ("", "HEAD"):
527
+ return "BLOCK_FORCE"
528
+ if commits and (ATTRIBUTION.search(text) or ATTRIBUTION.search(folded)):
529
+ return "BLOCK_ATTRIB"
530
+ return "OK"
531
+
532
+
454
533
  def unattended() -> bool:
455
534
  """MULTI_AGENT_UNATTENDED=1 in the hook's own environment - the launcher's,
456
535
  never anything the run wrote (lib/unattended.sh ma_unattended)."""
457
536
  return os.environ.get("MULTI_AGENT_UNATTENDED", "") == "1"
458
537
 
459
538
 
539
+ def _arm_deadline(on_deadline) -> None:
540
+ """Run on_deadline (which must end the process) once deadline_seconds()
541
+ have passed. Where SIGALRM is unavailable the call simply runs to the end."""
542
+
543
+ def _fire(_signum, _frame):
544
+ try:
545
+ signal.setitimer(signal.ITIMER_REAL, 0)
546
+ except (ValueError, AttributeError, OSError):
547
+ pass
548
+ on_deadline()
549
+
550
+ try:
551
+ signal.signal(signal.SIGALRM, _fire)
552
+ signal.setitimer(signal.ITIMER_REAL, deadline_seconds())
553
+ except (ValueError, AttributeError, OSError):
554
+ pass
555
+
556
+
557
+ def _exit_with(line: str) -> None:
558
+ # os._exit skips the stdio flush, so the line is flushed first or the
559
+ # caller reads nothing.
560
+ if line:
561
+ print(line, flush=True)
562
+ os._exit(0)
563
+
564
+
460
565
  def git_subcommand_mode() -> None:
461
566
  """`agent-guard.py --git-subcommand`: read a Bash payload (or a raw command
462
567
  on argv) and print each git subcommand it runs, one per line, using the same
463
568
  tokenizer the guard uses. pre-commit-check.sh reads this to detect a commit
464
- without a regex. Never executes anything."""
569
+ without a regex. Never executes anything. At the deadline it prints
570
+ `commit`, so the caller scans rather than skipping a commit it could not
571
+ rule out."""
572
+ _arm_deadline(lambda: _exit_with("commit"))
465
573
  if len(sys.argv) > 2:
466
574
  cmd = sys.argv[2]
467
575
  else:
@@ -490,8 +598,9 @@ def commit_dir_mode() -> None:
490
598
  """`agent-guard.py --commit-dir`: read a Bash payload and print the
491
599
  directory the first commit-writing git command on the line runs in, after
492
600
  any `cd` / `git -C`, resolved from the payload's cwd. Prints nothing when
493
- there is no commit or the directory cannot be read literally, so the caller
494
- keeps its own cwd. Never executes anything."""
601
+ there is no commit, the directory cannot be read literally, or the deadline
602
+ passes, so the caller keeps its own cwd. Never executes anything."""
603
+ _arm_deadline(lambda: _exit_with(""))
495
604
  try:
496
605
  data = json.load(sys.stdin)
497
606
  cmd = (data.get("tool_input") or {}).get("command", "")
@@ -519,21 +628,37 @@ def main() -> None:
519
628
  commit_dir_mode()
520
629
  return
521
630
  strict = unattended()
522
-
523
- def _deadline(_signum, _frame):
524
- # os._exit skips the stdio flush, so the verdict is flushed first or
525
- # the wrapper reads nothing.
526
- print("BLOCK_UNATTENDED\tthe guard hit its internal deadline" if strict else "OK", flush=True)
527
- os._exit(0)
631
+ seen = {"text": "", "raw": True}
632
+
633
+ def _unjudged(default: str) -> str:
634
+ # The always-on rules still hold when the full decision could not be
635
+ # reached: a linear scan of whatever text is at hand decides them.
636
+ text = seen["text"]
637
+ if seen.get("raw"):
638
+ try:
639
+ cmd = (json.loads(text).get("tool_input") or {}).get("command", "")
640
+ text = cmd if isinstance(cmd, str) else text
641
+ except Exception:
642
+ pass
643
+ try:
644
+ verdict = fallback_verdict(text)
645
+ except Exception:
646
+ verdict = "OK"
647
+ return verdict if verdict != "OK" else default
528
648
 
529
649
  try:
530
- signal.signal(signal.SIGALRM, _deadline)
531
- signal.setitimer(signal.ITIMER_REAL, deadline_seconds())
532
- except (ValueError, AttributeError, OSError):
533
- pass
650
+ seen["text"] = sys.stdin.read()
651
+ except Exception:
652
+ seen["text"] = ""
653
+ # Armed once the payload is in hand, so the fallback always has the text.
654
+ _arm_deadline(
655
+ lambda: _exit_with(_unjudged("BLOCK_UNATTENDED\tthe guard hit its internal deadline" if strict else "OK"))
656
+ )
534
657
  try:
535
- data = json.load(sys.stdin)
658
+ data = json.loads(seen["text"])
536
659
  cmd = (data.get("tool_input") or {}).get("command", "")
660
+ if isinstance(cmd, str):
661
+ seen["text"], seen["raw"] = cmd, False
537
662
  verdict = decide(cmd) if isinstance(cmd, str) and cmd else "OK"
538
663
  if verdict == "OK" and strict:
539
664
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -547,7 +672,7 @@ def main() -> None:
547
672
  # Attended: a guard bug must not break a legitimate tool call.
548
673
  # Unattended: nobody is there to notice a gap, so an input the guard
549
674
  # cannot judge is refused.
550
- print("BLOCK_PARSE" if strict else "OK")
675
+ print(_unjudged("BLOCK_PARSE" if strict else "OK"))
551
676
 
552
677
 
553
678
  if __name__ == "__main__":
@@ -56,10 +56,9 @@ hash_url() {
56
56
  elif command -v shasum >/dev/null 2>&1; then
57
57
  printf '%s' "$url" | shasum -a 256 | awk '{print $1}'
58
58
  else
59
- # The previous fallback printed `unhashed:<url>`, writing the plaintext remote into
60
- # the audit log - the exact thing this hashing exists to prevent, and it would have
61
- # carried any credentials embedded in the remote URL with it. Losing one field beats
62
- # leaking one.
59
+ # No hash tool: record a fixed marker, never the URL itself. The plaintext
60
+ # remote is what the hashing keeps out of the log, and a remote URL can
61
+ # carry embedded credentials.
63
62
  echo "unhashable-no-sha256-tool"
64
63
  fi
65
64
  }
@@ -263,10 +263,11 @@ const GUARD_MATCHERS = [
263
263
  ],
264
264
  ],
265
265
  ];
266
- // The hook command install/claude.mjs writes. Only this command, with $HOME
267
- // written literally or expanded, counts as the guard: a command that merely
268
- // mentions the script path can run anything and still look registered.
269
- const GUARD_COMMAND = "bash $HOME/.claude/scripts/agent-guard.sh";
266
+ // The hook command install/claude.mjs writes (the path quoted, so a HOME with a
267
+ // space still runs it). Only this command, quoted or not, with $HOME written
268
+ // literally or expanded, counts as the guard: a command that merely mentions
269
+ // the script path can run anything and still look registered.
270
+ const GUARD_COMMAND = 'bash "$HOME/.claude/scripts/agent-guard.sh"';
270
271
  const MANAGED_SETTINGS = "/Library/Application Support/ClaudeCode/managed-settings.json";
271
272
  // A phase record without a model is priced at the most expensive tier: an
272
273
  // over-estimate stops the spend ceiling early, an under-estimate stops it late.
@@ -1334,7 +1335,15 @@ function matcherCovers(matcher, tool) {
1334
1335
  /** The guard script, when the hook command is exactly the installer's and the script exists. */
1335
1336
  function guardScriptOf(command, home) {
1336
1337
  const script = join(home, ".claude", "scripts", "agent-guard.sh");
1337
- const forms = [GUARD_COMMAND, GUARD_COMMAND.replace("$HOME", "${HOME}"), `bash ${script}`];
1338
+ const unquoted = GUARD_COMMAND.replaceAll('"', "");
1339
+ const forms = [
1340
+ GUARD_COMMAND,
1341
+ GUARD_COMMAND.replace("$HOME", "${HOME}"),
1342
+ unquoted,
1343
+ unquoted.replace("$HOME", "${HOME}"),
1344
+ `bash "${script}"`,
1345
+ `bash ${script}`,
1346
+ ];
1338
1347
  return forms.includes(String(command || "").trim()) && existsSync(script) ? script : null;
1339
1348
  }
1340
1349
 
@@ -465,8 +465,14 @@ function checkHookCoverage() {
465
465
  for (const h of entry.hooks || []) if (h.command) wantedCmds.add(h.command);
466
466
  }
467
467
  }
468
- const haveStr = JSON.stringify(have?.hooks || {});
469
- const absent = [...wantedCmds].filter((c) => !haveStr.includes(c));
468
+ const haveCmds = new Set();
469
+ for (const group of Object.values(have?.hooks || {})) {
470
+ for (const entry of Array.isArray(group) ? group : []) {
471
+ for (const h of entry?.hooks || [])
472
+ if (typeof h?.command === "string") haveCmds.add(h.command);
473
+ }
474
+ }
475
+ const absent = [...wantedCmds].filter((c) => !haveCmds.has(c));
470
476
  if (absent.length) {
471
477
  report(
472
478
  "hook-coverage",
@@ -100,6 +100,18 @@ for (const k of order) {
100
100
  md += `\n`;
101
101
  }
102
102
 
103
- writeFileSync(join(SKILLS_ROOT, "README.md"), md);
103
+ const target = join(SKILLS_ROOT, "README.md");
104
+ if (process.argv.includes("--check")) {
105
+ const current = existsSync(target) ? readFileSync(target, "utf8") : "";
106
+ if (current !== md) {
107
+ console.error(
108
+ "gen-skills-index: pipeline/skills/shared/README.md is stale - run node pipeline/scripts/gen-skills-index.mjs",
109
+ );
110
+ process.exit(1);
111
+ }
112
+ console.log("gen-skills-index: README.md is current");
113
+ process.exit(0);
114
+ }
115
+ writeFileSync(target, md);
104
116
  const filledCategories = Object.values(buckets).filter((b) => b.length).length;
105
117
  console.log(`wrote ${total} skills (${breakdown}) across ${filledCategories} categories`);
@@ -55,6 +55,12 @@ mkdir -p "$METRICS_DIR" 2>/dev/null || {
55
55
  exit 0
56
56
  }
57
57
 
58
+ # Same escaping as audit-log.sh: backslash, quote, tab and CR escaped, and a
59
+ # newline written as \n, so any caller text stays inside one JSON string.
60
+ json_escape() {
61
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g; s/\r/\\r/g' | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }'
62
+ }
63
+
58
64
  # Build details object from key=value args. Keep it shell-portable: no jq required.
59
65
  DETAILS="{"
60
66
  SEP=""
@@ -65,10 +71,10 @@ for kv in "$@"; do
65
71
  # int or true/false becomes a JSON string.
66
72
  case "$VAL" in
67
73
  true|false) JSON_VAL="$VAL" ;;
68
- ''|*[!0-9]*) JSON_VAL="\"$(printf '%s' "$VAL" | tr -d '\n\r' | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g')\"" ;;
74
+ ''|*[!0-9]*) JSON_VAL="\"$(json_escape "$VAL")\"" ;;
69
75
  *) JSON_VAL="$VAL" ;;
70
76
  esac
71
- DETAILS="${DETAILS}${SEP}\"${KEY}\":${JSON_VAL}"
77
+ DETAILS="${DETAILS}${SEP}\"$(json_escape "$KEY")\":${JSON_VAL}"
72
78
  SEP=","
73
79
  done
74
80
  DETAILS="${DETAILS}}"
@@ -79,7 +85,7 @@ PHASE_SCHEMA="$(node -e 'const c=require("'"$(cd "$(dirname "$0")" && pwd)"'/../
79
85
  [ -n "$PHASE_SCHEMA" ] || PHASE_SCHEMA=2
80
86
 
81
87
  TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
82
- LINE="{\"ts\":\"${TS}\",\"task_id\":\"${TASK_ID}\",\"phase\":\"${PHASE}\",\"event\":\"${EVENT}\",\"details\":${DETAILS},\"phaseSchema\":${PHASE_SCHEMA}}"
88
+ LINE="{\"ts\":\"${TS}\",\"task_id\":\"$(json_escape "$TASK_ID")\",\"phase\":\"$(json_escape "$PHASE")\",\"event\":\"$(json_escape "$EVENT")\",\"details\":${DETAILS},\"phaseSchema\":${PHASE_SCHEMA}}"
83
89
 
84
90
  # Caller-supplied values can carry a credential copied out of an error.
85
91
  # redact.mjs replaces it with a marker; without node the line is written as is.
@@ -522,10 +522,23 @@ function migrateReportChannels(out, changes) {
522
522
  }
523
523
  }
524
524
 
525
+ // Writes through a symlinked prefs file to its target, so a prefs file kept in
526
+ // a dotfiles repo stays a link and the repo copy receives the migration.
525
527
  function atomicWrite(filePath, data) {
526
- withFileLockSync(filePath, () => writeJsonAtomicSync(filePath, data, { mode: 0o600 }), {
527
- timeoutMs: lockTimeoutFrom(process.env),
528
- });
528
+ withFileLockSync(
529
+ filePath,
530
+ () => writeJsonAtomicSync(filePath, data, { mode: 0o600, followSymlink: true }),
531
+ { timeoutMs: lockTimeoutFrom(process.env) },
532
+ );
533
+ }
534
+
535
+ // The document as it was before this migration, beside the path given, so a
536
+ // migration that went wrong can be undone by hand.
537
+ function writeBackup(filePath, raw) {
538
+ const bak = `${filePath}.bak`;
539
+ fs.writeFileSync(bak, raw, { mode: 0o600 });
540
+ fs.chmodSync(bak, 0o600);
541
+ return bak;
529
542
  }
530
543
 
531
544
  function main() {
@@ -560,8 +573,9 @@ function main() {
560
573
  process.exit(0);
561
574
  }
562
575
 
576
+ const bak = writeBackup(args.file, raw);
563
577
  atomicWrite(args.file, out);
564
- console.log(`✓ wrote migrated prefs to ${args.file}`);
578
+ console.log(`✓ wrote migrated prefs to ${args.file} (previous version: ${bak})`);
565
579
  }
566
580
 
567
581
  runMain("migrate-prefs", main);