Revision history for SimpleFlow


0.191 2026-09-27 (Claude Opus 5.5 helped)

 [Fixed]

 - **A signal sent just as the command started could be lost.** 0.19 passes a
     TERM or HUP (and, under `timeout`, an INT or QUIT) to the command, but
     only once its handlers were installed, which was after the exec had
     succeeded. A signal that arrived in between went to the caller's handler
     alone: the command was neither killed nor sent it, ran to its end, and
     was reported as `done`. A CPAN smoker (perl 5.16.3 on Alpine) hit this
     in `t/07.coverage.t`. Those signals are now blocked from before the fork
     until the handlers are in place, and one that arrived meanwhile is then
     delivered to them.

 [Tests]

 - `t/08.fixes.t` sends the signal inside that window deterministically, and
     fails against 0.19.
 - `t/04.fixes.t` and `t/05.features.t` no longer draw "Statement unlikely to
     be reached" from perl 5.16.
 - Seven subtests failed on a Strawberry Perl 5.42.0 smoker; the module was
     not at fault in any of them. `t/05.features.t` and `t/07.coverage.t`
     handed a child perl code with a double quote in it, which MSWin32's
     `system(LIST)` does not escape; `t/05.features.t`'s wrapper used `exec`,
     which on MSWin32 ends the wrapper before the command has printed; and
     `t/06.pipeline.t` ran `parallel()` with `jobs` above 1, which is refused
     there by design. Those subtests now check the refusal instead, and the
     tests' helpers refuse a double quote in a list `cmd` or `wrapper` on
     every platform, so that mistake can no longer reach a Windows smoker
     unseen.

 [Documentation]

 - A string `cmd` inside a wrapper is run by `cmd.exe /c` on MSWin32, not by
     `/bin/sh -c` as the documentation said.

0.19 2026-09-26 (Claude Opus 5.5 helped)

 [Fixed]

 - **A command killed by a signal was reported as a success.** A death by
     signal leaves the exit code at 0, and `will.do` looked only at `exit`,
     `timed.out` and missing outputs, so an OOM kill or a Ctrl-C -- which
     `system()` ignores in the parent and so leaves to the child -- came back
     `will.do => 'done'` with no warning, and under the default `die => 1`
     the pipeline went on to its next step. It now counts as `FAILED`, and
     `task()` dies (or, under `die => 0`, warns) naming the signal. This
     affected a list `cmd`, a string with no shell metacharacters, and a
     string the shell execs directly; otherwise the shell reports 128 + the
     signal as a non-zero exit, which was already caught.
 - **A dry run could not get past the second step of a pipeline.** The input
     files were checked before `dry.run` was, so a step whose input is an
     earlier step's output -- which a dry run never makes -- died with "the
     above files are missing or are not readable". Under `dry.run` a missing
     input is now listed in what the dry run prints, and in the log, rather
     than being fatal; its entry in `input.file.size` is undef. Outside a dry
     run a missing input still dies.
 - **A failed step's partial output was taken as done on the next run.** An
     output file half-written before a non-zero exit, a kill by signal or a
     timeout was left under its declared name, so the next run found it,
     reported `done => 'before'`, and skipped the step for good. The existing
     outputs of a failed step, including one whose sibling outputs are
     missing, are now moved to `<file>.failed`, replacing any `.failed` left
     from before, and the move is reported on `STDERR` and in the log.
     `output.file.size` still gives the sizes the command wrote.
 - **Loading SimpleFlow changed how the caller's own program died and
     warned.** `use Devel::Confess 'color'` installed global `__DIE__` and
     `__WARN__` handlers, so a caller's `die "message\n"` came back with a
     stack trace appended, and code comparing `$@` with a string broke.
     Devel::Confess is now switched on only for the length of each `task()` or
     `say2()` call, and the caller's handlers are put back afterwards;
     SimpleFlow's own errors and warnings keep their coloured stack traces.
 - **A Ctrl-C during a timed command left the command running.** Under
     `timeout` the command has its own process group, which is not the
     terminal's, so the interrupt reached only perl, and the command ran on as
     an orphan. `INT`, `TERM`, `HUP` and `QUIT` are now caught while it runs:
     the group is killed, the record is written, and the signal is passed on
     to the caller's handler, or ends the program if there is none. One the
     caller ignores stays ignored.
 - **Under `timeout` with `stdin => 'inherit'`, a command reading the
     terminal was reported as timed out.** Outside the terminal's foreground
     group it was stopped by `SIGTTIN` until the timeout killed it. It is now
     given the foreground for the run, as a shell gives it to a job, and the
     caller takes it back after. This has no test in the suite, since showing
     it needs a pseudo-terminal; it was checked by hand under `script(1)`.
 - **`timeout` cancelled the caller's own pending `alarm`.** It is now put
     back when the command finishes, less the time taken, and delivered at
     once if it fell due while the command ran.
 - **`timeout` accepted `"5\n"` and non-ASCII digits.** The check was
     `/^\d+$/`; a Unicode digit then died "isn't numeric" rather than with the
     argument error. It is now ASCII digits to the end of the string.
 - **`stale` compared whole-second mtimes,** so an input rewritten in the same
     second as its output was not newer. The mtimes now come from
     `Time::HiRes::stat`.
 - **A step that failed in more than one way died naming only one.** A
     missing output was checked first, so a step that also exited non-zero or
     timed out said only that the output was missing. The message now names
     every reason, including the exit code.
 - **A command that could not be launched did not say why.** `exit` was
     `-1` and `$!` was discarded. `stderr` now holds the reason, as a shell
     would have printed it.
 - **A dry run's record lacked `output.file.size` and was not logged.** It
     now has every field the other paths have, and is printed and logged as
     theirs are.
 - **Under `die => 0` a step with a missing output logged its record twice,**
     the first copy without `output.file.size`. It is now printed once.
 - **A missing output was also reported as having 0 size.**
 - **The dumps explaining an error went to `STDOUT`.** Only their header
     lines went to `STDERR`, so a caller that redirected standard output lost
     the arguments and file lists into its output file. They now go to
     `STDERR`; the record printed after every step still goes to `STDOUT`.
 - **A `TERM` or `HUP` to perl during a command without a `timeout` left
     the command running.** `system()` shields its caller from `INT` and
     `QUIT` only, so a signal sent to perl alone, by a batch scheduler or
     `kill`, killed perl and orphaned the command. On POSIX the command is
     now forked and waited for by `task()` itself, as it already was under a
     `timeout`: the signal is passed on to the command, the command waited
     for, the record written, and the signal passed on to the caller.
 - **Ctrl-Z during a timed command reading the terminal hung until the
     timeout.** The command was stopped and nothing noticed. It is now
     suspended along with the caller, as a shell suspends a job, with the
     timeout's clock stopped, and resumed with it. Checked by hand under
     `script(1)`, since showing it needs a pseudo-terminal: before, the step
     sat stopped until its 8 s timeout killed it; after, it read its input and
     succeeded in 3 s.
 - **A command that could not be launched under a `timeout` was `exit
     127`.** The forked child had no way to hand back why; it now writes its
     `errno` down a close-on-exec pipe, as perl's own `system()` does, and the
     command is `exit -1` with the reason in `stderr`, with or without a
     timeout.

 [Changed]

 - **Two of these fixes change what an existing pipeline sees.** A step
     killed by a signal now stops a pipeline running under the default
     `die => 1`, where it used to carry on. A failed step's outputs are no
     longer under their declared names afterwards, so code run under
     `die => 0` that reads a failed step's output must read
     `<file>.failed` instead, or look in `failed.outputs`.
 - **Under a `timeout`, a command that could not be launched is `exit -1`,
     not `127`,** as it already was without one, and `stderr` says why. Code
     that tested for 127 there should test for -1.
 - **A program that relied on SimpleFlow to give it Devel::Confess loses
     it.** Its own `die` and `warn` no longer carry stack traces; one that
     wants them should `use Devel::Confess` itself.
 - **The failure messages are worded differently.** Each is now
     `"<cmd>" <reason>; <reason>, from <file> line <line>`, the reasons being
     "exited N", "was killed by signal N", "was killed after exceeding its Ns
     timeout" and "these output files should have been made but are missing:
     ...". A die under `die => 1` for a non-zero exit used to say
     "failed from"; it now says "exited N". Under `die => 0` a missing output
     is now a `warn` rather than a line printed to `STDERR`.

 [Added]

 - **`failed.outputs`**, a new field of the record: an array ref of the
     `.failed` names a failed step's outputs were moved to, and `[]` on every
     other path.
 - **`retries` and `retry.delay`**: run a failed step again, up to `retries`
     more times, waiting `retry.delay` seconds before each, as Nextflow's
     `errorStrategy 'retry'` and Snakemake's `--retries` do. Each failed
     attempt has its outputs moved aside and is reported on `STDERR` and in
     the log. The record describes the last attempt, and a new field,
     `attempts`, says how many there were. An interrupt is never retried.
 - **`env`**: environment variables for the command alone, `undef` removing
     one; the caller's `%ENV` is put back afterwards.
 - **`dir`**: run the whole step, its file checks included, in another
     directory; the caller is put back in its own afterwards, however
     `task()` returns.
 - **`stdout.file` and `stderr.file`**: send the command's output to a file
     instead of holding it in the record, as Snakemake's `log:` does. The
     files are emptied when the step starts and keep every attempt's output;
     one file may be named for both.
 - **`output.dir` and `output.dirs`**: directory outputs, as Snakemake's
     `directory()`. A directory counts as made if it exists, is warned about
     if empty, is moved aside when the step fails, and under `stale` is as
     new as the newest thing in it.
 - **`protect`**: make a step's outputs read-only once it succeeds, as
     Snakemake's `protected()`, and refuse to re-run over them.
 - **`trace.fh`**: one line of JSON per task, on every path, holding the
     record without `stdout` and `stderr`, as Nextflow's `trace.txt` does.
 - **`lock`**: a `flock` on each output, kept in `.simpleflow/` in the
     working directory, so that a second copy of the pipeline reaching the
     step waits for the first and then finds it done.
 - **`%SimpleFlow::DEFAULTS`**: defaults for every `task()` in the program,
     for any key a call leaves undefined, so that one line can dry-run, quiet
     or log a whole pipeline. `env` is merged rather than replaced; keys that
     name a particular step are refused.
 - **`cpu.user`, `cpu.system` and `start.time`**, new fields of the record:
     the CPU time the command spent, from `times`, and when its last attempt
     started.
 - **The end of stderr in a failure's message.** The message a failed
     step dies or warns with now ends with the last six lines of its
     standard error, read back from `stderr.file` if that is where it went.
 - **`input.dir` and `input.dirs`**: directory inputs, which must exist
     before the step runs, and under `stale` are as new as the newest thing
     in them.
 - **`stale.cmd`**: re-run a step whose command, `env`, or container,
     conda environment, executor or wrapper has changed since it made its
     outputs, as Snakemake's `params` and `code` rerun triggers do. A new
     field, `cmd.changed`, says when that happened. What made each set of
     outputs is kept as a digest in `.simpleflow/cmd/`.
 - **`on.success` and `on.failure`**: code called with the record after a
     command has run, before `task()` dies; in `%SimpleFlow::DEFAULTS`, a
     pipeline's `onsuccess` and `onerror`.
 - **`container`, `container.engine` and `container.args`**: run the
     command in a docker, podman, singularity or apptainer container, with
     the working directory mounted.
 - **`conda.env`**: run the command with `conda run`.
 - **`executor`, `executor.args`, `threads`, `mem` and `walltime`**: run the
     command as a SLURM job step with `srun`, asking for the resources given.
     `threads` is also given to the command as `SIMPLEFLOW_THREADS`.
 - **`wrapper`**: run the command inside any other command. A new field,
     `wrapped.cmd`, is the command as actually run.
 - **`parallel()`**, exported on request: run independent steps at the same
     time, at most `jobs` at once, each in a child of its own, and return
     their records in order. A failure stops new steps, lets the running ones
     finish, and dies; `keep.going` runs them all first. POSIX-only for
     `jobs` above 1.
 - **`report()`**, exported on request: an HTML page of a trace, with a row
     and a timeline bar for every task and a count of each status.
 - Every new option has its resolved value on the record, as the existing
     ones do, except the hooks, which are code. The record therefore has
     many more fields, printed after every step, and a pipeline that
     compares whole records will see them.

 [Tests]

 - **The 0.181 suite failed one test on MSWin32.** `t/03.fixes.t` expected a missing command given as a list to
     come back `exit => -1`, but on MSWin32 a failed spawn of a list does not
     make `system` return -1: win32.c's `do_aspawn` sets the status to
     255 * 256, so `exit` is 255. The test now expects 255 there.

0.181 2026-09-25 (Claude Opus 5 helped)

 [Tests]

 - **The 0.18 suite failed on MSWin32; the module itself is unchanged.**
     `t/lib/CaptureStd.pm` read its capture files in binary mode, so every
     line captured from a `:crlf` handle ended in CR LF, and `t/01.t`'s
     `say2` comparison failed. `t/03.fixes.t` passed its child program and
     the command under test to a fresh perl as arguments containing double
     quotes, which `system(LIST)` on MSWin32 does not escape, so the child
     never ran and blocks 1 and 3 failed. The child code now avoids double
     quotes, the command goes through `%ENV`, and a missing command given as a
     string, which MSWin32 retries through `cmd.exe`, is required only to
     fail with a non-zero `exit` there rather than `-1`.

0.18 2026-09-24 (Claude Opus 5 helped)

 [Fixed]

 - **A command that could not be launched was reported as a success, and the
     rest of the calling program ran twice.** `system()` forks and then execs;
     when the exec fails, its child warns "Can't exec", which the module's
     `use warnings FATAL => 'all'` turned into a die *in that forked child*.
     The die unwound out of `task()` into the caller's program, which then ran
     on as a second copy, while the parent was handed the copy's exit status.
     `task(cmd => 'no-such-program', die => 0)` came back `exit => 0`,
     `will.do => 'done'`, and every later step ran twice. The `timeout` path
     had the same defect in its own fork. A command that cannot be launched is
     now `exit => -1` (`127` under a `timeout`) and `FAILED`. A string with a
     shell metacharacter in it was never affected, since the shell launches
     the program and reports `127` itself.
 - **A one-element array-ref `cmd` went through the shell.** An array ref is
     documented as run without a shell, but `system(@list)` hands a list of one
     to the shell, so `cmd => ['echo hi; rm x']` ran both commands. The array
     form now always runs the named program directly.
 - **A command finishing at the moment its `timeout` fired could be reported
     as timed out, with `exit => -1`.** If the alarm arrived after the child had
     been reaped but before it was cancelled, the group was killed anyway and a
     second `waitpid` overwrote the real status. The group is now killed only
     if the child has not already been reaped. The window is a few
     instructions wide, and has no test.

 [Performance]

 - **`stdout` and `stderr` are captured without `Capture::Tiny`.** It slurped
     each capture into a lexical and returned it through several list copies,
     which perls before 5.20 do not share: a `task()` whose command printed
     100 MB peaked at 498 MB RSS and took 0.53 s on perl 5.10.1. `task()` now
     points descriptors 1 and 2 at temporary files itself, with `POSIX::dup2`,
     and reads each file straight into the record: 108 MB and 0.17 s. The
     capture is read back through the caller's own `STDOUT` and `STDERR`
     layers, as `Capture::Tiny` did, so a caller that set
     `:encoding(UTF-8)` still gets characters; standard descriptors the
     caller had closed are plugged with the null device for the run and
     closed again after.
 - **Trailing whitespace is stripped by walking back from the end.** The
     regex used before scanned the whole of `stdout` from the start, and on a
     perl with copy-on-write copied it first: on a 100 MB capture, 0.13 s and
     peak RSS from 107 MB to 205 MB on perl 5.44.0. End to end, a `task()`
     producing 100 MB of `stdout` went from 306 MB peak RSS and 0.30 s to
     111 MB and 0.12 s on 5.44.0.
 - **Printing the record no longer copies the captured output.** The clipped
     copy made for printing copied every field in full before clipping it,
     which perls before 5.20 do not share: on perl 5.10.1 that raised peak RSS
     from 205 MB to 303 MB for a 100 MB `stdout`.

 [Changed]

 - **A caller whose `STDOUT` is an in-memory handle now has the command's
     output captured.** `Capture::Tiny` redirected the `STDOUT` glob, and a
     glob opened on a scalar has no descriptor, so the command wrote past it
     onto the real descriptor 1 — the terminal — and the record's `stdout`
     came back empty. The redirect is now on the descriptor, which is what
     the command inherits. A caller that relied on that output reaching the
     terminal will now find it in `stdout` instead.

 [Packaging]

 - **`Capture::Tiny` is no longer a prerequisite at all.** The module
     captures on its own descriptors, and the tests use `t/lib/CaptureStd.pm`,
     a 46-line `capture {}` with the same calling convention that reopens
     the `STDOUT` and `STDERR` globs — deliberately not the module's own
     mechanism, so a bug in one cannot hide the same bug in the other.
     `File::Temp`, which is core, is now a runtime prerequisite.

 [Tests]

 - **`t/03.fixes.t`**, one block per defect above except the race, and one
     for the in-memory `STDOUT`, each confirmed to fail against 0.17 first;
     and two covering the new capture's handling of closed descriptors and of
     output layers, which pass against 0.17 as well, since the point is that
     nothing changed there.
 - Block 14 of `t/02.fixes.t` (`quiet => 1`) used to assert that the
     command's output escaped an in-memory `STDOUT`; it now asserts that the
     output is in the record.

0.17 2026-09-14 (Claude Opus 5 helped)

 [Fixed]

 - **A command that prompted hung for ever.** `Capture::Tiny::capture`
     redirects file descriptors 1 and 2 and nothing else, so the command
     inherited the caller's descriptor 0. A command that stops to ask a
     question — `rm` over a write-protected file, `cp -i`, `git` asking for
     credentials — wrote its prompt into the captured stderr, where nobody
     could see it, and then blocked on the terminal waiting for an answer the
     user did not know was wanted. Nothing was printed and, with `timeout` at
     its default of 0, nothing ever returned; with a `timeout` set the process
     group was killed and the record then said `timed.out => 1, signal => 9`,
     blaming the clock for what was really an unanswered question. The command
     now runs with descriptor 0 on the null device. Both execution paths were
     affected and both are fixed: the `timeout` path forks and execs, and its
     child inherited descriptor 0 across the fork just as `system()`'s did.
     Found while debugging a pipeline that hung on `rm -r` over a read-only
     file.

 [Added]

 - **`stdin`**: `'devnull'` (the default) or `'inherit'`, saying what the
     command sees on its standard input. `'inherit'` restores the behaviour of
     0.162 and earlier for a step that really does read the data the calling
     script was given, with the hazards that implies: it consumes input the
     caller can then no longer read, and a command that prompts hangs exactly
     as it used to. The caller's standard input is saved and restored around
     every run either way — including when the run dies, so a caller that traps
     the exception is not left without it — and a caller that had closed it
     keeps it closed.

 [Changed]

 - The result record carries `stdin`, the resolved value of that option.
 - `File::Spec` (core) is now a dependency, for the name of the null device:
     `/dev/null` on Unix, `nul` on Windows.
 - Callers relying on the old behaviour are affected: a command that read the
     calling script's standard input now reads end-of-file instead, and
     succeeds while doing nothing. `stdin => 'inherit'` is the one-word repair.

 [Packaging]

 - **`SECURITY.md`**, giving an address to report a vulnerability to privately
     and saying what is in scope. SimpleFlow runs the command it is given, so a
     `cmd` string built out of untrusted data is a shell injection in the
     *calling* program; the array-ref form of `cmd` runs without a shell and is
     the way to avoid that.
 - **`CONTRIBUTING.md`** now ships too. Both files are gathered by `[@Basic]`
     without a `dist.ini` entry, and both are what the CPANTS experimental
     metrics `has_security_doc`, `security_doc_contains_contact` and
     `has_contributing_doc` look for. Checked by running the contact half of
     `Module::CPANTS::SiteKwalitee::Security` over the built tarball: the
     address it extracts is `dec986@gmail.com`.
 - **`autodie` is no longer a prerequisite.** The only file that ever loaded it
     is `md2pod.pl`, which `MANIFEST.SKIP` keeps out of the distribution, so
     every installer was being asked for a module the shipped code never loads.
     `Exporter` is declared instead, since the module does load it.
 - **The test-only prerequisites are declared as such.** `Test::More`,
     `Test::Exception` and `File::Temp` are used by `t/` and by nothing that is
     installed, so they moved from `requires` to `test_requires`. `Test::More`
     is pinned at 0.96 for the first time: every test file uses `subtest`,
     which arrived in Test::Simple 0.94, and perl 5.10.1 shipped 0.92 — a
     smoker with nothing beyond core could not have run the suite at all, and
     nothing said so. The `Makefile.PL` folds `TEST_REQUIRES` back into
     `PREREQ_PM` on ExtUtils::MakeMaker older than 6.63_03, so 5.10's own
     toolchain still sees them.
 - **`cover_db/`, `cover.sh` and `dzil.sh` no longer ship.** The committed
     Devel::Cover report is stale by design — it predates `t/02.fixes.t` — and
     was 39 files of HTML in the tarball; the two scripts are author-only, like
     `md2pod.pl`. The distribution is 17 files, and passes its own suite (60
     tests) when the tests are run inside the built tree.

0.162 2026-09-12 (Claude Opus 5 helped)

 [Tests]

 - **Block 14 of `t/02.fixes.t` failed on Data::Printer before 1.x.** A CPAN
     tester on perl 5.20.0 with Data::Printer 0.38 reported it against 0.161.
     The block redirects STDOUT to an in-memory handle to check that
     `quiet => 1` silences the terminal, and asserted that exactly the
     command's own output -- and nothing else -- escaped that redirect to the
     real file descriptor 1. On Data::Printer 0.38 the record escapes too:
     `use DDP {output => 'STDOUT'}` binds the handle as the property is
     parsed, at import, while 1.002001 resolves it at print time, so a later
     `local *STDOUT` cannot reach the older release. The block now counts how
     many times the command ran instead of demanding that nothing else
     escaped, and its command prints an upper-cased sentinel so that the
     record, which quotes the command it ran, cannot be counted as a second
     copy. Confirmed against Data::Printer 0.38 and 1.002001, on perl 5.10.1,
     5.12.5 and 5.44.0. No module behaviour changed, and nothing about
     `quiet` was wrong: on every version the record goes to the terminal
     unless `quiet => 1` and to the log either way.

0.161 2026-09-07 (Claude Opus 5 helped)

 [Fixed]

 - **The printed record's length cap did nothing on Data::Printer before
     0.99_001.** 0.16 capped each field of the record at 4096 characters by
     handing `string_max` to Data::Printer and leaving the clipping to it, but
     that property only arrived in Data::Printer 0.99_001 (2018-04-21) and
     every earlier release ignores a property it does not know, in silence. A
     CPAN tester on perl 5.20.0 with Data::Printer 0.38 therefore had a
     200,000-character capture printed whole: 200,927 bytes to the terminal and
     200,914 bytes to the log. SimpleFlow now clips the strings itself before
     printing them, and marks what it dropped in Data::Printer's own wording,
     so the ceiling holds on every version. The full capture is still on the
     result hash, and the output on Data::Printer 1.x is unchanged.

 [Tests]

 - Added a regression test for the above (block 17 of `t/02.fixes.t`), confirmed
     to fail against 0.16 first. Its probe loads a stub `DDP` that ignores every
     property it is handed, which is what those Data::Printer releases did, and
     is the only way to reproduce the flood on a machine whose Data::Printer is
     current; it needs no network and nothing installed.

 - Block 10 asked that `$VERSION` have exactly two decimal places, which this
     release does not: 0.161 is a point release on 0.16 and keeps three. The
     assertion now takes two or more, and a new one compares `$VERSION` against
     the literal in the source digit for digit -- the check that actually
     catches an unquoted version, since with the quotes off 0.161 the module
     still reports "0.161" and any pattern on the digits passes.

 - A passing run of the suite no longer looks like a crash. task() prints the
     result record, and its failure paths dump the arguments with `p` and then
     warn or die on the terminal -- all as documented -- so the three test
     files together printed some 1,180 lines of record dumps and eight
     backtraces on a clean, wholly successful run. Every call whose printing is
     not itself under test now goes through a `quietly` helper that captures
     both streams with `Capture::Tiny`, and the expected diagnostics are
     asserted on rather than discarded: `prove -Ilib t/` prints TAP and nothing
     else. Capturing was chosen over passing `quiet => 1` because it leaves the
     arguments handed to task() unchanged, which is what keeps blocks 1-11 of
     `t/02.fixes.t` runnable against 0.15 (re-checked: they still fail there).
     No module behaviour changed.

 [Documentation]

 - README.md no longer carries the release notes, so they are no longer copied
     into `read.me.pod` and the module's POD either. `Changes` is the only copy
     now, and `md2pod.pl` checks it with `changes_file_ok()` rather than
     generating it.


0.16 2026-08-28 (Claude Opus 5 helped)

 [Fixed]

 - **`die => 0` never reported a failure.** The `will.do => "FAILED"` assignment
     sat inside the `if ($r{die})` branch, so it could only run on the path that
     immediately died. Under `die => 0` — the mode in which the caller is meant
     to read `will.do` — a command that exited non-zero was reported as `"done"`,
     and nothing warned. `will.do` is now `"FAILED"` for a non-zero exit, a
     timeout, or a missing output file regardless of `die`, and `die => 0` emits
     a warning naming the exit code.
 - **The log lost the record of the task that killed the run.** The log
     filehandle was never autoflushed. Measured with a `SIGKILL` part-way through
     a pipeline (the shape of an OOM kill or a scheduler eviction), a log holding
     862 bytes on a clean exit held 139 bytes after the kill: everything written
     after the last command started — its exit code, duration and captured output
     — was still in stdio's buffer. `task` and `say2` now switch the handle to
     autoflush.
 - **An undefined filename still crashed.** 0.14 added a `defined` guard to the
     0-length check, but the `-f -r` filetest ran first, so an `undef` element of
     an `input.files` array died as `Use of uninitialized value $_ in -r` under
     `warnings FATAL => 'all'`. Names are now validated before anything is
     filetested.
 - **The 0-length `input.files` check was unreachable.** `''` fails `-f`, so an
     empty input filename was reported as `"missing or unreadable"` and the
     0-length check below it could never fire. Both undefined and 0-length names
     are now reported as what they are, and the message names the offending index.
 - **`cmd` was not type-checked.** Only definedness was checked, so any reference
     was stringified straight into the shell: `task(cmd => ['echo','hi'])` ran the
     literal command `ARRAY(0x5ed9d076e618)`. `cmd` must now be a non-empty string
     or a non-empty array ref of defined values.
 - **Skip detection and the post-run check disagreed.** Skipping tested a bare
     `-f` while the post-run check tested `-f -r`, so an output file that existed
     but could not be read counted as already done. Both use `-f -r` now.
 - **The result record changed shape between paths.** `exit`, `signal`, `stdout`
     and `stderr` were absent after a skip or a dry run, so a caller running under
     the `warnings FATAL => 'all'` this module recommends died just by reading
     `$t->{'exit'}`. They are now always present, holding their empty values.
 - **`string_max` was uncapped**, so a chatty command had its whole capture echoed
     to the terminal and written to the log — a measured 3 MB stdout wrote
     3,002,832 bytes to each. It is now capped at 4096 characters; Data::Printer
     marks what it drops. The full capture is still on the result hash.
 - **Loading SimpleFlow polluted `main::`.** `use DDP` and `use Cwd 'getcwd'` sat
     above the `package` statement, so `p`, `np` and `getcwd` were imported into
     every program that loaded the module. The `package` statement now comes
     first, and the duplicated `use` lines are gone.
 - **Unbalanced parenthesis** in the 0-length `output.files` error message.

 [Added]

 - **`stale`**: also re-run when an input file is newer than an output file, the
     rule `make` and `snakemake` use. Off by default, so existing pipelines are
     unaffected. The result carries `out.of.date`.
 - **`timeout`**: a wall-clock budget in whole seconds. The command runs in its
     own process group and the whole group is killed if the budget is exceeded,
     so a wedged pipeline does not leave orphans behind. The result carries
     `timed.out`. POSIX only.
 - **An array-ref `cmd`** runs the command without a shell, so arguments coming
     from data need no quoting.
 - **`quiet`**: suppress the record printed to the terminal without silencing the
     log or `STDERR`.
 - **`input.file`**, the single-file convenience form of `input.files`, matching
     `output.file`.

 [Changed]

 - `$VERSION` is now a quoted string. As a bare number it was stringified through
     `%g`, so a future `0.20` would have become `"0.2"` and compared as older than
     `"0.15"` on CPAN.
 - **Incompatible:** `input.files` on the result is now always an array ref, as
     `output.files` always was. A scalar argument used to be stored raw.
 - `POSIX` (core) is now a dependency, for `_exit` in the timeout child.

0.15 2026-07-17 (Claude Opus 4.8 helped)

 - addition of `output.file`, a single-file convenience form of `output.files`. It
   takes one plain filename, cannot be combined with `output.files`, and dies if
   given a reference or an empty name.

 - removal of Term::ANSIColor dependency

 - improved coverage testing

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

 [`task`]
 - **New:** accepts a flat key/value list as well as a hash ref —
     `task(cmd => ...)` and `task( cmd => ... )` are now equivalent. A lone
     non-hashref scalar or any odd-length argument list is fatal.
 - **Bug fix:** the default `die => 1` was ignored when checking for missing
     `output.files`. The block tested the raw `$args->{'die'}` (undef when the
     caller omitted it) instead of the resolved `$r{'die'}`, so a command that
     failed to produce its declared outputs only warned instead of dying. Now
     consistent with the exit-code check.
 - **Bug fix:** removed a stray `)` (and an extraneous leading space) from the
     "command is" line written to the log file; it now matches the on-screen form.
 - **Bug fix:** `length $_ == 0` could throw a fatal uninitialized-value warning
     (under `warnings FATAL => 'all'`) on an undef element of the `input.files`
     array branch and the `output.files` empty-name check. Both now guard with
     `(defined $_) && (length $_ == 0)`, matching the `input.files` scalar branch.

0.13 2026-06-11

 [Fixed (Claude Opus 4.8 helped)]

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

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

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

 [Changed / Windows support]

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

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

 [Tests]

 - Rewrote `t/01.t` to be cross-platform: shell commands now invoke the running
     Perl interpreter (`"$^X" -e ...`) instead of Unix-only tools (`which`, `ls`,
     `ln`, `cp`), and temp files use the system temp directory instead of a
     hard-coded `/tmp`.
 - Added regression tests for both fixed bugs (exit/signal decoding; surviving a
     missing output file with `die => 0`).
 - Added coverage for the `note` field, the `input.file.size` / `output.file.size`
     hashes, scalar-vs-array normalisation of `input.files` / `output.files`, the
     `dir` / `source.file` / `source.line` metadata, captured `stdout` / `stderr`
     (including trailing-whitespace stripping), and argument validation (missing
     `cmd`, unknown keys, bad `log.fh`, missing input files).

0.12 2026-02-14

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

0.11 2026-01-13

 - 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

