@jaguilar87/gaia 5.1.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/agents/gaia-orchestrator.md +4 -10
  5. package/bin/README.md +1 -1
  6. package/bin/cli/approvals.py +0 -144
  7. package/bin/cli/cleanup.py +1 -30
  8. package/bin/cli/context.py +379 -6
  9. package/bin/cli/doctor.py +1 -1
  10. package/bin/cli/history.py +18 -6
  11. package/bin/cli/install.py +4 -2
  12. package/bin/cli/memory.py +86 -10
  13. package/bin/cli/metrics.py +1216 -275
  14. package/bin/cli/query.py +89 -0
  15. package/bin/cli/scan.py +68 -2
  16. package/bin/cli/uninstall.py +9 -5
  17. package/bin/pre-publish-validate.js +7 -4
  18. package/gaia/project.py +49 -20
  19. package/gaia/store/reader.py +175 -11
  20. package/gaia/store/schema.sql +180 -1
  21. package/gaia/store/writer.py +1146 -165
  22. package/hooks/README.md +2 -2
  23. package/hooks/adapters/claude_code.py +6 -12
  24. package/hooks/elicitation_result.py +4 -9
  25. package/hooks/modules/agents/contract_validator.py +2 -1
  26. package/hooks/modules/agents/handoff_persister.py +5 -220
  27. package/hooks/modules/agents/task_info_builder.py +9 -2
  28. package/hooks/modules/agents/transcript_reader.py +41 -21
  29. package/hooks/modules/audit/logger.py +8 -3
  30. package/hooks/modules/context/context_injector.py +10 -3
  31. package/hooks/modules/core/__init__.py +5 -2
  32. package/hooks/modules/core/logging_setup.py +60 -0
  33. package/hooks/modules/core/paths.py +0 -12
  34. package/hooks/modules/security/approval_grants.py +24 -92
  35. package/hooks/modules/security/gaia_db_write_guard.py +7 -2
  36. package/hooks/modules/security/mutative_verbs.py +194 -11
  37. package/hooks/modules/session/pending_scanner.py +24 -222
  38. package/hooks/modules/session/session_manifest.py +13 -288
  39. package/hooks/post_compact.py +4 -9
  40. package/hooks/post_tool_use.py +4 -9
  41. package/hooks/pre_compact.py +5 -10
  42. package/hooks/pre_tool_use.py +4 -11
  43. package/hooks/session_end_hook.py +4 -9
  44. package/hooks/session_start.py +18 -21
  45. package/hooks/stop_hook.py +5 -10
  46. package/hooks/subagent_start.py +5 -10
  47. package/hooks/subagent_stop.py +9 -23
  48. package/hooks/task_completed.py +5 -10
  49. package/hooks/user_prompt_submit.py +4 -9
  50. package/package.json +1 -1
  51. package/pyproject.toml +1 -1
  52. package/scripts/migrations/schema.checksum +2 -2
  53. package/scripts/migrations/v21_to_v22.sql +80 -0
  54. package/scripts/migrations/v22_to_v23.sql +20 -0
  55. package/scripts/migrations/v23_to_v24.sql +30 -0
  56. package/scripts/migrations/v24_to_v25.sql +77 -0
  57. package/scripts/migrations/v25_to_v26.sql +116 -0
  58. package/skills/README.md +8 -0
  59. package/skills/agent-approval-protocol/SKILL.md +27 -27
  60. package/skills/agent-approval-protocol/reference.md +20 -12
  61. package/skills/agent-protocol/SKILL.md +1 -1
  62. package/skills/agent-protocol/examples.md +17 -9
  63. package/skills/agent-response/SKILL.md +1 -1
  64. package/skills/diagram-builder/GLOSSARY.md +77 -0
  65. package/skills/diagram-builder/SKILL.md +156 -0
  66. package/skills/diagram-builder/assets/README.md +45 -0
  67. package/skills/diagram-builder/assets/data/data.generated.js +77 -0
  68. package/skills/diagram-builder/assets/data/document.yaml +12 -0
  69. package/skills/diagram-builder/assets/data/pages/overview.yaml +47 -0
  70. package/skills/diagram-builder/assets/engine/build-data.mjs +51 -0
  71. package/skills/diagram-builder/assets/engine/engine.js +863 -0
  72. package/skills/diagram-builder/assets/index.html +638 -0
  73. package/skills/diagram-builder/assets/package.json +15 -0
  74. package/skills/diagram-builder/assets/tools/verify.mjs +128 -0
  75. package/skills/diagram-builder/reference.md +444 -0
  76. package/skills/execution/SKILL.md +3 -3
  77. package/skills/gaia-patterns/reference.md +2 -2
  78. package/skills/memory/SKILL.md +49 -0
  79. package/skills/orchestrator-present-approval/SKILL.md +90 -79
  80. package/skills/orchestrator-present-approval/reference.md +59 -42
  81. package/skills/orchestrator-present-approval/template.md +16 -14
  82. package/skills/pending-approvals/SKILL.md +80 -29
  83. package/skills/pending-approvals/reference.md +48 -60
  84. package/skills/security-tiers/SKILL.md +1 -1
  85. package/skills/subagent-request-approval/SKILL.md +65 -68
  86. package/skills/subagent-request-approval/reference.md +63 -63
  87. package/skills/visual-verify/SKILL.md +107 -0
  88. package/skills/visual-verify/scripts/screenshot.js +210 -0
  89. package/tools/scan/classify.py +584 -15
  90. package/tools/scan/migrate_workspace.py +9 -4
  91. package/tools/scan/store_populator.py +231 -2
  92. package/tools/scan/tests/conftest.py +31 -0
package/bin/cli/query.py CHANGED
@@ -105,6 +105,57 @@ def _render_grouped_count(rows: list[dict], group_by: str | None) -> None:
105
105
  print(f"{(str(r.get(group_by) or '')):<{key_w}} {r['count']}")
106
106
 
107
107
 
108
+ def _fmt_int(n) -> str:
109
+ return "-" if n is None else f"{int(n):,}"
110
+
111
+
112
+ def _render_metrics_agg(rows: list[dict], group_by: str | None) -> None:
113
+ """Render aggregate_metrics() output: avg compliance + token sums per group."""
114
+ if not rows:
115
+ print("(no results)")
116
+ return
117
+ key = group_by or "group"
118
+ key_vals = [str(r.get(group_by) or "(all)") if group_by else "(all)" for r in rows]
119
+ key_w = max(len(key.upper()), max(len(v) for v in key_vals))
120
+ header = (f"{key.upper():<{key_w}} {'COUNT':>6} {'AVG_COMPL':>9} "
121
+ f"{'AVG_MS':>8} {'IN_TOK':>12} {'OUT_TOK':>12} {'CACHE_RD':>14}")
122
+ print(header)
123
+ print("-" * len(header))
124
+ for r, kv in zip(rows, key_vals):
125
+ avg_c = "-" if r.get("avg_compliance_score") is None else f"{r['avg_compliance_score']:.1f}"
126
+ avg_ms = _fmt_int(r.get("avg_duration_ms"))
127
+ print(
128
+ f"{kv:<{key_w}} {r['count']:>6} {avg_c:>9} {avg_ms:>8} "
129
+ f"{_fmt_int(r.get('input_tokens')):>12} "
130
+ f"{_fmt_int(r.get('output_tokens_real')):>12} "
131
+ f"{_fmt_int(r.get('cache_read_tokens')):>14}"
132
+ )
133
+
134
+
135
+ def _render_metrics_rows(rows: list[dict]) -> None:
136
+ """Render per-episode --metrics rows (agent, compliance, tokens, model)."""
137
+ if not rows:
138
+ print("(no results)")
139
+ return
140
+ agent_w = max(len("AGENT"), max(len(r.get("agent") or "") for r in rows))
141
+ header = (f"{'TIMESTAMP':<19} {'AGENT':<{agent_w}} {'COMPL':>5} "
142
+ f"{'GR':>2} {'IN_TOK':>10} {'OUT_REAL':>10} MODEL")
143
+ print(header)
144
+ print("-" * len(header))
145
+ for r in rows:
146
+ raw = r.get("raw") or {}
147
+ ts = (r.get("timestamp") or "")[:19]
148
+ cs = raw.get("compliance_score")
149
+ cs_s = "-" if cs is None else str(cs)
150
+ grade = raw.get("compliance_grade") or "-"
151
+ print(
152
+ f"{ts:<19} {(r.get('agent') or ''):<{agent_w}} {cs_s:>5} "
153
+ f"{grade:>2} {_fmt_int(raw.get('input_tokens')):>10} "
154
+ f"{_fmt_int(raw.get('output_tokens_real')):>10} "
155
+ f"{raw.get('model_used') or '-'}"
156
+ )
157
+
158
+
108
159
  # ---------------------------------------------------------------------------
109
160
  # Entry point
110
161
  # ---------------------------------------------------------------------------
@@ -114,6 +165,7 @@ def cmd_query(args) -> int:
114
165
  from gaia.store.reader import (
115
166
  cross_surface_query,
116
167
  group_and_count,
168
+ aggregate_metrics,
117
169
  _extract_text_needle,
118
170
  _highlight_snippet,
119
171
  _row_text_for_snippet,
@@ -127,6 +179,12 @@ def cmd_query(args) -> int:
127
179
  group_by = getattr(args, "group_by", None)
128
180
  do_count = bool(getattr(args, "count", False))
129
181
  do_snippets = bool(getattr(args, "snippets", False))
182
+ do_metrics = bool(getattr(args, "metrics", False))
183
+
184
+ # --metrics projects context_metrics telemetry, which only lives on the
185
+ # episodes surface -- scope the query to it so the projection applies.
186
+ if do_metrics:
187
+ surface = "episodes"
130
188
 
131
189
  try:
132
190
  rows = cross_surface_query(
@@ -139,10 +197,32 @@ def cmd_query(args) -> int:
139
197
  agent=getattr(args, "agent", None),
140
198
  command_like=getattr(args, "command_like", None),
141
199
  failed=getattr(args, "failed", False),
200
+ metrics=do_metrics,
142
201
  )
143
202
  except ValueError as exc:
144
203
  return _err(str(exc), as_json=as_json)
145
204
 
205
+ # --metrics: project + optionally aggregate the per-turn telemetry.
206
+ if do_metrics:
207
+ if do_count or group_by:
208
+ try:
209
+ agg = aggregate_metrics(rows, group_by=group_by)
210
+ except ValueError as exc:
211
+ return _err(str(exc), as_json=as_json)
212
+ if as_json or fmt == "json":
213
+ print(json.dumps(agg, indent=2, default=str))
214
+ return 0
215
+ _render_metrics_agg(agg, group_by)
216
+ return 0
217
+ if as_json or fmt == "json":
218
+ print(json.dumps(rows, indent=2, default=str))
219
+ return 0
220
+ if fmt == "count":
221
+ print(len(rows))
222
+ return 0
223
+ _render_metrics_rows(rows)
224
+ return 0
225
+
146
226
  # --snippets: rewrite the summary field with highlighted fragments.
147
227
  # Applies only when there is a textual filter (command_like / type / agent).
148
228
  if do_snippets:
@@ -189,6 +269,8 @@ _QUERY_EPILOG = """\
189
269
  Examples:
190
270
  gaia query --since=24h --failed
191
271
  gaia query --since=7d --command-like='%git push%' --group-by=day
272
+ gaia query --metrics --since=7d --group-by=agent
273
+ gaia query --metrics --agent=developer --since=24h
192
274
  """
193
275
 
194
276
 
@@ -256,6 +338,13 @@ def register(subparsers) -> None:
256
338
  help="Replace summary with [bracketed] fragments around the textual "
257
339
  "filter. No-op without --command-like / --type / --agent.",
258
340
  )
341
+ p.add_argument(
342
+ "--metrics", action="store_true", default=False,
343
+ help="Project per-turn telemetry from episodes.context_metrics "
344
+ "(compliance_score, token counts, duration_ms, tool/api calls, "
345
+ "model). Forces --surface=episodes. Combine with --group-by / "
346
+ "--count for avg-compliance + token-sum rollups per agent/day/type.",
347
+ )
259
348
  p.add_argument(
260
349
  "--format", default="table",
261
350
  choices=("table", "json", "count"),
package/bin/cli/scan.py CHANGED
@@ -68,9 +68,35 @@ def _render_human(report, *, dry_run: bool) -> None:
68
68
  print(f"{prefix}projects:")
69
69
  for p in report.projects:
70
70
  applied = "applied" if p["applied"] else ("would-apply" if dry_run else "not-applied")
71
+ # Show the workspace -> proyecto (container) -> repo hierarchy plus
72
+ # the repo's own absolute path (M2-T4/T5). ``project`` (the DB
73
+ # storage slot) is shown only when it differs from the proyecto
74
+ # (i.e. when M1 collision-disambiguation renamed the slot).
75
+ container = p.get("container", p["project"])
76
+ slot = p["project"]
77
+ slot_note = f" slot={slot}" if slot != container else ""
71
78
  print(
72
- f" - repo={p['repo']} project={p['project']} "
73
- f"workspace={p['workspace']} [{applied}]"
79
+ f" - workspace={p['workspace']} project={container} "
80
+ f"repo={p['repo']}{slot_note} path={p.get('path')} [{applied}]"
81
+ )
82
+ # M3/T8 (AC-6): the stack fingerprint (facet rows) for this repo.
83
+ facets = p.get("facets") or []
84
+ if facets:
85
+ summary = ", ".join(
86
+ f"{f['scope']}:{f['key']}"
87
+ + (f"={f['value']}" if f.get("value") else "")
88
+ for f in facets
89
+ )
90
+ print(f" facets: {summary}")
91
+
92
+ if report.warnings:
93
+ # M2-T6 (AC-5): would-be collisions are surfaced explicitly, never
94
+ # silently merged/renamed.
95
+ print(f"{prefix}WARNING -- repo collisions (would-be silent data loss):")
96
+ for w in report.warnings:
97
+ print(
98
+ f" ! repo={w['repo']} requested_project={w['requested_project']} "
99
+ f"-> assigned={w['assigned_project']} path={w.get('path')}"
74
100
  )
75
101
 
76
102
  if report.ambiguities:
@@ -83,6 +109,46 @@ def _render_human(report, *, dry_run: bool) -> None:
83
109
  for e in report.errors:
84
110
  print(f" - repo={e['repo']} --workspace={e['W']}: {e['suggestion']}")
85
111
 
112
+ # SV2: cross-DB detection blocks (alerts only -- the human adjudicates).
113
+ if report.move_candidates:
114
+ print(f"{prefix}MOVE CANDIDATES (remote match, human adjudicates):")
115
+ for m in report.move_candidates:
116
+ src, dst = m["from"], m["to"]
117
+ print(
118
+ f" ~ {src['workspace']}/{src['project']} ({src['path']}) -> "
119
+ f"{dst['workspace']}/{dst['project']} ({dst['path']}) "
120
+ f"remote={m['remote']} confidence={m['confidence']}"
121
+ )
122
+
123
+ if report.vanished:
124
+ print(f"{prefix}VANISHED:")
125
+ for v in report.vanished:
126
+ since = v["missing_since"] if v["missing_since"] else ("pending" if dry_run else None)
127
+ print(
128
+ f" - {v['workspace']}/{v['project']} path={v['path']} "
129
+ f"remote={v['remote']} missing_since={since}"
130
+ )
131
+
132
+ if report.rename_candidates:
133
+ print(f"{prefix}RENAME CANDIDATES (folder basename != stored name):")
134
+ for r in report.rename_candidates:
135
+ print(
136
+ f" ? {r['workspace']}/{r['project']} path={r['path']} "
137
+ f"expected_name={r['expected_name']}"
138
+ )
139
+
140
+ if report.orphaned_autored:
141
+ print(f"{prefix}ORPHANED CONTEXT (vanished project carried authored content):")
142
+ for o in report.orphaned_autored:
143
+ print(
144
+ f" ! {o['workspace']}/{o['project']}: {o['description']!r} "
145
+ f"(workspace holds {o['memory_count']} memory note(s), "
146
+ f"{o['brief_count']} open brief(s))"
147
+ )
148
+
149
+ if report.diff:
150
+ print(f"{prefix}diff : {report.diff}")
151
+
86
152
  if not dry_run:
87
153
  print(f"{prefix}marked_missing : {report.marked_missing}")
88
154
 
@@ -311,11 +311,15 @@ def _print_human(result: dict, *, preuninstall: bool, dry_run: bool) -> None:
311
311
  for rel in symlinks.get("removed", []):
312
312
  verb = "Would remove symlink" if dry_run else "Removed symlink"
313
313
  print(f" {verb}: {rel}")
314
- for action in retention:
315
- verb = "Would prune" if dry_run else "Pruned"
316
- path = action.get("path", "?")
317
- label = action.get("label", "")
318
- print(f" {verb}: {path} ({label})")
314
+ if retention:
315
+ # These are routine log-hygiene prunes applied on the way out, NOT
316
+ # part of the uninstall teardown -- the header keeps that distinct.
317
+ print("\n Retention policy (routine log hygiene, not part of uninstall):")
318
+ for action in retention:
319
+ verb = "Would prune" if dry_run else "Pruned"
320
+ path = action.get("path", "?")
321
+ label = action.get("label", "")
322
+ print(f" {verb}: {path} ({label})")
319
323
 
320
324
  print()
321
325
  snapshot = result.get("snapshot") or {}
@@ -208,6 +208,11 @@ class PrePublishValidator {
208
208
  validateNodeModules() {
209
209
  this.log('Step 4: Validating node_modules installation...', 'info');
210
210
 
211
+ if (this.dryRun) {
212
+ this.log('[DRY RUN] skipping node_modules version validation', 'info');
213
+ return;
214
+ }
215
+
211
216
  if (!fs.existsSync(NODE_MODULES_INSTALL)) {
212
217
  this.log(`⚠️ ${PKG_NAME} not found in node_modules, installing...`, 'warning');
213
218
  this.reinstallNodeModules();
@@ -254,7 +259,7 @@ class PrePublishValidator {
254
259
 
255
260
  const criticalFiles = [
256
261
  'package.json',
257
- 'bin/gaia-scan',
262
+ 'bin/cli/scan.py',
258
263
  'tools/context/context_provider.py',
259
264
  'hooks/pre_tool_use.py',
260
265
  ];
@@ -527,9 +532,7 @@ class PrePublishValidator {
527
532
  try {
528
533
  // Test 1: Validate JSON files
529
534
  this.log('Test 1: Validating JSON configuration files...', 'info');
530
- const jsonFiles = [
531
- 'config/clarification_rules.json'
532
- ];
535
+ const jsonFiles = [];
533
536
 
534
537
  jsonFiles.forEach(file => {
535
538
  const filePath = path.join(baseDir, file);
package/gaia/project.py CHANGED
@@ -1,13 +1,20 @@
1
1
  """
2
2
  gaia.project -- Workspace identity model and consolidate operations.
3
3
 
4
- Workspace identity is derived from the git remote URL, normalized to the
5
- canonical form ``host/owner/repo`` (lowercase, no protocol, no .git suffix).
4
+ Workspace identity (``current()``) is PATH-based (M2-T7, AC-9): it is derived
5
+ from the repository's location on disk, not from the git remote, so it is
6
+ stable regardless of remote state and converges across vantage points (root,
7
+ subdirectory, linked worktree of the same repo all resolve identically).
6
8
 
7
- Three-level fallback:
8
- 1. Git remote URL normalized to canonical form (primary)
9
- 2. Directory name in lowercase (when no git remote)
10
- 3. Literal ``"global"`` (when neither git remote nor identifiable directory)
9
+ Three-level resolution:
10
+ 1. Git repo -> basename of the repository ROOT (via git-common-dir)
11
+ 2. Directory name in lowercase (when not a git repo)
12
+ 3. Literal ``"global"`` (when neither a git repo nor an identifiable name)
13
+
14
+ The remote-derived canonical identity (``host/owner/repo``) is still produced
15
+ by :func:`_normalize_remote`, but it is captured separately in the
16
+ ``workspaces.identity`` column by the store writer, which reads the remote
17
+ directly -- it no longer flows through ``current()``.
11
18
 
12
19
  Patterns inspired by engram (https://github.com/koaning/engram), MIT License.
13
20
  No runtime dependency on engram.
@@ -142,12 +149,28 @@ def git_common_dir(cwd: Path | str | None = None) -> str | None:
142
149
 
143
150
 
144
151
  def current(cwd: Path | str | None = None) -> str:
145
- """Return the workspace identity for the given directory.
146
-
147
- Three-level fallback:
148
- 1. Git remote URL normalized to canonical form (``host/owner/repo``)
149
- 2. Directory basename in lowercase
150
- 3. Literal ``"global"`` (no git, no identifiable directory)
152
+ """Return the workspace identity for the given directory -- PATH-BASED.
153
+
154
+ Resolution is PATH-first (M2-T7, AC-9), NOT git-remote-first. The identity
155
+ is derived from the repository's own location on disk, so it is stable
156
+ regardless of remote state and converges across vantage points:
157
+
158
+ 1. Git repo -> the basename of the REPOSITORY ROOT, resolved via
159
+ ``git rev-parse --git-common-dir`` (:func:`git_common_dir`). Because
160
+ the common dir is identical from the repo root, any nested
161
+ subdirectory, and any linked worktree, two different working-directory
162
+ paths of the SAME repo always resolve to the SAME identity -- and the
163
+ git remote never decides the identity ahead of the path.
164
+ 2. Not a git repo -> the basename of ``cwd`` in lowercase.
165
+ 3. Literal ``"global"`` (no git, no identifiable directory name).
166
+
167
+ The git remote URL is deliberately NOT consulted here. The remote-derived
168
+ canonical identity still exists, but it is captured separately in the
169
+ ``workspaces.identity`` column by the store writer
170
+ (``gaia/store/writer.py::_resolve_identity``), which reads the remote
171
+ directly -- so ``current()`` answering "which workspace am I in" stays
172
+ coherent with the path-anchored scan model (workspace -> proyecto -> repo)
173
+ while the remote identity is preserved where it belongs.
151
174
 
152
175
  Args:
153
176
  cwd: Directory to resolve identity for. Defaults to ``Path.cwd()``.
@@ -162,14 +185,20 @@ def current(cwd: Path | str | None = None) -> str:
162
185
  # If resolve fails (broken symlink, permission), fall back to global
163
186
  return "global"
164
187
 
165
- # Level 1: git remote URL
166
- remote = _git_remote_origin(target)
167
- if remote:
168
- normalized = _normalize_remote(remote)
169
- if normalized:
170
- return normalized
171
-
172
- # Level 2: directory basename
188
+ # Level 1 (PATH-based): repository root basename, vantage-independent.
189
+ # git_common_dir collapses root / subdir / worktree of the same repo to
190
+ # one path, so the identity does not diverge across vantage points and
191
+ # does not depend on the remote.
192
+ common = git_common_dir(target)
193
+ if common:
194
+ # The common dir (e.g. ``/x/repo/.git``) sits inside the repo root;
195
+ # its parent is the repository root directory.
196
+ repo_root = Path(common).parent
197
+ name = repo_root.name.lower().strip()
198
+ if name:
199
+ return name
200
+
201
+ # Level 2: directory basename (not a git repo).
173
202
  name = target.name.lower().strip()
174
203
  if name:
175
204
  return name
@@ -130,6 +130,12 @@ def _query_memory(
130
130
  where.append("type = ?")
131
131
  params.append(type_filter)
132
132
 
133
+ # scan-v2 SV3: tombstoned rows (deleted_at non-NULL) are soft-deleted and
134
+ # must never surface in a query. delete_memory() stamps deleted_at instead
135
+ # of physically removing the row, so this filter is what keeps a tombstone
136
+ # invisible to `gaia query` / cross_surface_query.
137
+ where.append("deleted_at IS NULL")
138
+
133
139
  sql = (
134
140
  "SELECT workspace, name, type, description, body, origin_session_id, "
135
141
  "updated_at "
@@ -164,6 +170,49 @@ def _query_memory(
164
170
  return out
165
171
 
166
172
 
173
+ # Metric fields projected out of episodes.context_metrics when metrics=True.
174
+ # The workflow recorder nests these under the "metrics" key of the JSON blob
175
+ # (T4 episodic-workflow-to-db); a few older migrated rows store them at the
176
+ # top level, so each extraction COALESCEs the nested path with the flat one.
177
+ # json1 is compiled into SQLite by default -- no schema migration required.
178
+ _METRIC_JSON_FIELDS = (
179
+ # (output column, json leaf path under $.metrics / $)
180
+ ("compliance_score", "compliance_score.total"),
181
+ ("compliance_grade", "compliance_score.grade"),
182
+ ("input_tokens", "input_tokens"),
183
+ ("cache_creation_tokens", "cache_creation_tokens"),
184
+ ("cache_read_tokens", "cache_read_tokens"),
185
+ ("output_tokens_real", "output_tokens_real"),
186
+ ("duration_ms", "duration_ms"),
187
+ ("tool_call_count", "tool_call_count"),
188
+ ("api_call_count", "api_call_count"),
189
+ ("model_used", "model_used"),
190
+ )
191
+
192
+ # Numeric metric fields summed by aggregate_metrics.
193
+ _METRIC_SUM_FIELDS = (
194
+ "input_tokens",
195
+ "cache_creation_tokens",
196
+ "cache_read_tokens",
197
+ "output_tokens_real",
198
+ "tool_call_count",
199
+ "api_call_count",
200
+ )
201
+
202
+
203
+ def _metric_select_columns() -> str:
204
+ """Build the json_extract projection clause for --metrics queries."""
205
+ cols = []
206
+ for out_col, leaf in _METRIC_JSON_FIELDS:
207
+ cols.append(
208
+ f"COALESCE("
209
+ f"json_extract(context_metrics, '$.metrics.{leaf}'), "
210
+ f"json_extract(context_metrics, '$.{leaf}')"
211
+ f") AS {out_col}"
212
+ )
213
+ return ", ".join(cols)
214
+
215
+
167
216
  def _query_episodes(
168
217
  con: sqlite3.Connection,
169
218
  *,
@@ -174,6 +223,7 @@ def _query_episodes(
174
223
  agent_filter: str | None,
175
224
  failed: bool,
176
225
  limit: int,
226
+ metrics: bool = False,
177
227
  ) -> list[dict]:
178
228
  where = []
179
229
  params: list[Any] = []
@@ -198,12 +248,18 @@ def _query_episodes(
198
248
  "(plan_status IN ('BLOCKED', 'NEEDS_INPUT') "
199
249
  "OR (outcome IS NOT NULL AND outcome NOT IN ('success', '')))"
200
250
  )
251
+ if metrics:
252
+ # Only rows that actually carry a metrics blob can project fields.
253
+ where.append("context_metrics IS NOT NULL")
201
254
 
202
- sql = (
203
- "SELECT episode_id, workspace, timestamp, session_id, task_id, agent, "
204
- "type, title, plan_status, outcome, exit_code, duration_seconds "
205
- "FROM episodes"
255
+ base_cols = (
256
+ "episode_id, workspace, timestamp, session_id, task_id, agent, "
257
+ "type, title, plan_status, outcome, exit_code, duration_seconds"
206
258
  )
259
+ if metrics:
260
+ sql = f"SELECT {base_cols}, {_metric_select_columns()} FROM episodes"
261
+ else:
262
+ sql = f"SELECT {base_cols} FROM episodes"
207
263
  if where:
208
264
  sql += " WHERE " + " AND ".join(where)
209
265
  sql += " ORDER BY timestamp DESC LIMIT ?"
@@ -217,13 +273,30 @@ def _query_episodes(
217
273
  ps = d.get("plan_status") or ""
218
274
  oc = d.get("outcome") or ""
219
275
  bits = [title or d.get("episode_id", "")]
220
- tail = []
221
- if ps:
222
- tail.append(f"plan_status={ps}")
223
- if oc and oc != ps:
224
- tail.append(f"outcome={oc}")
225
- if tail:
226
- bits.append("[" + ", ".join(tail) + "]")
276
+ if metrics:
277
+ # Metrics view: lead with the projected metric fields.
278
+ mtail = []
279
+ cs = d.get("compliance_score")
280
+ if cs is not None:
281
+ grade = d.get("compliance_grade") or ""
282
+ mtail.append(f"compliance={cs}{('/' + grade) if grade else ''}")
283
+ it = d.get("input_tokens")
284
+ ot = d.get("output_tokens_real")
285
+ if it is not None or ot is not None:
286
+ mtail.append(f"tok in={it if it is not None else '?'} out_real={ot if ot is not None else '?'}")
287
+ model = d.get("model_used")
288
+ if model:
289
+ mtail.append(f"model={model}")
290
+ if mtail:
291
+ bits.append("[" + ", ".join(mtail) + "]")
292
+ else:
293
+ tail = []
294
+ if ps:
295
+ tail.append(f"plan_status={ps}")
296
+ if oc and oc != ps:
297
+ tail.append(f"outcome={oc}")
298
+ if tail:
299
+ bits.append("[" + ", ".join(tail) + "]")
227
300
  out.append({
228
301
  "surface": "episodes",
229
302
  "timestamp": d.get("timestamp") or "",
@@ -435,6 +508,87 @@ def group_and_count(
435
508
  return out
436
509
 
437
510
 
511
+ def aggregate_metrics(
512
+ rows: list[dict],
513
+ *,
514
+ group_by: str | None,
515
+ ) -> list[dict]:
516
+ """Roll up ``--metrics`` episode rows into per-group summaries.
517
+
518
+ Each output bucket carries ``count``, ``avg_compliance_score`` (over rows
519
+ that have one), ``avg_duration_ms``, and the sum of every field in
520
+ ``_METRIC_SUM_FIELDS``. When ``group_by`` is ``None`` a single ``(all)``
521
+ bucket is returned. Reuses the same ``VALID_GROUP_BY`` keys as
522
+ :func:`group_and_count`; metric values are read from each row's ``raw``.
523
+ Group order is descending count, ties broken alphabetically.
524
+ """
525
+ if group_by and group_by not in VALID_GROUP_BY:
526
+ raise ValueError(
527
+ f"invalid group_by '{group_by}'; must be one of {list(VALID_GROUP_BY)}"
528
+ )
529
+
530
+ buckets: dict[str, dict] = {}
531
+ for r in rows:
532
+ raw = r.get("raw") or {}
533
+ if group_by is None:
534
+ key = "(all)"
535
+ elif group_by == "day":
536
+ key = _truncate_day(r.get("timestamp"))
537
+ elif group_by == "surface":
538
+ key = r.get("surface") or ""
539
+ else:
540
+ key = r.get(group_by) or raw.get(group_by) or ""
541
+
542
+ b = buckets.get(key)
543
+ if b is None:
544
+ b = {
545
+ "count": 0,
546
+ "compliance_sum": 0.0,
547
+ "compliance_n": 0,
548
+ "duration_sum": 0.0,
549
+ "duration_n": 0,
550
+ }
551
+ for f in _METRIC_SUM_FIELDS:
552
+ b[f] = 0
553
+ buckets[key] = b
554
+
555
+ b["count"] += 1
556
+ cs = raw.get("compliance_score")
557
+ if isinstance(cs, (int, float)):
558
+ b["compliance_sum"] += cs
559
+ b["compliance_n"] += 1
560
+ dm = raw.get("duration_ms")
561
+ if isinstance(dm, (int, float)):
562
+ b["duration_sum"] += dm
563
+ b["duration_n"] += 1
564
+ for f in _METRIC_SUM_FIELDS:
565
+ v = raw.get(f)
566
+ if isinstance(v, (int, float)):
567
+ b[f] += v
568
+
569
+ out = []
570
+ for key, b in buckets.items():
571
+ row = {
572
+ "count": b["count"],
573
+ "avg_compliance_score": (
574
+ round(b["compliance_sum"] / b["compliance_n"], 1)
575
+ if b["compliance_n"] else None
576
+ ),
577
+ "avg_duration_ms": (
578
+ round(b["duration_sum"] / b["duration_n"])
579
+ if b["duration_n"] else None
580
+ ),
581
+ }
582
+ for f in _METRIC_SUM_FIELDS:
583
+ row[f] = b[f]
584
+ if group_by:
585
+ row[group_by] = key
586
+ out.append(row)
587
+
588
+ out.sort(key=lambda d: (-d["count"], str(d.get(group_by) if group_by else "")))
589
+ return out
590
+
591
+
438
592
  def cross_surface_query(
439
593
  *,
440
594
  surface: str = "all",
@@ -446,6 +600,7 @@ def cross_surface_query(
446
600
  agent: str | None = None,
447
601
  command_like: str | None = None,
448
602
  failed: bool = False,
603
+ metrics: bool = False,
449
604
  db_path: Path | None = None,
450
605
  ) -> list[dict]:
451
606
  """Run a cross-surface analytical query against the substrate.
@@ -475,6 +630,13 @@ def cross_surface_query(
475
630
  outcome != success; harness_events: severity=error or
476
631
  result starting with fail/error). Memory surface
477
632
  has no notion of "failed" -- ignored there.
633
+ metrics: When True, the episodes surface projects the per-turn
634
+ telemetry stored in ``episodes.context_metrics``
635
+ (compliance_score, token counts, duration_ms,
636
+ tool/api call counts, model_used) via json_extract.
637
+ Only affects the episodes surface. Pair with
638
+ :func:`aggregate_metrics` for per-agent/-surface
639
+ rollups.
478
640
  db_path: Optional explicit substrate path (tests).
479
641
 
480
642
  Returns:
@@ -511,6 +673,7 @@ def cross_surface_query(
511
673
  agent_filter=agent,
512
674
  failed=failed,
513
675
  limit=last,
676
+ metrics=metrics,
514
677
  ))
515
678
  if surface in ("harness_events", "all"):
516
679
  results.extend(_query_harness_events(
@@ -539,6 +702,7 @@ __all__ = [
539
702
  "parse_when",
540
703
  "cross_surface_query",
541
704
  "group_and_count",
705
+ "aggregate_metrics",
542
706
  "_highlight_snippet",
543
707
  "_extract_text_needle",
544
708
  "_row_text_for_snippet",