arkaos 4.48.0 → 4.49.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.
Files changed (50) hide show
  1. package/README.md +2 -2
  2. package/THE-ARKAOS-GUIDE.md +2 -2
  3. package/VERSION +1 -1
  4. package/arka/SKILL.md +1 -1
  5. package/config/hooks/_lib/fastpath/engine.cjs +29 -5
  6. package/config/hooks/gate-manifest.json +14 -1
  7. package/core/hooks/gate_manifest.py +12 -0
  8. package/core/hooks/post_tool_use.py +46 -4
  9. package/core/hooks/pre_tool_use.py +36 -3
  10. package/core/workflow/flow_enforcer.py +112 -20
  11. package/core/workflow/frontend_gate.py +64 -5
  12. package/core/workflow/specialist_enforcer.py +48 -15
  13. package/departments/kb/SKILL.md +1 -0
  14. package/departments/kb/skills/research-deep/SKILL.md +192 -0
  15. package/departments/kb/skills/research-deep/THIRD-PARTY-NOTICES.md +15 -0
  16. package/harness/codex/AGENTS.md +2 -2
  17. package/harness/copilot/copilot-instructions.md +2 -2
  18. package/harness/cursor/rules/arkaos.mdc +3 -3
  19. package/harness/gemini/GEMINI.md +2 -2
  20. package/harness/opencode/AGENTS.md +2 -2
  21. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  22. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  23. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  24. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  25. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  26. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  27. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  28. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  29. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  30. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  31. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  32. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  33. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  34. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  35. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  36. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  37. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  38. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  39. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  40. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  41. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  42. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  43. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  44. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  45. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  46. package/harness/zed/.rules +2 -2
  47. package/knowledge/commands-registry.json +25 -3
  48. package/knowledge/skills-manifest.json +14 -1
  49. package/package.json +1 -1
  50. package/pyproject.toml +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **The Operating System for AI Agent Teams.**
4
4
 
5
- 89 agents. 17 departments. 332 skills. Enterprise frameworks. Multi-runtime. One install.
5
+ 89 agents. 17 departments. 333 skills. Enterprise frameworks. Multi-runtime. One install.
6
6
 
7
7
  ```bash
8
8
  npx arkaos install
@@ -95,7 +95,7 @@ npx arkaos doctor # Health check
95
95
 
96
96
  ### Skill packs, à la carte
97
97
 
98
- The default install ships a curated core so your context window stays lean. Everything else lives in the ArkaOS plugin marketplace: 16 department packs with 258 skills, generated straight from the same sources the core uses. Inside Claude Code:
98
+ The default install ships a curated core so your context window stays lean. Everything else lives in the ArkaOS plugin marketplace: 16 department packs with 259 skills, generated straight from the same sources the core uses. Inside Claude Code:
99
99
 
100
100
  ```
101
101
  /plugin marketplace add andreagroferreira/arka-os
@@ -1,6 +1,6 @@
1
1
  # The ArkaOS Guide
2
2
 
3
- > v4.48.0 — 89 agents, 17 departments, 332 skills, 297 commands, 19 ADRs.
3
+ > v4.49.0 — 89 agents, 17 departments, 333 skills, 298 commands, 19 ADRs.
4
4
  > One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
5
5
 
6
6
  ## What it is
@@ -45,7 +45,7 @@ answers as a generic assistant.
45
45
  | `/do` | 1 |
46
46
  | `/ecom` | 18 |
47
47
  | `/fin` | 10 |
48
- | `/kb` | 22 |
48
+ | `/kb` | 23 |
49
49
  | `/landing` | 17 |
50
50
  | `/lead` | 10 |
51
51
  | `/mkt` | 30 |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.48.0
1
+ 4.49.0
package/arka/SKILL.md CHANGED
@@ -47,7 +47,7 @@ complementary, not alternatives.
47
47
  # ArkaOS — Main Orchestrator
48
48
 
49
49
  > **The Operating System for AI Agent Teams**
50
- > 89 agents. 17 departments. 332 skills. Multi-runtime. Dashboard. Knowledge RAG.
50
+ > 89 agents. 17 departments. 333 skills. Multi-runtime. Dashboard. Knowledge RAG.
51
51
 
52
52
  ## ⛔ Evidence flow — 4 gates (NON-NEGOTIABLE)
53
53
 
@@ -152,6 +152,28 @@ function hardEnforcementOn(configResult, manifest) {
152
152
  return pythonTruthy(hooks[key]);
153
153
  }
154
154
 
155
+ /** flow_enforcer.shadow_deny_on (PR-A5a): DEFAULT ON — missing config
156
+ * file, corrupt JSON, or missing key all mean ON; an explicit value is
157
+ * python-truthy. A missing MANIFEST entry (old manifest + new engine,
158
+ * mid-upgrade only) also means ON: delegation is the
159
+ * degrade-to-latency direction, never a skipped state write. */
160
+ function shadowDenyOn(configResult, manifest) {
161
+ const spec = manifest.flags && manifest.flags.shadowDeny;
162
+ if (!spec) return true;
163
+ if (configResult.state !== "ok") return true;
164
+ const [section, key] = spec.path;
165
+ const data = configResult.data;
166
+ const hooks =
167
+ data && typeof data === "object" && !Array.isArray(data)
168
+ ? data[section]
169
+ : null;
170
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) {
171
+ return true;
172
+ }
173
+ if (!(key in hooks)) return true;
174
+ return pythonTruthy(hooks[key]);
175
+ }
176
+
155
177
  function telemetryLine(prefix, template, now) {
156
178
  return JSON.stringify({ ...prefix, ...template }) + "\n";
157
179
  }
@@ -319,16 +341,17 @@ function decidePost(payload, manifest, ctx) {
319
341
  }
320
342
 
321
343
  // Q6/Q7 — the only remaining Python work on this path is the flow-auth
322
- // confirm rescan. Redundant when enforcement is off (the pre-side flag
323
- // check short-circuits before ever reading auth) or when a confirmed
324
- // auth is fresh; load-bearing otherwise (GRACE_CAP exhaustion)
325
- // delegate.
344
+ // confirm rescan. Load-bearing when hard enforcement OR shadow-deny
345
+ // (PR-A5a) consumes the grace/confirm state without the rescan the
346
+ // shadow grace ladder overstates would_block; redundant only when
347
+ // both are off or a confirmed auth is fresh → delegate otherwise.
326
348
  const sid = String(payload.session_id || "");
327
349
  const enforcementOff =
328
350
  ctx.config.state !== "missing"
329
351
  ? !hardEnforcementOn(ctx.config, manifest)
330
352
  : true; // missing config file → flag definitively false
331
- if (!enforcementOff && !flowAuthFresh(sid, manifest, ctx)) {
353
+ const shadowOn = shadowDenyOn(ctx.config, manifest);
354
+ if ((!enforcementOff || shadowOn) && !flowAuthFresh(sid, manifest, ctx)) {
332
355
  return { action: "delegate", reason: "flow-auth-not-fresh" };
333
356
  }
334
357
 
@@ -369,6 +392,7 @@ module.exports = {
369
392
  pythonTruthy,
370
393
  readJsonFile,
371
394
  safeSessionId,
395
+ shadowDenyOn,
372
396
  toolText,
373
397
  validateManifest,
374
398
  };
@@ -605,6 +605,16 @@
605
605
  "hooks",
606
606
  "hardEnforcement"
607
607
  ]
608
+ },
609
+ "shadowDeny": {
610
+ "coercion": "python-truthy",
611
+ "on_corrupt": true,
612
+ "on_missing_file": true,
613
+ "on_missing_key": true,
614
+ "path": [
615
+ "hooks",
616
+ "shadowDeny"
617
+ ]
608
618
  }
609
619
  },
610
620
  "home_paths": {
@@ -675,7 +685,10 @@
675
685
  "marker_found": null,
676
686
  "phase_observed": null,
677
687
  "reason": "tool-not-gated",
678
- "warning": ""
688
+ "shadow_ms": 0.0,
689
+ "shadow_reason": "",
690
+ "warning": "",
691
+ "would_block": false
679
692
  },
680
693
  "kb_first_prefix_keys": [
681
694
  "ts",
@@ -249,6 +249,18 @@ def _flags_and_budget() -> dict:
249
249
  "on_corrupt": False,
250
250
  "coercion": "python-truthy",
251
251
  },
252
+ "shadowDeny": {
253
+ "path": ["hooks", "shadowDeny"],
254
+ # flow_enforcer.shadow_deny_on: DEFAULT ON — missing file,
255
+ # corrupt JSON, or missing key all mean ON (PR-A5a telemetry
256
+ # collects by default); an explicit value is python-truthy.
257
+ # The engine additionally treats a MISSING MANIFEST ENTRY as
258
+ # ON: delegation is the degrade-to-latency direction.
259
+ "on_missing_file": True,
260
+ "on_corrupt": True,
261
+ "on_missing_key": True,
262
+ "coercion": "python-truthy",
263
+ },
252
264
  "budget": {
253
265
  "section": "budget",
254
266
  "cap_keys": ["hardCapUsd", "dailyCapUsd"],
@@ -685,7 +685,36 @@ def _enqueue_cognition_capture(session_id: str, tool_text: str) -> None:
685
685
  # ─── Section 10: metrics ─────────────────────────────────────────────────
686
686
 
687
687
 
688
- def _log_metrics(duration_ms: int) -> None:
688
+ def _shadow_attribution() -> dict:
689
+ """PR-A5a: label this (delegated) run with the flag states. The
690
+ labels partition by FLAG STATE only — stateful-tool and error-turn
691
+ delegations carry the same flags — so the shadow-forced population
692
+ is isolated by combining them with the ``delegation`` field
693
+ (``_delegation_kind``): enforcement=false, shadow=true,
694
+ delegation="benign" is the flow-auth rescan population that only
695
+ reaches Python because of shadow-deny (engine.cjs Q6), plus the
696
+ negligible direct-invocation/fastpath-off runs. Empty on stripped
697
+ installs."""
698
+ try:
699
+ from core.workflow.flow_enforcer import _feature_flag_on, shadow_deny_on
700
+ return {"enforcement": _feature_flag_on(), "shadow": shadow_deny_on()}
701
+ except Exception:
702
+ return {}
703
+
704
+
705
+ def _delegation_kind(stdin_json: dict, tool_name: str, benign: bool) -> str:
706
+ """Why this run reached Python, derived from the payload in the same
707
+ precedence the shim decides delegation (engine.cjs decidePost:
708
+ failure event, then the stateful set, then the error trigger —
709
+ everything else on this path is the Q6 flow-auth rescan)."""
710
+ if get_str(stdin_json, "hook_event_name") == "PostToolUseFailure":
711
+ return "error"
712
+ if tool_name in ("ExitPlanMode", "Task", "Agent"):
713
+ return "stateful"
714
+ return "benign" if benign else "error"
715
+
716
+
717
+ def _log_metrics(duration_ms: int, attribution: dict | None = None) -> None:
689
718
  metrics_file = Path.home() / ".arkaos" / "hook-metrics.json"
690
719
  lock_file = Path.home() / ".arkaos" / "hook-metrics.lock"
691
720
  metrics_file.parent.mkdir(parents=True, exist_ok=True)
@@ -706,6 +735,7 @@ def _log_metrics(duration_ms: int) -> None:
706
735
  "hook": "post-tool-use",
707
736
  "duration_ms": duration_ms,
708
737
  "timestamp": now,
738
+ **(attribution or {}),
709
739
  })
710
740
  tmp = metrics_file.with_suffix(".json.tmp")
711
741
  tmp.write_text(json.dumps(entries[-500:]), encoding="utf-8")
@@ -776,9 +806,17 @@ def main(stdin_json: dict | None = None) -> int:
776
806
  _record_pattern_stub(tool_text, prompt)
777
807
  _record_activation(subagent_type, session_id)
778
808
 
779
- # Only process further if there was an error signal (same early exit
780
- # as the bash version violations/metrics only run on error turns).
809
+ # Only process violations/gotchas further on error turns (same early
810
+ # exit as the bash version). Metrics ARE logged here first: benign
811
+ # turns are exactly where the shadow-forced flow-auth delegations
812
+ # live — skipping them left the kill-switch population unrecorded
813
+ # (QG r2, Eduardo B1 + Francisca B3).
781
814
  if exit_code in ("0", "") and not _ERROR_TRIGGER_RE.search(tool_text):
815
+ _log_metrics(
816
+ int((time.monotonic() - start) * 1000),
817
+ {**_shadow_attribution(),
818
+ "delegation": _delegation_kind(stdin_json, tool_name, True)},
819
+ )
782
820
  print("{}")
783
821
  return 0
784
822
 
@@ -802,7 +840,11 @@ def main(stdin_json: dict | None = None) -> int:
802
840
  )
803
841
 
804
842
  _enqueue_cognition_capture(session_id, tool_text)
805
- _log_metrics(int((time.monotonic() - start) * 1000))
843
+ _log_metrics(
844
+ int((time.monotonic() - start) * 1000),
845
+ {**_shadow_attribution(),
846
+ "delegation": _delegation_kind(stdin_json, tool_name, False)},
847
+ )
806
848
 
807
849
  if violation_msg:
808
850
  # The runtime drops context whose hookEventName differs from the
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
 
29
29
  import contextlib
30
30
  import sys
31
+ import time
31
32
  from pathlib import Path
32
33
 
33
34
  from core.hooks._shared import (
@@ -55,7 +56,8 @@ def _query_hint(tool_input: dict) -> str:
55
56
 
56
57
 
57
58
  class _MessagesOnce:
58
- """Parse the transcript at most once, lazily, across both gates.
59
+ """Parse the transcript at most once, lazily, across the gates
60
+ (specialist, frontend and flow all consume the same instance).
59
61
 
60
62
  ``peek()`` never triggers a read — gates that have their own cheaper
61
63
  early-outs (feature flag off, marker-cache hit) pass ``peek()`` so the
@@ -67,6 +69,7 @@ class _MessagesOnce:
67
69
  self._path = transcript_path
68
70
  self._messages: list[str] | None = None
69
71
  self._sidechain: bool | None = None
72
+ self._load_ms: float = 0.0
70
73
 
71
74
  def peek(self) -> list[str] | None:
72
75
  return self._messages
@@ -75,8 +78,19 @@ class _MessagesOnce:
75
78
  """Scope of the most recent assistant record; None before load()."""
76
79
  return self._sidechain
77
80
 
81
+ def consume_load_ms(self) -> float:
82
+ """The parse cost in ms, claimable ONCE (PR-A5a shadow timing).
83
+
84
+ The parse a shadow evaluation forces happens here, outside every
85
+ gate's own timer — the first shadow caller claims it into its
86
+ Decision.shadow_ms; subsequent callers get 0.0 so the cost is
87
+ never double-attributed."""
88
+ elapsed, self._load_ms = self._load_ms, 0.0
89
+ return elapsed
90
+
78
91
  def load(self) -> list[str] | None:
79
92
  if self._messages is None:
93
+ start = time.perf_counter()
80
94
  try:
81
95
  # Scope-aware since P0.2: the window counts MAIN-scope
82
96
  # messages only, so interleaved subagent records cannot
@@ -88,6 +102,10 @@ class _MessagesOnce:
88
102
  self._sidechain = split.active_sidechain
89
103
  except Exception:
90
104
  return None
105
+ finally:
106
+ self._load_ms = round(
107
+ (time.perf_counter() - start) * 1000, 3
108
+ )
91
109
  return self._messages
92
110
 
93
111
 
@@ -121,6 +139,17 @@ def _kb_gate(root: str, tool_name: str, session_id: str, query: str) -> int | No
121
139
  return None
122
140
 
123
141
 
142
+ def _claim_parse_cost(decision, messages: _MessagesOnce) -> None:
143
+ """Attribute the parse a shadow evaluation forced (QG r1, Francisca
144
+ B2): the load ran outside the gate's timer, so the first shadow
145
+ decision claims it — consume_load_ms() returns 0.0 when another
146
+ gate already did, or when enforcement (not shadow) paid for it."""
147
+ if decision.shadow_reason:
148
+ decision.shadow_ms = round(
149
+ decision.shadow_ms + messages.consume_load_ms(), 3
150
+ )
151
+
152
+
124
153
  def _specialist_gate(
125
154
  root: str,
126
155
  tool_name: str,
@@ -137,6 +166,7 @@ def _specialist_gate(
137
166
  if not module_path.is_file():
138
167
  return None
139
168
  try:
169
+ from core.workflow.flow_enforcer import shadow_deny_on
140
170
  from core.workflow.specialist_enforcer import (
141
171
  _feature_flag_on,
142
172
  evaluate,
@@ -145,9 +175,11 @@ def _specialist_gate(
145
175
  except Exception:
146
176
  return None # specialist-import-failed → allow
147
177
  # Load (and share) the transcript only when the gate will actually
148
- # scan it — flag-off sessions keep the zero-read fast path.
178
+ # scan it — flag-off sessions with the shadow kill-switch thrown
179
+ # keep the zero-read fast path; shadow sessions share ONE parse
180
+ # across the specialist, frontend and flow gates (PR-A5a).
149
181
  shared = messages.peek()
150
- if shared is None and _feature_flag_on():
182
+ if shared is None and (_feature_flag_on() or shadow_deny_on()):
151
183
  shared = messages.load()
152
184
  decision = evaluate(
153
185
  tool_name=tool_name,
@@ -158,6 +190,7 @@ def _specialist_gate(
158
190
  messages=shared,
159
191
  is_sidechain=messages.sidechain_active(),
160
192
  )
193
+ _claim_parse_cost(decision, messages)
161
194
  with contextlib.suppress(Exception):
162
195
  record_telemetry(
163
196
  session_id=session_id,
@@ -16,12 +16,15 @@ Design contract:
16
16
  - Gated tool list is closed: anything outside it is always allowed.
17
17
  """
18
18
 
19
+ import contextlib
19
20
  import json
20
21
  import os
21
22
  import re
23
+ import time
22
24
  from contextlib import contextmanager
23
25
  from dataclasses import asdict, dataclass
24
- from datetime import datetime, timezone
26
+ from datetime import UTC, datetime
27
+ from functools import lru_cache
25
28
  from pathlib import Path
26
29
 
27
30
  from core.shared import safe_session_id as _safe_session_id_module
@@ -51,10 +54,8 @@ def _locked_append(path: Path):
51
54
  yield fh
52
55
  finally:
53
56
  if _HAS_FLOCK:
54
- try:
57
+ with contextlib.suppress(OSError):
55
58
  fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
56
- except OSError:
57
- pass
58
59
  fh.close()
59
60
 
60
61
  # PR11 v2.33.0 — Discovery vs Effect tool taxonomy (Conclave Phase 5).
@@ -107,7 +108,7 @@ _BASH_DISCOVERY_FIRST: frozenset[str] = frozenset({
107
108
  "shopt", "trap", "wait", "eval", "exec",
108
109
  # Test runners (read state but don't mutate canonical files)
109
110
  "pytest", "jest", "vitest", "phpunit", "pest", "rspec", "mocha",
110
- "go", "cargo",
111
+ "cargo",
111
112
  })
112
113
 
113
114
  # Bash classifier — patterns that indicate mutation (anywhere in command).
@@ -182,10 +183,8 @@ def bash_is_effect(command: str) -> bool:
182
183
  first = first_tokens[0] if first_tokens else ""
183
184
  if not first:
184
185
  return False
185
- if first in _BASH_DISCOVERY_FIRST:
186
- return False
187
- # Unknown command — default to requiring routing.
188
- return True
186
+ # Unknown commands default to requiring routing.
187
+ return first not in _BASH_DISCOVERY_FIRST
189
188
 
190
189
  ROUTING_RE = re.compile(r"\[arka:routing\]\s*[\w-]+\s*->\s*\w+", re.IGNORECASE)
191
190
  TRIVIAL_RE = re.compile(r"\[arka:trivial\]\s*\S+", re.IGNORECASE)
@@ -227,6 +226,17 @@ class Decision:
227
226
  # Interaction Reform PR3 — plan-approval telemetry (WARN phase):
228
227
  # "approved" | "missing" | "no-plan" | "unknown" | "" (not gated).
229
228
  approval_state: str = ""
229
+ # PR-A5a shadow-deny: with hardEnforcement off, the gate still runs
230
+ # and records what it WOULD have decided. `would_block` is True when
231
+ # enforcement would have denied; `shadow_reason` carries the inner
232
+ # decision's reason (shadow ran iff it is non-empty); `shadow_ms`
233
+ # (float, sub-ms preserved) measures the added evaluation cost for
234
+ # the pre-side p90 kill-switch criterion — the post-side cost is
235
+ # attributed separately in hook-metrics.json (see wiki/16). All
236
+ # three stay at their defaults on enforced paths.
237
+ would_block: bool = False
238
+ shadow_reason: str = ""
239
+ shadow_ms: float = 0.0
230
240
 
231
241
  def to_stderr_message(self) -> str:
232
242
  if self.allow:
@@ -257,13 +267,37 @@ def _feature_flag_on() -> bool:
257
267
  return bool(data.get("hooks", {}).get("hardEnforcement", False))
258
268
 
259
269
 
270
+ @lru_cache(maxsize=1)
271
+ def shadow_deny_on() -> bool:
272
+ """``hooks.shadowDeny`` — the shadow-telemetry kill-switch (PR-A5a).
273
+
274
+ DEFAULT ON: a missing config file, corrupt or undecodable JSON, or a
275
+ missing key all mean ON (the telemetry that gates the A5b flip
276
+ collects by default); only an explicit falsy value turns it off.
277
+ Mirrored by engine.cjs ``shadowDenyOn`` via the gate manifest — keep
278
+ the two in lockstep. Cached per process (each hook invocation is a
279
+ fresh interpreter, so the cache scope is one tool call; tests call
280
+ ``shadow_deny_on.cache_clear()`` after rewriting the config).
281
+ """
282
+ if not CONFIG_PATH.exists():
283
+ return True
284
+ try:
285
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
286
+ except (ValueError, OSError):
287
+ return True
288
+ hooks = data.get("hooks") if isinstance(data, dict) else None
289
+ if not isinstance(hooks, dict) or "shadowDeny" not in hooks:
290
+ return True
291
+ return bool(hooks["shadowDeny"])
292
+
293
+
260
294
  def _bypass_env_active() -> bool:
261
295
  return os.environ.get("ARKA_BYPASS_FLOW", "").strip() == "1"
262
296
 
263
297
 
264
298
  def _audit_bypass(session_id: str, tool: str, cwd: str) -> None:
265
299
  entry = {
266
- "ts": datetime.now(timezone.utc).isoformat(),
300
+ "ts": datetime.now(UTC).isoformat(),
267
301
  "session_id": session_id,
268
302
  "tool": tool,
269
303
  "cwd": cwd,
@@ -278,7 +312,7 @@ def record_telemetry(
278
312
  ) -> None:
279
313
  """Append a structured record to the enforcement telemetry log."""
280
314
  entry = {
281
- "ts": datetime.now(timezone.utc).isoformat(),
315
+ "ts": datetime.now(UTC).isoformat(),
282
316
  "session_id": session_id,
283
317
  "tool": tool,
284
318
  "cwd": cwd,
@@ -407,7 +441,7 @@ def _annotate_plan_approval(decision: Decision, session_id: str) -> Decision:
407
441
  )
408
442
  else:
409
443
  decision.approval_state = "no-plan"
410
- except Exception: # noqa: BLE001 — annotation must never break a hook
444
+ except Exception:
411
445
  decision.approval_state = "unknown"
412
446
  return decision
413
447
 
@@ -462,11 +496,61 @@ def _evaluate_flow(
462
496
  if not is_gated:
463
497
  return Decision(allow=True, reason="tool-not-gated")
464
498
 
465
- if not _feature_flag_on():
499
+ if _feature_flag_on():
500
+ return _decide_gated(
501
+ tool_name, transcript_path, session_id, cwd, messages
502
+ )
503
+ if not shadow_deny_on():
466
504
  return Decision(allow=True, reason="feature-flag-off")
505
+ return _shadow_decision(
506
+ tool_name, transcript_path, session_id, cwd, messages
507
+ )
508
+
509
+
510
+ def _shadow_decision(
511
+ tool_name: str,
512
+ transcript_path: str,
513
+ session_id: str,
514
+ cwd: str,
515
+ messages: list[str] | None,
516
+ ) -> Decision:
517
+ """Run the gated chain in shadow (PR-A5a): allow regardless, record
518
+ what enforcement would have decided. Silent by construction — the
519
+ inner decision's warning/stderr never propagates."""
520
+ start = time.perf_counter()
521
+ inner = _decide_gated(
522
+ tool_name, transcript_path, session_id, cwd, messages, shadow=True
523
+ )
524
+ return Decision(
525
+ allow=True,
526
+ reason="feature-flag-off",
527
+ marker_found=inner.marker_found,
528
+ phase_observed=inner.phase_observed,
529
+ would_block=not inner.allow,
530
+ shadow_reason=inner.reason,
531
+ shadow_ms=round((time.perf_counter() - start) * 1000, 3),
532
+ )
533
+
534
+
535
+ def _decide_gated(
536
+ tool_name: str,
537
+ transcript_path: str,
538
+ session_id: str,
539
+ cwd: str,
540
+ messages: list[str] | None,
541
+ shadow: bool = False,
542
+ ) -> Decision:
543
+ """The enforcement chain behind the feature flag.
467
544
 
545
+ ``shadow=True`` runs the identical chain for the telemetry-only
546
+ caller; the sole suppressed side effect is the bypass audit — an env
547
+ bypass of an inactive gate is not a bypass and must not pollute the
548
+ accountability log. State writes (confirm/grace) run in both modes
549
+ so the shadow would_block rate is the rate a flip would produce.
550
+ """
468
551
  if _bypass_env_active():
469
- _audit_bypass(session_id, tool_name, cwd)
552
+ if not shadow:
553
+ _audit_bypass(session_id, tool_name, cwd)
470
554
  return Decision(allow=True, reason="env-bypass", bypass_used=True)
471
555
 
472
556
  if not _flow_required_for_session(session_id):
@@ -485,6 +569,11 @@ def _evaluate_flow(
485
569
  messages = _load_last_assistant_messages(
486
570
  transcript_path, ASSISTANT_WINDOW
487
571
  )
572
+ return _decide_from_markers(session_id, messages)
573
+
574
+
575
+ def _decide_from_markers(session_id: str, messages: list[str]) -> Decision:
576
+ """Marker scan + persistent-auth fallback + grace ladder."""
488
577
  marker_found, phase_observed = _scan_markers(messages)
489
578
 
490
579
  if marker_found is not None:
@@ -511,12 +600,15 @@ def _evaluate_flow(
511
600
  return Decision(
512
601
  allow=True, reason="turn-grace", phase_observed=phase_observed
513
602
  )
603
+ return _decide_grace(session_id, phase_observed)
604
+
514
605
 
515
- # First effect-tool of a turn with no confirmed auth. A hard deny here
516
- # is a false positive (the assistant may have routed in this very
517
- # message invisible to us). Grace it with a warning; escalate to a
518
- # real block only after GRACE_CAP consecutive graced turns without any
519
- # confirmation (a normally-routing session confirms by turn 2).
606
+ def _decide_grace(session_id: str, phase_observed: str | None) -> Decision:
607
+ """First effect-tool of a turn with no confirmed auth. A hard deny
608
+ here is a false positive (the assistant may have routed in this very
609
+ message invisible to us). Grace it with a warning; escalate to a
610
+ real block only after GRACE_CAP consecutive graced turns without any
611
+ confirmation (a normally-routing session confirms by turn 2)."""
520
612
  grace = flow_authorization.register_grace(session_id)
521
613
  if grace.escalate:
522
614
  return Decision(
@@ -545,7 +637,7 @@ def mark_flow_required(session_id: str) -> None:
545
637
  return
546
638
  FLOW_REQUIRED_DIR.mkdir(parents=True, exist_ok=True)
547
639
  marker = FLOW_REQUIRED_DIR / safe
548
- marker.write_text(datetime.now(timezone.utc).isoformat(), encoding="utf-8")
640
+ marker.write_text(datetime.now(UTC).isoformat(), encoding="utf-8")
549
641
 
550
642
 
551
643
  def clear_flow_required(session_id: str) -> None: