File Coverage

File:lib/SimpleFlow.pm
Coverage:90.4%

linestmtbrancondsubtimecode
1# ABSTRACT: SimpleFlow - easy, simple workflow manager (and logger); for keeping track of and debugging large and complex shell command workflows
2
2
2
2
2
0
14
use strict;
3
2
2
2
3
2
33
use warnings FATAL => 'all';
4require 5.010;
5
2
2
2
2
1
63
use feature 'say';
6
2
2
2
53
9654
6
use DDP {output => 'STDOUT', array_max => 10, show_memsize => 1};
7
2
2
2
288
5685
3
use Devel::Confess 'color';
8
2
2
2
64
0
38
use Cwd 'getcwd';
9package SimpleFlow;
10our $VERSION = 0.14;
11
2
2
2
2
1
4
use Time::HiRes;
12
2
2
2
340
5057
74
use Term::ANSIColor;
13# Windows portability: the legacy Windows console (cmd.exe) prints raw ANSI
14# escape sequences as garbage. Disable colouring there unless a terminal that
15# understands ANSI is in use (Windows Terminal, ConEmu, ANSICON). Unix and
16# modern Windows terminals are left untouched.
17BEGIN {
18        $ENV{ANSI_COLORS_DISABLED} = 1
19                if $^O eq 'MSWin32'
20                && !$ENV{WT_SESSION} # Windows Terminal
21                && !$ENV{ConEmuANSI} # ConEmu
22
2
18
                && !$ENV{ANSICON};   # ANSICON
23}
24
2
2
2
4
1
23
use Scalar::Util 'openhandle';
25
2
2
2
2
2
5
use DDP {output => 'STDOUT', array_max => 10, show_memsize => 1};
26
2
2
2
61
1
3
use Devel::Confess 'color';
27
2
2
2
58
1
19
use Cwd 'getcwd';
28
2
2
2
2
0
27
use warnings FATAL => 'all';
29
2
2
2
240
2768
32
use Capture::Tiny 'capture';
30
2
2
2
3
1
22
use List::Util 'max';
31
2
2
2
2
2
1476
use Exporter 'import';
32our @EXPORT = qw(say2 task);
33our @EXPORT_OK = @EXPORT;
34
35sub say2 { # say to both command line and log file
36
2
4
        my ($msg, $fh) = @_;
37
2
6
        my $current_sub = (split(/::/,(caller(0))[3]))[-1]; # https://stackoverflow.com/questions/2559792/how-can-i-get-the-name-of-the-current-subroutine-in-perl
38
2
30
        my @c = caller;
39
2
21
        if (not openhandle($fh)) {
40
1
5
                die "the filehandle given to $current_sub with \"$msg\" from $c[1] line $c[2] isn't actually a filehandle";
41        }
42
1
1
        $msg = "\@ $c[1] line $c[2] $msg";
43
1
2
        say $msg;
44
1
1
        say $fh $msg;
45
1
1
        return $msg;
46}
47
48sub task {
49
32
99
        my $current_sub = (split(/::/,(caller(0))[3]))[-1];
50        # Accept either a single hash ref -- task({ cmd => ... }) -- or a flat
51        # key/value list -- task(cmd => ...). A lone non-hashref scalar, or an
52        # odd-length list, can't be read either way and is fatal.
53
32
548
        my $args;
54
32
181
        if (@_ == 1 && ref $_[0] eq 'HASH') {
55
29
23
                $args = $_[0];
56        } elsif (@_ % 2 == 0) {
57
2
2
                $args = { @_ };
58        } else {
59
1
4
                die "args to $current_sub must be a hash ref (e.g. $current_sub({ cmd => ... })) or a flat key/value list (e.g. $current_sub(cmd => ...)); got an odd-length list";
60        }
61
31
37
        my @c = caller;
62
31
316
        my @reqd_args = (
63                'cmd', # the shell command
64        );
65
31
31
45
51
        my @undef_args = grep { !defined $args->{$_}} @reqd_args;
66
31
55
        if (scalar @undef_args > 0) {
67
1
2
                p @undef_args;
68
1
1146
                die 'the above args are necessary, but were not defined.';
69        }
70
30
50
        my @defined_args = ( @reqd_args,
71                'die',                    # die if not successful; 0 or 1
72                'dry.run',       # dry run or not
73                'input.files',   # check for input files; SCALAR or ARRAY
74                'log.fh',
75                'note',          # a note for the log
76                'overwrite',     #
77                'output.files'    # product files that need to be checked; can be scalar or array
78        );
79
30
64
64
512
30
25
44
47
326
36
        my @bad_args = grep { my $key = $_; not grep {$_ eq $key} @defined_args} keys %{ $args };
80
30
43
        if (scalar @bad_args > 0) {
81
1
2
                p @bad_args, array_max => scalar @bad_args;
82
1
1065
                say "the above arguments are not recognized by $current_sub";
83
1
1
                p @defined_args, array_max => scalar @defined_args;
84
1
1703
                die "The above args are accepted by $current_sub";
85        }
86
29
57
        if (
87                        (defined $args->{'log.fh'}) &&
88                        (not openhandle($args->{'log.fh'}))
89                ) {
90
1
1
                p $args;
91
1
1236
                die "the filehandle given to $current_sub isn't actually a filehandle";
92        }
93
28
43
        my (%input_file_size, @existing_files, @output_files, @empty_filenames);
94
28
47
        if (defined $args->{'input.files'}) {
95
6
5
                my $ref = ref $args->{'input.files'};
96
6
4
                my @missing_files;
97
6
10
                if ($ref eq 'ARRAY') {
98
1
2
1
1
7
1
                        @missing_files = grep {not -f -r $_ } @{ $args->{'input.files'} };
99
1
2
1
1
4
1
                        %input_file_size = map { $_ => -s $_ } @{ $args->{'input.files'} };
100
1
2
1
1
5
0
                        @empty_filenames = grep {(defined $_) && (length $_ == 0)} @{ $args->{'input.files'} };
101                } elsif ($ref eq '') { # scalar
102
4
4
6
12
                        @missing_files = grep {not -f -r $_ } ($args->{'input.files'});
103
4
4
4
6
                        %input_file_size = map { $_ => -s $_ } ($args->{'input.files'} );
104
4
4
4
13
                        @empty_filenames = grep {(defined $_) && (length $_ == 0)} ($args->{'input.files'});
105                } else {
106
1
2
                        p $args;
107
1
4371
                        die 'ref type "' . $ref . '" is not allowed for "input.files"';
108                }
109
5
6
                if (scalar @missing_files > 0) {
110
3
10
                        say STDERR 'this list of arguments:';
111
3
5
                        p $args;
112
3
3665
                        say STDERR 'Cannot run because these files are either missing or unreadable in: ' . getcwd();
113
3
9
                        p @missing_files;
114
3
2994
                        die 'the above files are missing or are not readable';
115                }
116        }
117
24
27
        if (scalar @empty_filenames > 0) {
118
0
0
                p $args;
119
0
0
                die '0-length filenames are not allowed (found in "input.files")';
120        }
121
24
144
        my $msg = "\@ $c[1] line $c[2] The command is:\n" . colored(['blue on_bright_red'], $args->{cmd});
122
24
886
        say $msg;
123
24
1
53
4
        say {$args->{'log.fh'}} "\@ $c[1] line $c[2] The command is:\n$args->{cmd}" if defined $args->{'log.fh'};
124
24
39
        if (defined $args->{'output.files'}) { # avoid "uninitialized value" warning
125
8
13
                my $ref = ref $args->{'output.files'};
126
8
19
                if ($ref eq 'ARRAY') {
127
1
1
0
1
                        @output_files = @{ $args->{'output.files'} };
128                } elsif ($ref eq '') { # a scalar
129
6
10
                        @output_files = $args->{'output.files'};
130                } else {
131
1
2
                        p $args;
132
1
1402
                        die "$ref isn't allowed for \"output.files\"";
133                }
134        }
135
23
7
26
22
        @empty_filenames = grep {(defined $_) && (length $_ == 0)} @output_files; # 0-length filenames aren't allowed
136
23
27
        if (scalar @empty_filenames > 0) {
137
1
2
                p $args;
138
1
1237
                die '0-length filenames are not allowed (found in "output.files"';
139        }
140
22
43
        if (scalar @output_files > 0) {
141
6
6
6
24
                @existing_files = grep {-f $_} @output_files;
142        }
143        my %r = (
144                cmd            => $args->{cmd},
145
22
147
                dir                             => getcwd(),
146                'source.file'  => $c[1],
147                'source.line'  => $c[2],
148                'output.files' => [@output_files],
149        );
150
22
72
        $r{'die'}     = $args->{'die'}     // 1; # by default, true
151
22
88
        $r{'dry.run'} = $args->{'dry.run'} // 0; # by default, false
152
22
68
        $r{note}      = $args->{note}      // '';# by default, false
153
22
56
        $r{overwrite} = $args->{overwrite} // 0; # by default, false
154
22
19
        $r{'will.do'} = 'yes';
155
22
22
        $r{'will.do'} = 'no: dry run'  if $args->{'dry.run'};
156
22
14
        my $string_max = 0;
157
22
20
        if (defined $args->{'input.files'}) {
158
2
1
                $r{'input.files'} = $args->{'input.files'};
159
2
1
                $r{'input.file.size'} = \%input_file_size;
160        }
161
22
6
21
26
        my %output_file_size = map {$_ => -s $_} @output_files;
162
22
224
35
234
        foreach my $val (grep {ref $r{$_} eq ''} keys %r) {
163
199
203
                $string_max = max($string_max, length $r{$val});
164        }
165
22
82
        if ((!$args->{overwrite}) && (scalar @output_files > 0) && (scalar @existing_files == scalar @output_files)) { # this has been done before
166
1
1
                $r{done} = 'before';
167
1
1
                $r{'will.do'} = 'no';
168
1
3
                say colored(['black on_green'], "\"$args->{cmd}\"\n") . ' has been done before';
169
1
23
                $r{'output.file.size'} = \%output_file_size;
170
1
2
                $r{duration} = 0;
171
1
2
                p(%r, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
172
1
1
                p %r, string_max => $string_max;
173
1
2921
                return \%r;
174        } else {
175
21
22
                $r{done} = 'not yet';
176        }
177
21
28
        if ($r{'dry.run'}) {
178
2
3
                say "\@ $c[1] line $c[2] in $r{dir} the command was going to be:";
179
2
5
                say colored(['red on_black'], "\"$args->{cmd}\"");
180
2
42
                say 'But this is a dry run';
181
2
2
                say '-------------';
182
2
2
                $r{duration} = 0;
183
2
6
                return \%r;
184        }
185
19
35
        my $t0 = Time::HiRes::time();
186
19
12
        my $status;
187        ($r{stdout}, $r{stderr}, $status) = capture {
188
19
84412
                system( $args->{cmd} );
189
19
551
        };
190
19
9165
        my $t1 = Time::HiRes::time();
191
19
35
        $r{duration} = $t1-$t0;
192        # Decode the raw wait status. On Unix the low 7 bits hold the death
193        # signal and the high byte holds the exit code. The signal MUST be read
194        # from the raw status *before* shifting -- the old code shifted first and
195        # then did ($exit & 127), so $r{signal} was always 0 and could never
196        # detect a kill by signal 9/15. Windows has no POSIX signals, and a -1
197        # return from system() means the command never launched.
198
19
108
        if (!defined $status || $status == -1) {
199
0
0
                $r{'exit'}   = -1;
200
0
0
                $r{signal}   = 0;
201        } elsif ($^O eq 'MSWin32') {
202
0
0
                $r{signal}   = 0;
203
0
0
                $r{'exit'}   = $status >> 8;
204        } else {
205
19
62
                $r{signal}   = $status & 127; # FIX: taken from raw status, not from $exit
206
19
59
                $r{'exit'}   = $status >> 8;
207        }
208
19
43
        foreach my $std ('stderr', 'stdout') {
209
38
75
                $r{$std} =~ s/\s+$//; # remove trailing whitespace/newline
210
38
58
                $string_max = max($string_max, length $r{$std});
211        }
212
19
41
        $r{done} = 'now';
213
19
16
        $r{'will.do'} = 'done';
214
19
5
27
28
        my @missing_output_files = grep {not -f -r $_} @output_files;
215
19
36
        if (scalar @missing_output_files > 0) {
216
1
7
                $r{'will.do'} = 'FAILED';
217
1
9
                say STDERR "this input to $current_sub:";
218
1
21
                p $args;
219
1
0
3485
0
                say {$args->{'log.fh'}} "this input to $current_sub:" if defined $args->{'log.fh'};
220
1
4
                p($args, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
221
1
3
                say STDERR 'has these output files missing:';
222
1
0
1
0
                say {$args->{'log.fh'}} 'has these output files missing:' if defined $args->{'log.fh'};
223
1
3
                p @missing_output_files;
224
1
1804
                p(@missing_output_files, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
225
1
15
                p %r, string_max => $string_max;
226
1
3980
                p(%r, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
227
1
3
                if ($r{'die'}) { # use the resolved value (defaults to 1), not the raw arg
228
0
0
                        die 'those above files should have been made but are missing';
229                } else {
230
1
2
                        say STDERR 'those above files should have been made but are missing';
231                }
232        }
233
19
5
23
25
        %output_file_size = map {$_ => -s $_} @output_files;
234
19
21
        $r{'output.file.size'} = \%output_file_size;
235#       p %output_file_size;
236
19
5
19
34
        my @files_with_zero_size = grep { ($output_file_size{$_} // 0) == 0 } @output_files;
237
19
26
        if (scalar @files_with_zero_size > 0) {
238
2
5
                p @files_with_zero_size;
239
2
2233
                warn 'the above output files have 0 size.';
240        }
241
19
346
        p(%r, output => $args->{'log.fh'}) if defined $args->{'log.fh'};
242
19
3540
        if (($r{'die'}) && ($r{'exit'} != 0)) {
243
1
3
                $r{'will.do'} = 'FAILED';
244
1
5
                p %r, string_max => $string_max;
245
1
3297
                die "\"$args->{cmd}\" failed from $c[1] line $c[2]"
246        }
247
18
78
        p %r, string_max => $string_max;
248
18
93943
        return \%r;
249}
2501;
251
252=encoding utf8
253
254A tiny workflow manager and logger for Perl, like SnakeMake or NextFlow, but in pure Perl and aimed at making long, error-prone shell pipelines easy to B<debug> and B<reproduce>.
255
256Every step is a single C<task()> call. SimpleFlow checks the inputs before a
257command runs and the outputs after, times the command, captures its C<stdout>,
258C<stderr>, exit code and signal, optionally logs a full structured record, and
259skips work that has already been done.
260
261Two subroutines are exported by default: L</"task"> and L</"say2">.
262
263 - 650
=head1 Install

With a CPAN client:

 cpanm SimpleFlow

Or from a checkout:

 perl Makefile.PL
 make
 make test
 make install

=head1 Synopsis

The simplest useful case: run a command and confirm it produced its output:

 use SimpleFlow qw(task say2);
 
 my $t = task({
     cmd            => 'which ls',
     'output.files' => '/tmp/AFK3mnEK8L.log',
 });

C<task> returns a hash reference describing exactly what happened:

 {
     cmd            "which ls",
     die            1,
     dir            "/home/con/Scripts/SimpleFlow",
     done           "now",
     dry.run        0,
     duration       0.00191903114318848,
     exit           0,
     note           "",
     output.files   [
         [0] "/tmp/AFK3mnEK8L.log"
     ],
     overwrite      1,
     signal         0,
     source.file    "t/01.t",
     source.line    29,
     stderr         "",
     stdout         "/usr/bin/ls",
     will.do        "done"
 }

 > B<Portability note.> SimpleFlow runs whatever shell command you give it via
 > C<system()>, so the I<commands themselves> are your responsibility to keep
 > cross-platform (e.g. C<which ls> is Unix-only). SimpleFlow's own behaviour
 > exit/signal decoding and coloured output is cross-platform; see the
 > L<change log|/"Change log">.

=head1 C<task>

 my $result = task(\%args);

Runs one shell command with checking, timing, capture and logging. Takes a
B<single hash reference>; the only required key is C<cmd>.

=head2 Arguments



=begin html

<table>
<thead>
<tr>
  <th>Key</th>
  <th>Type</th>
  <th>Default</th>
  <th>Description</th>
</tr>
</thead>
<tbody>
<tr>
  <td><code>cmd</code></td>
  <td>scalar</td>
  <td><code>undef</code></td>
  <td><b>Required.</b> The shell command to run.</td>
</tr>
<tr>
  <td><code>die</code></td>
  <td>bool (<code>0</code>/<code>1</code>)</td>
  <td><code>1</code></td>
  <td>Die if the command fails (non-zero exit) or an output file is missing. Set to <code>0</code> to warn and continue instead.</td>
</tr>
<tr>
  <td><code>dry.run</code></td>
  <td>bool</td>
  <td><code>0</code></td>
  <td>Print the command (and log it) but do not execute it.</td>
</tr>
<tr>
  <td><code>input.files</code></td>
  <td>scalar or array</td>
  <td><code>undef</code></td>
  <td>File(s) that must exist and be readable <b>before</b> running; otherwise <code>task</code> dies.</td>
</tr>
<tr>
  <td><code>output.files</code></td>
  <td>scalar or array</td>
  <td><code>undef</code></td>
  <td>File(s) expected to exist <b>after</b> running; used both for the missing-output check and for skip detection.</td>
</tr>
<tr>
  <td><code>log.fh</code></td>
  <td>open filehandle</td>
  <td><code>undef</code></td>
  <td>If given, the full result record is also written here. Must be a real, open filehandle.</td>
</tr>
<tr>
  <td><code>note</code></td>
  <td>scalar</td>
  <td><code>''</code></td>
  <td>Free-text note copied into the result and the log.</td>
</tr>
<tr>
  <td><code>overwrite</code></td>
  <td>bool</td>
  <td><code>0</code></td>
  <td>If false and all <code>output.files</code> already exist, the command is skipped. Set true to always run.</td>
</tr>
</tbody>
</table>

=end html



Passing an unrecognised key, an empty filename, or a non-filehandle C<log.fh>
causes C<task> to die: these are usually mistakes worth catching early.

=head2 Return value

C<task> always returns a hash reference. The fields below are present after a
normal run; the L<skip|/"Skipping completed work"> and L<dry-run|/"Dry runs"> paths
omit the execution-only fields (C<exit>, C<signal>, C<stdout>, C<stderr>).



=begin html

<table>
<thead>
<tr>
  <th>Field</th>
  <th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
  <td><code>cmd</code></td>
  <td>The command that was run.</td>
</tr>
<tr>
  <td><code>dir</code></td>
  <td>Working directory at execution time.</td>
</tr>
<tr>
  <td><code>done</code></td>
  <td><code>"now"</code> (just ran), <code>"before"</code> (skipped, outputs already existed), or <code>"not yet"</code> (dry run).</td>
</tr>
<tr>
  <td><code>will.do</code></td>
  <td><code>"done"</code>, <code>"no"</code> (skipped), <code>"no: dry run"</code>, or <code>"FAILED"</code>.</td>
</tr>
<tr>
  <td><code>duration</code></td>
  <td>Wall-clock seconds the command took (<code>0</code> for skips/dry runs).</td>
</tr>
<tr>
  <td><code>exit</code></td>
  <td>Exit code of the command (<code>-1</code> if it could not be launched).</td>
</tr>
<tr>
  <td><code>signal</code></td>
  <td>Signal number if the command process was killed by a signal, else <code>0</code>. Always <code>0</code> on Windows (no POSIX signals).</td>
</tr>
<tr>
  <td><code>stdout</code>, <code>stderr</code></td>
  <td>Captured output, with trailing whitespace stripped.</td>
</tr>
<tr>
  <td><code>die</code>, <code>dry.run</code>, <code>overwrite</code>, <code>note</code></td>
  <td>The (defaulted) argument values used.</td>
</tr>
<tr>
  <td><code>output.files</code></td>
  <td>Array ref of the output files (a scalar argument is normalised to a one-element array).</td>
</tr>
<tr>
  <td><code>output.file.size</code></td>
  <td>Hash of <code>filename => size in bytes</code> for the outputs.</td>
</tr>
<tr>
  <td><code>input.files</code></td>
  <td>The input argument, as given (present only if you passed <code>input.files</code>).</td>
</tr>
<tr>
  <td><code>input.file.size</code></td>
  <td>Hash of <code>filename => size in bytes</code> for the inputs (present only if you passed <code>input.files</code>).</td>
</tr>
<tr>
  <td><code>source.file</code>, <code>source.line</code></td>
  <td>Where in <i>your</i> code the <code>task</code> was called: handy when debugging a long pipeline.</td>
</tr>
</tbody>
</table>

=end html



=head2 Skipping completed work

If C<overwrite> is false (the default) and every file in C<output.files> already
exists, C<task> does B<not> re-run the command. This makes pipelines
restartable: re-running the script picks up where it left off.

 open my $log, '>', 'logfile.txt';
 my $t = task({
     cmd            => 'gmx grompp -f em.mdp -c box.gro -p topol.top -o em.tpr',
     'input.files'  => ['em.mdp', 'box.gro', 'topol.top'],
     'output.files' => 'em.tpr',
     'log.fh'       => $log,
 });
 close $log;

On the first run C<done> is C<"now">; on a re-run (with C<em.tpr> present) C<done>
is C<"before"> and C<will.do> is C<"no">. Pass C<< overwrite =E<gt> 1 >> to force it.

=head2 Dry runs

Useful for inspecting a pipeline without executing anything expensive:

 my $t = task({
     cmd       => 'a long-running, time-consuming command',
     'dry.run' => 1,
     'log.fh'  => $fh,
 });

The command is printed (and logged) but not run; C<will.do> is C<"no: dry run">.

=head2 Failure behaviour

By default (C<< die =E<gt> 1 >>) C<task> dies if the command exits non-zero or if any
declared C<output.files> are missing afterwards, so a broken step stops the
pipeline immediately. With C<< die =E<gt> 0 >>, C<task> instead warns and returns its
result hash (with C<< will.do =E<gt> "FAILED" >>), letting you decide what to do.

=head2 C<say2>

 say2($message, $filehandle);

"Say to two places": prints C<$message> to standard output B<and> to the given
log filehandle, prefixed with the calling file and line number so log entries
are traceable. The filehandle must be open, or C<say2> dies.

 open my $log, '>', 'run.log';
 say2('starting equilibration', $log);   # -> STDOUT and run.log
 close $log;

=head1 Dependencies

Core/runtime modules used by SimpleFlow:

=over

=item * L<Capture::Tiny> captures C<stdout>/C<stderr>

=item * L<Data::Printer> (C<DDP>) pretty result/record printing

=item * L<Devel::Confess> better backtraces on death

=item * L<Term::ANSIColor> coloured terminal output

=item * C<List::Util>, C<Scalar::Util>, C<Time::HiRes>, C<Cwd> core utilities

=back

The test suite additionally uses C<Test::More> and
L<Test::Exception>.

=head1 Change log

=head2 0.14 (2026-06-29) (Claude Opus 4.8 helped)

=head3 C<task>

=over

=item * B<New:> accepts a flat key/value list as well as a hash ref —
C<< task(cmd =E<gt> ...) >> and C<< task({ cmd =E<gt> ... }) >> are now equivalent. A lone
non-hashref scalar or any odd-length argument list is fatal.

=item * B<Bug fix:> the default C<< die =E<gt> 1 >> was ignored when checking for missing
C<output.files>. The block tested the raw C<< $args-E<gt>{'die'} >> (undef when the
caller omitted it) instead of the resolved C<$r{'die'}>, so a command that
failed to produce its declared outputs only warned instead of dying. Now
consistent with the exit-code check.

=item * B<Bug fix:> removed a stray C<)> (and an extraneous leading space) from the
"command is" line written to the log file; it now matches the on-screen form.

=item * B<Bug fix:> C<length $_ == 0> could throw a fatal uninitialized-value warning
(under C<< warnings FATAL =E<gt> 'all' >>) on an undef element of the C<input.files>
array branch and the C<output.files> empty-name check. Both now guard with
C<(defined $_) && (length $_ == 0)>, matching the C<input.files> scalar branch.

=back

=head2 0.13 (2026-06-11)

=head3 Fixed (Claude Opus 4.8 helped)

=over

=item * B<Exit status and signal are now decoded correctly.> C<task()> previously
computed the exit code (C<< $status E<gt>E<gt> 8 >>) and I<then> derived the signal as
C<$exit & 127>. Because the signal lives in the low byte of the raw wait
status, which C<< E<gt>E<gt> 8 >> discards the C<signal> field was always wrong: a clean
C<exit 42> was reported as C<signal 42>, and a process actually killed by a
signal reported C<signal 0>. The signal is now read from the raw status before
shifting, so C<exit> and C<signal> are independent and accurate.

=item * B<< No longer dies on a missing output file when C<< die =E<gt> 0 >>. >> The zero-size
check did C<(-s $file) == 0>, which is C<undef == 0> when a declared output file
is absent. Under C<< use warnings FATAL =E<gt> 'all' >> that "uninitialized value"
warning was fatal, so a task that was meant to I<warn> about missing output
(with C<< die =E<gt> 0 >>) crashed instead. Missing sizes are now treated as C<0>, so
the task warns and returns its result hash as intended.

=item * B<< The "already done" result is now logged with its C<duration>. >> In the
short-circuit path (output files already exist), C<duration> was set I<after>
the record was written to the log, so the logged hash was missing it; the
duplicate C<< done =E<gt> 'before' >> assignment was also removed.

=back

=head3 Changed / Windows support

=over

=item * B<Portable exit-status handling.> Decoding now branches on C<$^O>: Windows has
no POSIX signals (C<signal> is reported as C<0> there), and a C<system()> that
fails to launch the command (C<-1>) yields C<< exit =E<gt> -1 >> instead of a garbage
value from shifting C<-1>.

=item * B<ANSI colour is disabled on the legacy Windows console.> C<Term::ANSIColor>
output is suppressed on C<MSWin32> unless an ANSI-capable terminal is detected
(Windows Terminal, ConEmu, or ANSICON), so C<cmd.exe> no longer prints raw
escape sequences and redirected logs stay clean. Unix and modern Windows
terminals are unaffected.

=back

=head3 Tests

=over

=item * Rewrote C<t/01.t> to be cross-platform: shell commands now invoke the running
Perl interpreter (C<"$^X" -e ...>) instead of Unix-only tools (C<which>, C<ls>,
C<ln>, C<cp>), and temp files use the system temp directory instead of a
hard-coded C</tmp>.

=item * Added regression tests for both fixed bugs (exit/signal decoding; surviving a
missing output file with C<< die =E<gt> 0 >>).

=item * Added coverage for the C<note> field, the C<input.file.size> / C<output.file.size>
hashes, scalar-vs-array normalisation of C<input.files> / C<output.files>, the
C<dir> / C<source.file> / C<source.line> metadata, captured C<stdout> / C<stderr>
(including trailing-whitespace stripping), and argument validation (missing
C<cmd>, unknown keys, bad C<log.fh>, missing input files).

=back

=head2 0.12

exit code now matches what shell would show it as; signal now appears

=head2 0.11

max string length now corresponds to max of output strings, no more truncated output
added List::Util dependency for string length maxes
memory size now shows when output
directory is now output during dry runs