App-FuguBench

 view release on metacpan or  search on metacpan

lib/App/FuguBench/Traces.pm  view on Meta::CPAN

# ex:ts=8 sw=4:
# $OpenBSD$
#
# Copyright (c) 2026 Dick Olsson <hi@senzilla.io>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

package App::FuguBench::Traces;
our $VERSION = '0.1.0';

use v5.34;
use warnings;
use experimental 'signatures';
no feature qw(indirect multidimensional bareword_filehandles);

use Cwd            ();
use File::Basename qw(basename);
use File::Spec     ();
use JSON::PP       ();

use Fugu::CLI qw(EXIT_SUCCESS EXIT_ERROR);
use Fugu::Sandbox;

# App::FuguBench::Traces - the traces verb.
#
# The verb measures the Claude Code sessions of one checkout. Claude
# Code keeps one trace directory for each working directory, under
# ~/.claude/projects/. The name of the directory is the absolute path
# of the working directory, and the harness replaces each character
# outside a letter, a digit and a hyphen with a hyphen.
#
# The verb derives that name from the checkout root, it matches the
# trace directories of the checkout, and it prints one line for each
# session in them that holds a request.
#
# The derivation cuts the root at the last .claude/worktrees/ marker,
# because a nested checkout holds the marker more than one time
# (TRACE-NAME-1). Three name forms belong to one checkout: the
# checkout itself, a worktree of it, and a project clone in either of
# them. The match takes the exact forms, so a sibling checkout, such
# as a backup, stays out (TRACE-NAME-2).
#
# The option --root names a trace root in place of the one under the
# home of the operator. The option --name replaces the derived name
# (TRACE-NAME-3).
#
# The columns are:
#
#     session  the first eight characters of the session identifier
#     start    the time of the first record of the session, in UTC
#     reqs     the requests of the main session
#     peak     the largest context of one request: the fresh input
#              tokens, the cache writes and the cache reads
#     out      the output tokens of the main session, thinking
#              included
#     panel    the rounds of the review panel
#     edits    the file edits of the main session after the first
#              panel launch: a file inside the checkout, outside
#              scratch/ and SCRATCHPAD*.md
#     sub-in   the input tokens of every sub-agent of the session
#     sub-out  the output tokens of every sub-agent
#     rev-peak the largest peak context of one panel reviewer
#
# One request writes one record for each content block, and each
# record carries the usage of the whole request. An early record can
# carry a partial count, so the verb takes the usage of the last
# record of a request (TRACE-USAGE-1). A tool_use block appears in one
# record only, so each block counts one time.
#
# The sub-in and sub-out columns hold every sub-agent together, so
# neither one measures one reviewer (TRACE-SUB-1). A panel launch is a
# tool_use block with an identifier, and each sub-agent trace has a
# sibling <agent>.meta.json that carries the identifier of its launch.
# The rev-peak column maps the launch identifiers of the panel to
# their traces, and it reports the largest peak of them (TRACE-SUB-2).
#
# This verb and the hook verb hold the Claude Code assumptions of the
# program, and every other verb is agent-agnostic (D-10).

# The marker of a worktree path, and the trace root of the harness
# under the home of the operator.
use constant MARKER   => '/.claude/worktrees/';
use constant PROJECTS => '.claude/projects';

# The format of one line. The columns are session, start, reqs, peak,
# out, panel, edits, sub-in, sub-out and rev-peak (TRACE-COLUMNS-2).
# The format lives in a variable: on the floor perl, printf reads a
# bareword in that place as a filehandle, and the pragma block of the
# file forbids one.
my $ROW = "%-8s  %-16s  %6s  %8s  %8s  %6s  %6s  %10s  %10s  %8s\n";

# The tools that change a file. The panel reviews a commit, so an edit
# of the main session after the first launch of a round is an edit
# that no reviewer saw (TRACE-PANEL-3).
my %EDIT = map { $_ => 1 } qw(Edit Write MultiEdit NotebookEdit);

# The scratch space of the repository. The panel writes its ledger
# under scratch/, an audit writes its findings to a SCRATCHPAD-<N>.md
# file, and .gitignore holds both. The name must start a path segment
# and the scratchpad must end the path, so myscratch/x.md,
# NOTSCRATCHPAD.md and SCRATCHPAD-3.md.bak stay edits.

lib/App/FuguBench/Traces.pm  view on Meta::CPAN

		    && ( $rec->{type} // q{} ) eq 'assistant';

		# An old trace carries no requestId, and its records
		# then count one by one, which is the safe direction.
		my $req = $rec->{requestId} // $rec->{uuid} // q{};
		push @order, $req unless exists $usage{$req};
		$usage{$req} = _usage_of($rec);

		for my $block ( @{ $rec->{message}{content} // [] } ) {
			next unless ( $block->{type} // q{} ) eq 'tool_use';

			if ( _is_panel($block) ) {
				$launched                      = 1;
				$round{$req}                   = 1;
				$row{launches}{ $block->{id} } = 1
				    if length( $block->{id} // q{} );
			}
			elsif (    $launched
				&& $EDIT{ $block->{name} // q{} } )
			{
				$row{edits}++
				    if _repo_edit( $block, $checkout );
			}
		}
	}
	close $fh;

	for my $req (@order) {
		my ( $in, $out ) = @{ $usage{$req} };
		$row{in}  += $in;
		$row{out} += $out;
		$row{peak} = $in if $in > $row{peak};
	}
	$row{reqs}  = scalar @order;
	$row{panel} = scalar keys %round;

	return \%row;
}

# _usage_of($rec):
#	The context and the output of one request, in that order. The
#	context is the input that the request paid for: the fresh
#	input, the cache writes and the cache reads (TRACE-COLUMNS-3).
sub _usage_of ($rec)
{
	my $u = $rec->{message}{usage} // {};
	my $in =
	    ( $u->{input_tokens}                // 0 ) +
	    ( $u->{cache_creation_input_tokens} // 0 ) +
	    ( $u->{cache_read_input_tokens}     // 0 );

	return [ $in, $u->{output_tokens} // 0 ];
}

# _repo_edit($block, $checkout):
#	True when one edit block changes a repository file. The target
#	is file_path, or notebook_path for a notebook.
#
#	An absolute target must sit inside the checkout, on a
#	directory boundary, so a write to the home of the operator, or
#	to a sibling such as <checkout>-backup, is no repository file.
#	A relative target sits inside it, because the path resolves
#	against the working directory of the session. A block with no
#	target counts as an edit, which is the safe direction
#	(TRACE-PANEL-3).
#
#	A target in scratch space is no repository file: a path under
#	scratch/, or a SCRATCHPAD*.md file.
sub _repo_edit ( $block, $checkout )
{
	my $input = $block->{input}     // {};
	my $path  = $input->{file_path} // $input->{notebook_path} // q{};
	return 0 if $path =~ m{\A/} && index( $path, "$checkout/" ) != 0;

	return $path =~ $SCRATCH ? 0 : 1;
}

# _is_panel($block):
#	One launch of one panel member. The type of the agent decides
#	first: a reviewer is a member, and another role, such as a
#	fixer, is not one. A catch-all type and an absent type name no
#	role, so the description decides. The panel of an early
#	session dispatched a catch-all agent, and the description of
#	each member names the panel (TRACE-PANEL-1).
sub _is_panel ($block)
{
	return 0 unless $LAUNCH{ $block->{name} // q{} };

	my $input = $block->{input}         // {};
	my $type  = $input->{subagent_type} // q{};
	return 1 if $type eq 'reviewer';
	return 0 if length $type && !$CATCHALL{$type};

	return ( $input->{description} // q{} ) =~ /panel/i ? 1 : 0;
}

# _meta_id($path):
#	The launch identifier of one sub-agent trace. The trace has a
#	sibling meta file, and the file names the tool_use block that
#	launched the sub-agent. A trace with no meta file gives the
#	empty string, which matches no launch (TRACE-SUB-2).
sub _meta_id ($path)
{
	my $meta = $path =~ s/\.jsonl\z/.meta.json/r;
	open my $fh, '<', $meta or return q{};
	my $text = do { local $/ = undef; <$fh> };
	close $fh;

	my $rec = eval { $JSON->decode( $text // q{} ) };

	return ref $rec eq 'HASH' ? $rec->{toolUseId} // q{} : q{};
}

1;



( run in 0.753 second using v1.01-cache-2.11-cpan-5e09290becf )