arkaos 4.48.0 → 5.0.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 (54) 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/governance/harness_scanner.py +108 -30
  8. package/core/governance/harness_scanner_cli.py +220 -8
  9. package/core/harness/remediation.py +644 -0
  10. package/core/hooks/gate_manifest.py +12 -0
  11. package/core/hooks/post_tool_use.py +46 -4
  12. package/core/hooks/pre_tool_use.py +36 -3
  13. package/core/workflow/flow_enforcer.py +112 -20
  14. package/core/workflow/frontend_gate.py +64 -5
  15. package/core/workflow/specialist_enforcer.py +48 -15
  16. package/departments/kb/SKILL.md +1 -0
  17. package/departments/kb/skills/research-deep/SKILL.md +192 -0
  18. package/departments/kb/skills/research-deep/THIRD-PARTY-NOTICES.md +15 -0
  19. package/harness/codex/AGENTS.md +2 -2
  20. package/harness/copilot/copilot-instructions.md +2 -2
  21. package/harness/cursor/rules/arkaos.mdc +3 -3
  22. package/harness/gemini/GEMINI.md +2 -2
  23. package/harness/opencode/AGENTS.md +2 -2
  24. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  25. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  26. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  27. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  28. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  29. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  30. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  31. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  32. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  33. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  34. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  35. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  36. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  37. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  38. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  39. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  40. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  41. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  42. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  43. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  44. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  45. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  46. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  47. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  48. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  49. package/harness/zed/.rules +2 -2
  50. package/installer/cli.js +9 -3
  51. package/knowledge/commands-registry.json +25 -3
  52. package/knowledge/skills-manifest.json +14 -1
  53. package/package.json +1 -1
  54. package/pyproject.toml +1 -1
@@ -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:
@@ -35,8 +35,9 @@ from __future__ import annotations
35
35
  import json
36
36
  import os
37
37
  import re
38
+ import time
38
39
  from dataclasses import asdict, dataclass
39
- from datetime import datetime, timezone
40
+ from datetime import UTC, datetime
40
41
  from pathlib import Path
41
42
 
42
43
  from core.shared import safe_session_id as _safe_session_id_module
@@ -44,6 +45,7 @@ from core.workflow import design_authorization
44
45
  from core.workflow.flow_enforcer import (
45
46
  TRIVIAL_RE,
46
47
  _load_last_assistant_messages,
48
+ shadow_deny_on,
47
49
  )
48
50
  from core.workflow.specialist_enforcer import _locked_append
49
51
 
@@ -93,6 +95,11 @@ class Decision:
93
95
  marker_found: str | None = None
94
96
  marker_kind: str = "none" # structured | legacy | trivial | none
95
97
  ui_scope: str = "suffix" # suffix | heuristic
98
+ # PR-A5a shadow-deny — see flow_enforcer.Decision: what hard mode
99
+ # WOULD have decided, recorded while warn/off modes keep allowing.
100
+ would_block: bool = False
101
+ shadow_reason: str = ""
102
+ shadow_ms: float = 0.0
96
103
 
97
104
  def to_stderr_message(self) -> str:
98
105
  if self.reason == "legacy-marker":
@@ -237,6 +244,51 @@ def _marker_decision(
237
244
  ui_scope=ui_scope)
238
245
 
239
246
 
247
+ def _shadow_off_decision(
248
+ session_id: str,
249
+ file_path: str,
250
+ ui_scope: str,
251
+ transcript_path: str,
252
+ messages: list[str] | None,
253
+ ) -> Decision:
254
+ """Off mode with shadow-deny on (PR-A5a): evaluate the marker chain
255
+ anyway and record what hard mode would have decided — silently
256
+ (reason "flag-off" produces no stderr message)."""
257
+ if os.environ.get("ARKA_BYPASS_DESIGN") == "1":
258
+ return Decision(allow=True, reason="flag-off", mode="off",
259
+ target_file=file_path, ui_scope=ui_scope,
260
+ shadow_reason="env-bypass")
261
+ start = time.perf_counter()
262
+ if messages is None:
263
+ messages = _load_last_assistant_messages(
264
+ transcript_path, ASSISTANT_WINDOW
265
+ )
266
+ marker, kind = _classify_marker(messages)
267
+ inner = _marker_decision(session_id, marker, kind, "hard", file_path, ui_scope)
268
+ return Decision(
269
+ allow=True, reason="flag-off", mode="off", target_file=file_path,
270
+ marker_found=inner.marker_found, marker_kind=inner.marker_kind,
271
+ ui_scope=ui_scope, would_block=not inner.allow,
272
+ shadow_reason=inner.reason,
273
+ shadow_ms=round((time.perf_counter() - start) * 1000, 3),
274
+ )
275
+
276
+
277
+ def _annotate_warn_shadow(decision: Decision) -> Decision:
278
+ """Warn mode already evaluates the full chain — annotate what hard
279
+ mode would have denied (suffix scope, no structured/trivial/persisted
280
+ marker). Zero added transcript reads; gated on the same kill-switch."""
281
+ if (
282
+ decision.allow
283
+ and decision.ui_scope == "suffix"
284
+ and decision.reason in ("legacy-marker", "no-design-marker")
285
+ and shadow_deny_on()
286
+ ):
287
+ decision.would_block = True
288
+ decision.shadow_reason = decision.reason
289
+ return decision
290
+
291
+
240
292
  def evaluate(
241
293
  tool_name: str,
242
294
  transcript_path: str,
@@ -252,8 +304,12 @@ def evaluate(
252
304
  return Decision(allow=True, reason="not-ui-scope", target_file=file_path)
253
305
  mode = _mode()
254
306
  if mode == "off":
255
- return Decision(allow=True, reason="flag-off", mode=mode,
256
- target_file=file_path, ui_scope=ui_scope)
307
+ if not shadow_deny_on():
308
+ return Decision(allow=True, reason="flag-off", mode=mode,
309
+ target_file=file_path, ui_scope=ui_scope)
310
+ return _shadow_off_decision(
311
+ session_id, file_path, ui_scope, transcript_path, messages
312
+ )
257
313
  if os.environ.get("ARKA_BYPASS_DESIGN") == "1":
258
314
  return Decision(allow=True, reason="env-bypass", mode=mode,
259
315
  target_file=file_path, ui_scope=ui_scope)
@@ -262,7 +318,10 @@ def evaluate(
262
318
  transcript_path, ASSISTANT_WINDOW
263
319
  )
264
320
  marker, kind = _classify_marker(messages)
265
- return _marker_decision(session_id, marker, kind, mode, file_path, ui_scope)
321
+ decision = _marker_decision(session_id, marker, kind, mode, file_path, ui_scope)
322
+ if mode == "warn":
323
+ decision = _annotate_warn_shadow(decision)
324
+ return decision
266
325
 
267
326
 
268
327
  def record_telemetry(session_id: str, tool: str, decision: Decision) -> None:
@@ -275,7 +334,7 @@ def record_telemetry(session_id: str, tool: str, decision: Decision) -> None:
275
334
  if safe is None:
276
335
  return
277
336
  entry = {
278
- "ts": datetime.now(timezone.utc).isoformat(),
337
+ "ts": datetime.now(UTC).isoformat(),
279
338
  "session_id": safe,
280
339
  "tool": tool,
281
340
  **asdict(decision),
@@ -39,8 +39,9 @@ flow-gate. Same Decision JSON contract as core.workflow.flow_enforcer.
39
39
  import contextlib
40
40
  import json
41
41
  import re
42
+ import time
42
43
  from contextlib import contextmanager
43
- from dataclasses import asdict, dataclass, field
44
+ from dataclasses import asdict, dataclass, field, replace
44
45
  from datetime import UTC, datetime
45
46
  from functools import lru_cache
46
47
  from pathlib import Path
@@ -49,6 +50,7 @@ import yaml
49
50
 
50
51
  from core.shared import safe_session_id as _safe_session_id_module
51
52
  from core.workflow import specialist_authorization, transcript_scope
53
+ from core.workflow.flow_enforcer import shadow_deny_on
52
54
 
53
55
  try:
54
56
  import fcntl # POSIX only
@@ -138,6 +140,11 @@ class Decision:
138
140
  persona_source: str = ""
139
141
  is_sidechain: bool = False
140
142
  msgs_since_marker: int | None = None
143
+ # PR-A5a shadow-deny — see flow_enforcer.Decision: what enforcement
144
+ # WOULD have decided while hooks.specialistEnforcement stays off.
145
+ would_block: bool = False
146
+ shadow_reason: str = ""
147
+ shadow_ms: float = 0.0
141
148
 
142
149
  def to_stderr_message(self) -> str:
143
150
  """The message a blocked session reads.
@@ -432,10 +439,38 @@ def _check_tool_gated(ctx: _Ctx) -> Decision | None:
432
439
  return None
433
440
 
434
441
 
435
- def _check_feature_flag(ctx: _Ctx) -> Decision | None:
436
- if not _feature_flag_on():
437
- return Decision(allow=True, reason="feature-flag-off")
438
- return None
442
+ def _decide_populated(ctx: _Ctx) -> Decision:
443
+ """The enforcement pipeline behind the feature flag: populate the
444
+ context, run the late checks, resolve ownership."""
445
+ _populate_context(ctx)
446
+ for late_check in (_check_no_persona, _check_c_suite, _check_lead_allowed_file):
447
+ decision = late_check(ctx)
448
+ if decision is not None:
449
+ return decision
450
+ return _resolve_ownership_outcome(ctx)
451
+
452
+
453
+ def _shadow_decision(ctx: _Ctx) -> Decision:
454
+ """Run the pipeline in shadow (PR-A5a): allow regardless, record what
455
+ enforcement would have decided, carrying the resolved persona and
456
+ ownership context into telemetry. Silent by construction — allow=True
457
+ means to_stderr_message() returns "". bypass_used/bypass_reason are
458
+ reset: a bypass of an inactive gate is not a bypass and must not
459
+ inflate the specialist-telemetry counter — shadow_reason still says
460
+ "bypass-with-reason", so no information is lost (QG r1, Francisca B1).
461
+ """
462
+ start = time.perf_counter()
463
+ inner = _decide_populated(ctx)
464
+ return replace(
465
+ inner,
466
+ allow=True,
467
+ reason="feature-flag-off",
468
+ bypass_used=False,
469
+ bypass_reason=None,
470
+ would_block=not inner.allow,
471
+ shadow_reason=inner.reason,
472
+ shadow_ms=round((time.perf_counter() - start) * 1000, 3),
473
+ )
439
474
 
440
475
 
441
476
  def _msgs_since_marker(messages: list[str]) -> int | None:
@@ -626,16 +661,14 @@ def evaluate(
626
661
  session_id=session_id, cwd=cwd, tool_input=tool_input or {},
627
662
  preloaded_messages=messages, is_sidechain=is_sidechain,
628
663
  )
629
- for early_check in (_check_tool_gated, _check_feature_flag):
630
- decision = early_check(ctx)
631
- if decision is not None:
632
- return decision
633
- _populate_context(ctx)
634
- for late_check in (_check_no_persona, _check_c_suite, _check_lead_allowed_file):
635
- decision = late_check(ctx)
636
- if decision is not None:
637
- return decision
638
- return _resolve_ownership_outcome(ctx)
664
+ decision = _check_tool_gated(ctx)
665
+ if decision is not None:
666
+ return decision
667
+ if _feature_flag_on():
668
+ return _decide_populated(ctx)
669
+ if not shadow_deny_on():
670
+ return Decision(allow=True, reason="feature-flag-off")
671
+ return _shadow_decision(ctx)
639
672
 
640
673
 
641
674
  def record_telemetry(
@@ -24,6 +24,7 @@ allowed-tools: [Read, Write, Edit, Bash, Grep, Glob, Agent, WebFetch, WebSearch]
24
24
  | `/kb learn <url>` | Ingest content (YouTube, article, PDF) into KB | Enterprise |
25
25
  | `/kb research <topic>` | Research plan and execution with source evaluation | Focused |
26
26
  | `/kb ai-research <topic>` | AI-augmented research (Elicit, Perplexity, Claude) | Focused |
27
+ | `/kb research-deep <question>` | Ladder over the operator's own corpus: Obsidian → NotebookLM (egress-guarded) → web, with vault write-back | Focused |
27
28
  | `/kb persona <name>` | Build or view a persona from the KB | Enterprise |
28
29
  | `/kb moc <cluster>` | Create/update Map of Content | Specialist |
29
30
  | `/kb zettelkasten <note>` | Process a note through Zettelkasten workflow | Specialist |