arkaos 4.47.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 (53) 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/egress/policy.py +84 -32
  8. package/core/hooks/gate_manifest.py +12 -0
  9. package/core/hooks/post_tool_use.py +46 -4
  10. package/core/hooks/pre_tool_use.py +36 -3
  11. package/core/kb/__init__.py +9 -0
  12. package/core/kb/nlm_client.py +883 -0
  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/knowledge/commands-registry.json +25 -3
  51. package/knowledge/skills-manifest.json +14 -1
  52. package/package.json +1 -1
  53. 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.47.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.47.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",
@@ -28,6 +28,7 @@ import re
28
28
  from dataclasses import dataclass, field
29
29
  from datetime import datetime
30
30
  from pathlib import Path
31
+ from typing import Any
31
32
 
32
33
  from core.egress import allowlist, audit, redact
33
34
  from core.governance.harness_scanner import secret_labels
@@ -42,7 +43,7 @@ class Finding:
42
43
  # # | audit-unavailable | payload-not-text | guard-failure
43
44
  token: str
44
45
 
45
- def to_audit(self, salt: bytes = b"") -> dict:
46
+ def to_audit(self, salt: bytes = b"") -> dict[str, Any]:
46
47
  return {
47
48
  "kind": self.kind,
48
49
  "token_sha16": (
@@ -64,7 +65,7 @@ class EgressDecision:
64
65
  redacted_sha256: str = ""
65
66
  audited: bool = False
66
67
 
67
- def to_audit(self, salt: bytes = b"") -> dict:
68
+ def to_audit(self, salt: bytes = b"") -> dict[str, Any]:
68
69
  return {
69
70
  "allowed": self.allowed,
70
71
  "destination": self.destination,
@@ -99,26 +100,43 @@ def evaluate(
99
100
  ) -> EgressDecision:
100
101
  """Judge one payload against the policy. Never raises.
101
102
 
102
- ``home`` scopes the home-path check and the default allowlist /
103
- audit locations; ``now`` pins allowlist expiry for tests.
103
+ ``home`` scopes the home-path check, the redaction config and the
104
+ default allowlist / audit locations; ``now`` pins allowlist expiry
105
+ for tests.
104
106
  """
105
107
  destination = _safe_str(destination)
106
- # Digest computed ONCE, before any failure path, so no handler
107
- # ever re-executes the operation that failed (QG D1 r2 E-B1).
108
- payload_digest = _sha256(text) if isinstance(text, str) else ""
108
+ # ONCE, before any failure path, so no handler re-runs what
109
+ # failed. Named `digest`: `payload_digest` shadowed the public
110
+ # function here (QG D2 r12, Francisca M2).
111
+ digest = payload_digest(text) if isinstance(text, str) else ""
109
112
  try:
110
113
  decision = _judge(
111
114
  text, destination, config_path, home, allowlist_path, now
112
115
  )
113
116
  except Exception as exc: # never-raises boundary — deny, not crash
114
- decision = EgressDecision(
115
- allowed=False, destination=destination,
116
- payload_sha256=payload_digest,
117
- findings=[Finding("guard-failure", type(exc).__name__)],
118
- )
117
+ decision = _guard_failure(destination, digest, exc)
119
118
  return _audited(decision, home, audit_path, now)
120
119
 
121
120
 
121
+ def _guard_failure(
122
+ destination: str, digest: str, exc: BaseException
123
+ ) -> EgressDecision:
124
+ return EgressDecision(
125
+ allowed=False, destination=destination, payload_sha256=digest,
126
+ findings=[Finding("guard-failure", type(exc).__name__)],
127
+ )
128
+
129
+
130
+ def default_redaction_config_path(home: Path | None = None) -> Path:
131
+ """The identifier list *home* implies.
132
+
133
+ Mirrors ``leak_scanner._DEFAULT_CONFIG_PATH`` for the real home, so
134
+ scoping by ``home`` never changes production behaviour — pinned by
135
+ ``test_egress_policy.py`` rather than by this comment.
136
+ """
137
+ return (home or Path.home()) / ".arkaos" / "redaction-clients.json"
138
+
139
+
122
140
  def _safe_str(value: object) -> str:
123
141
  try:
124
142
  return str(value)
@@ -150,7 +168,7 @@ def _audited(
150
168
  return decision
151
169
 
152
170
 
153
- def enforce(text: object, destination: str, **kwargs) -> str:
171
+ def enforce(text: object, destination: str, **kwargs: Any) -> str:
154
172
  """The redacted text cleared to leave, or :class:`EgressDeniedError`."""
155
173
  decision = evaluate(text, destination, **kwargs)
156
174
  if not decision.allowed or decision.redacted_text is None:
@@ -167,41 +185,70 @@ def _judge(
167
185
  now: datetime | None,
168
186
  ) -> EgressDecision:
169
187
  if not isinstance(text, str):
170
- return EgressDecision(
171
- allowed=False, destination=destination, payload_sha256="",
172
- findings=[Finding("payload-not-text", type(text).__name__)],
173
- )
188
+ return _not_text(destination, text)
174
189
  decision = EgressDecision(
175
190
  allowed=False, destination=destination,
176
- payload_sha256=_sha256(text),
191
+ payload_sha256=payload_digest(text),
177
192
  )
178
- clean, failure = _redacted(text, config_path)
179
- if failure is not None:
180
- decision.findings.append(failure)
193
+ # One scoped path for both layers: scoping only the redaction call
194
+ # left residual_identifiers reading the real machine's list
195
+ # (QG D2 r3, Francisca B1).
196
+ scoped = _scoped_config(config_path, home)
197
+ clean = _redacted(text, scoped)
198
+ if isinstance(clean, Finding):
199
+ decision.findings.append(clean)
181
200
  return decision
182
201
  _collect_findings(
183
- decision, clean, destination, config_path, home, allowlist_path, now
202
+ decision, clean, destination, scoped, home, allowlist_path, now
184
203
  )
185
204
  if not decision.findings:
186
205
  decision.allowed = True
187
206
  decision.redacted_text = clean
188
- decision.redacted_sha256 = _sha256(clean)
207
+ decision.redacted_sha256 = payload_digest(clean)
189
208
  return decision
190
209
 
191
210
 
192
- def _sha256(text: str) -> str:
211
+ def _not_text(destination: str, text: object) -> EgressDecision:
212
+ return EgressDecision(
213
+ allowed=False, destination=destination, payload_sha256="",
214
+ findings=[Finding("payload-not-text", type(text).__name__)],
215
+ )
216
+
217
+
218
+ def payload_digest(text: str) -> str:
193
219
  """Total over any str — surrogatepass, because a payload holding a
194
220
  lone surrogate (routine from errors="surrogateescape" decoding or
195
221
  json.loads of an escape) must be DIGESTIBLE to be denied with an
196
- audit line (QG D1 r2 E-B1)."""
222
+ audit line (QG D1 r2 E-B1).
223
+
224
+ Public because callers outside this package need the same digest
225
+ the audit trail records; reaching for a private symbol made a D1
226
+ rename a silent runtime break downstream (QG D2 r1, Francisca M6).
227
+ """
197
228
  payload = text.encode("utf-8", errors="surrogatepass")
198
229
  return hashlib.sha256(payload).hexdigest()
199
230
 
200
231
 
201
- def _redacted(
202
- text: str, config_path: Path | None
203
- ) -> tuple[str | None, Finding | None]:
204
- """``(clean_text, None)`` or ``(None, denial_finding)``.
232
+ def _scoped_config(config_path: Path | None, home: Path | None) -> Path | None:
233
+ """The redaction config *home* implies, unless one was named.
234
+
235
+ ``home`` scoped ``allowlist_path`` and ``audit_path`` but NOT the
236
+ redaction config, so a caller passing ``home=`` and forgetting
237
+ ``config_path`` judged paths against one home while redacting
238
+ against another (QG D2 r1, Francisca M7).
239
+
240
+ Called from inside ``_judge``, i.e. inside ``evaluate``'s try: the
241
+ same defaulting one frame up sat OUTSIDE it, and a non-Path home
242
+ turned the never-raises boundary into a TypeError — the very shape
243
+ QG D1 r2 F-M5 already closed once.
244
+ """
245
+ if config_path is not None or home is None:
246
+ return config_path
247
+ return default_redaction_config_path(home)
248
+
249
+
250
+ def _redacted(text: str, config_path: Path | None) -> str | Finding:
251
+ """The clean text, or the finding that denies it.
205
252
 
206
253
  A redaction that CRASHES proves nothing about the payload — same
207
254
  posture as a missing config: denied, never allowlistable (QG D1
@@ -209,11 +256,16 @@ def _redacted(
209
256
  """
210
257
  try:
211
258
  clean, _counts = redact.redact(text, config_path)
212
- return clean, None
259
+ # Annotated: redact() is untyped, so `clean` arrives as Any and
260
+ # the union return silently degrades to Any (mypy no-any-return,
261
+ # surfaced only with --follow-imports=skip —
262
+ # QG D2 r3, Francisca M3).
263
+ clean_text: str = clean
264
+ return clean_text
213
265
  except redact.SanitizerConfigMissing:
214
- return None, Finding("redaction-config-missing", "")
266
+ return Finding("redaction-config-missing", "")
215
267
  except Exception as exc:
216
- return None, Finding("redaction-failed", type(exc).__name__)
268
+ return Finding("redaction-failed", type(exc).__name__)
217
269
 
218
270
 
219
271
  def _collect_findings(
@@ -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,
@@ -0,0 +1,9 @@
1
+ """Knowledge-base integrations that reach outside the machine.
2
+
3
+ Everything here goes through ``core.egress.policy`` before a byte
4
+ leaves. The package is deliberately absent from the critical path:
5
+ ``tests/python/test_notebooklm_chokepoint.py`` asserts by import graph
6
+ that ``core/governance``, ``core/workflow`` and ``core/release`` never
7
+ import it, so an upstream tool breaking can stall research but never a
8
+ gate or a release.
9
+ """