@tiens.nguyen/gu-cli 1.0.831 → 1.0.833

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.
@@ -7711,13 +7711,13 @@ def _shell_commands(tokens):
7711
7711
  while i < len(tokens):
7712
7712
  kind, text, a, b, quoted = tokens[i]
7713
7713
  if kind == "op" and text in _SHELL_SEPARATORS:
7714
- if cur and (cur["argv"] or cur["redirects"]):
7714
+ if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
7715
7715
  cmds.append(cur)
7716
7716
  cur, sep = None, text
7717
7717
  i += 1
7718
7718
  continue
7719
7719
  if cur is None:
7720
- cur = {"argv": [], "spans": [], "sep": sep, "redirects": []}
7720
+ cur = {"argv": [], "spans": [], "sep": sep, "redirects": [], "assigns": []}
7721
7721
  if kind == "op":
7722
7722
  nxt = tokens[i + 1] if i + 1 < len(tokens) else None
7723
7723
  tgt = nxt[1] if nxt and nxt[0] == "word" else ""
@@ -7725,16 +7725,73 @@ def _shell_commands(tokens):
7725
7725
  i += 2 if tgt or (nxt and nxt[0] == "word") else 1
7726
7726
  continue
7727
7727
  if not cur["argv"] and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", text):
7728
+ cur["assigns"].append(tuple(text.split("=", 1)))
7728
7729
  i += 1
7729
7730
  continue
7730
7731
  cur["argv"].append(text)
7731
7732
  cur["spans"].append((a, b))
7732
7733
  i += 1
7733
- if cur and (cur["argv"] or cur["redirects"]):
7734
+ if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
7734
7735
  cmds.append(cur)
7735
7736
  return cmds
7736
7737
 
7737
7738
 
7739
+ # A RUNNER IN A VARIABLE THAT THE LINE ITSELF SET (#362). `U=/Applications/…/Unity && $U -batchmode
7740
+ # -runTests …` asked "the program to run is only known at run time" — but it is known: the same
7741
+ # line assigned it a literal. The check now resolves that case and judges the REAL program, by
7742
+ # re-scanning a copy of the line with the literal in place — so every rule (privilege, denylist,
7743
+ # allowlist, deletes) sees it: `S=sudo && $S rm -rf /` is still sudo. Only a top-level assignment
7744
+ # whose value has no $, `, glob or leading ~, made before the use and not inside a pipeline (a
7745
+ # subshell); anything else keeps asking. bash still runs the line exactly as written.
7746
+ # Anything else that can set or clear a variable makes the value unknowable from the text.
7747
+ _SHELL_VAR_CHANGERS = {"unset", "read", "declare", "typeset", "local", "readonly", "source", ".",
7748
+ "eval", "for", "select", "mapfile", "readarray", "getopts", "printf", "let"}
7749
+ _SHELL_VAR_RUNNER_RE = re.compile(r"^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$")
7750
+
7751
+
7752
+ def _shell_literal_value(v: str) -> bool:
7753
+ return bool(v) and not re.search(r"[$`*?\[]", v) and not v.startswith("~")
7754
+
7755
+
7756
+ def _shell_resolve_runner_vars(command: str, scan) -> str:
7757
+ """`command` with each top-level `$X` runner replaced by X's literal value, or "" when there
7758
+ is nothing this line can resolve. For CHECKING only — never executed."""
7759
+ import shlex as _shlex
7760
+ if not scan or scan.get("error"):
7761
+ return ""
7762
+ cmds = _shell_commands(scan["tokens"])
7763
+ if any(c["argv"] and _shell_prog_name(c["argv"][0]) in _SHELL_VAR_CHANGERS for c in cmds):
7764
+ return ""
7765
+ env, edits = {}, []
7766
+ for k, c in enumerate(cmds):
7767
+ in_pipe = c["sep"] in ("|", "|&") or (k + 1 < len(cmds) and cmds[k + 1]["sep"] in ("|", "|&"))
7768
+ a = c["argv"]
7769
+ pairs = []
7770
+ if not a:
7771
+ pairs = list(c.get("assigns") or [])
7772
+ elif a[0] == "export":
7773
+ pairs = [tuple(x.split("=", 1)) for x in a[1:] if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", x)]
7774
+ if pairs:
7775
+ for name, val in pairs:
7776
+ if not in_pipe and _shell_literal_value(val):
7777
+ env[name] = val
7778
+ else:
7779
+ env.pop(name, None)
7780
+ continue
7781
+ m = _SHELL_VAR_RUNNER_RE.match(a[0]) if a else None
7782
+ if m and (m.group(1) or m.group(2)) in env:
7783
+ start, end = c["spans"][0]
7784
+ val = env[m.group(1) or m.group(2)]
7785
+ # "$U" is one word; a bare $U is WORD-SPLIT by bash — `U="sudo rm" && $U -rf /` runs
7786
+ # sudo. Substituted raw, the re-scan splits it the same way and sees sudo.
7787
+ edits.append(((start, end), _shlex.quote(val) if command[start:end].startswith('"') else val))
7788
+ if not edits:
7789
+ return ""
7790
+ for (start, end), val in sorted(edits, reverse=True):
7791
+ command = command[:start] + val + command[end:]
7792
+ return command
7793
+
7794
+
7738
7795
  def _shell_needed(scan):
7739
7796
  """Does this line need a shell to mean what it says? False → run it as argv, as before."""
7740
7797
  return bool(scan["heredocs"] or scan["substs"] or scan["expands"]
@@ -7967,7 +8024,9 @@ def _run_command_prompt_line(shell, windows=None):
7967
8024
  "interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
7968
8025
  "stop_server closes it. " + _BG_LINE)
7969
8026
  return (
7970
- " run_command IS A SHELL (bash): pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
8027
+ " run_command IS A SHELL (bash), and it KEEPS its folder and exported variables from one "
8028
+ "call to the next in this turn (`cd sub`, then `npm test` runs in sub; an explicit workdir "
8029
+ "wins). Pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
7971
8030
  "work, so put a whole sequence in ONE call — dependent steps with &&, independent ones with "
7972
8031
  "`;` (e.g. `cd app && npm test 2>&1 | tail -40`); a multi-line command goes in a "
7973
8032
  "triple-quoted string.\n"
@@ -7980,6 +8039,13 @@ def _run_command_prompt_line(shell, windows=None):
7980
8039
  " GITHUB: use `gh`. Multi-line text goes as a heredoc on stdin — "
7981
8040
  "`gh issue comment 12 --body-file - <<'EOF'` … `EOF` (same for gh issue create / gh pr "
7982
8041
  "create) — never write a body file first, and do not run `gh auth status` first.\n"
8042
+ # #362: how Claude Code checks work — scripts as heredocs, the one function not the app,
8043
+ # cheapest check first, and never a pipe between a test run and the decision it feeds.
8044
+ " CHECKS: a Python/Node script goes as a heredoc — `python3 - <<'EOF'` … `EOF` (node: "
8045
+ "`node --input-type=module - <<'EOF'`) — never `python3 -c` built from joined strings. To "
8046
+ "check one function, load its module and call it with real input instead of running the "
8047
+ "app. Order: syntax check → the ONE test → the suite once. Test output arrives with a TEST "
8048
+ "SUMMARY of each failure first, so never pipe it into tail/grep (a pipe hides the exit code).\n"
7983
8049
  " Read and search FILES with read_file_lines / grep_repo, not cat/grep/sed. `sudo`/`su` "
7984
8050
  "are blocked; piping into sh/python and writing or deleting outside the project ask the "
7985
8051
  "user first; an interactive input() CLI fails with EOFError. A server or GUI app is left "
@@ -8099,9 +8165,28 @@ def _test_commands_line(root: str) -> str:
8099
8165
  cmds = _project_test_commands(root) if root else []
8100
8166
  if not cmds:
8101
8167
  return ""
8102
- return (" TEST COMMANDS, from this project's own manifests — use these, never guess a runner "
8168
+ line = (" TEST COMMANDS, from this project's own manifests — use these, never guess a runner "
8103
8169
  "(a missing pytest fails silently behind a pipe): "
8104
8170
  + "; ".join(f"{d} → {c}" for d, c in cmds) + "\n")
8171
+ try:
8172
+ import gu_test_digest as _td
8173
+ forms = _td.one_test_forms(c for _, c in cmds)
8174
+ except Exception: # noqa: BLE001
8175
+ forms = []
8176
+ if forms:
8177
+ # #362: check a fix with ONE test (seconds), run the suite once at the end.
8178
+ line += (" ONE TEST while fixing (the suite once at the end): " + " · ".join(forms) + "\n")
8179
+ return line
8180
+
8181
+
8182
+ def _test_digest(output: str, cwd: str = "") -> str:
8183
+ """gu_test_digest.digest, or "" if the module is missing — a summary must never fail a command."""
8184
+ try:
8185
+ import gu_test_digest as _td
8186
+ return _td.digest(output or "", cwd or "")
8187
+ except Exception as e: # noqa: BLE001
8188
+ _log(f"test digest skip: {e}")
8189
+ return ""
8105
8190
 
8106
8191
 
8107
8192
  # bash's words for a line whose quotes do not pair up.
@@ -8994,10 +9079,10 @@ def _ws_cap_servers(max_live: int = 3) -> list:
8994
9079
  # running" link and the Shell details view (shell-details.mjs).
8995
9080
  #
8996
9081
  # THE EXIT CODE OUTLIVES THE AGENT. The agent is one process per turn; a build that ends after the
8997
- # turn has no parent left to wait() on it. So a shell line runs as `"$0" -c "$1"; rc=$?; echo $rc
8998
- # > "$2"; exit $rc` — the command text untouched, its exit code written to `exit_file` AND passed
8999
- # on (without the `exit $rc` every failure read as EXITED 0). The argv-only path (Windows)
9000
- # has no such wrapper and says "exit code unknown" instead of guessing.
9082
+ # turn has no parent left to wait() on it. So a shell line runs as `eval "$1"` under an EXIT trap
9083
+ # that writes its exit code to `exit_file` AND exits with it (without that every failure read as
9084
+ # EXITED 0) — see _run_state_wrapper, which also keeps the folder and exports for the next call.
9085
+ # The argv-only path has no such wrapper and says "exit code unknown" instead of guessing.
9001
9086
  _SHELLS_KEEP = 20 # newest records kept, running or not
9002
9087
  _SHELLS_KEEP_DONE_S = 86400 # a finished shell is listed for a day
9003
9088
 
@@ -9164,6 +9249,55 @@ def _shell_or_server(shells: list, servers: list, key: str):
9164
9249
  return max(cands, key=lambda x: float(x.get("started") or 0)) if cands else None
9165
9250
 
9166
9251
 
9252
+ # A SHELL THAT WRITES ITS OWN LOG (#362). Unity `-batchmode … -logFile Logs/all.log` prints nothing
9253
+ # for its whole 5-minute run — everything goes to that file — so the shell's output read "No output
9254
+ # available" until the end. When a shell has no output of its own, the file its command names is
9255
+ # the next best thing: an explicit log flag first, then `tee`, then the last `>`/`>>` redirect.
9256
+ _LOG_FLAG_RE = re.compile(r"^--?(?:log-?file|logfile|log-path|log)(?:=(.+))?$", re.I)
9257
+
9258
+
9259
+ def _shell_log_file(command: str, workdir: str = "") -> str:
9260
+ """The log file a command line writes to, as an absolute path that exists — or ""."""
9261
+ scan = _shell_scan(command or "")
9262
+ if scan.get("error"):
9263
+ return ""
9264
+ flag = tee = redir = ""
9265
+ for c in _shell_commands(scan["tokens"]):
9266
+ a = c["argv"]
9267
+ for i, w in enumerate(a):
9268
+ m = _LOG_FLAG_RE.match(w)
9269
+ if m:
9270
+ flag = m.group(1) or (a[i + 1] if i + 1 < len(a) else "") or flag
9271
+ if a and _shell_prog_name(a[0]) == "tee":
9272
+ files = [x for x in a[1:] if not x.startswith("-")]
9273
+ tee = files[-1] if files else tee
9274
+ for op, tgt in c["redirects"]:
9275
+ if op in (">", ">>", "&>", "&>>", "1>", "1>>") and tgt and not tgt.startswith("&"):
9276
+ redir = tgt
9277
+ for cand in (flag, tee, redir):
9278
+ if not cand or cand in ("/dev/null", "-"):
9279
+ continue
9280
+ path = cand.replace("$PWD", workdir or "").replace("${PWD}", workdir or "")
9281
+ path = os.path.expanduser(path)
9282
+ if not os.path.isabs(path):
9283
+ path = os.path.join(workdir or os.getcwd(), path)
9284
+ if os.path.isfile(path):
9285
+ return os.path.normpath(path)
9286
+ return ""
9287
+
9288
+
9289
+ def _file_tail(path: str, max_chars: int = 4000) -> str:
9290
+ try:
9291
+ with open(path, "rb") as fh:
9292
+ fh.seek(0, 2)
9293
+ size = fh.tell()
9294
+ fh.seek(max(0, size - max_chars))
9295
+ text = fh.read().decode("utf-8", errors="replace")
9296
+ return text if size <= max_chars else text.split("\n", 1)[-1]
9297
+ except OSError:
9298
+ return ""
9299
+
9300
+
9167
9301
  def _shell_kill(s: dict) -> None:
9168
9302
  """Stop a shell's whole process group and mark it killed (unless it already finished)."""
9169
9303
  import time as _t
@@ -9195,6 +9329,77 @@ def _shells_context_line(shells: list, now=None) -> str:
9195
9329
  + ". Read one with shell_output(<id>); stop one with stop_server(<id>).\n")
9196
9330
 
9197
9331
 
9332
+ # THE SHELL KEEPS ITS FOLDER AND EXPORTS BETWEEN CALLS (#362), the way Claude Code's Bash tool does.
9333
+ # Every run_command was a fresh bash, so `cd tools/x` or `source .venv/bin/activate` lasted one call
9334
+ # and every line started `cd … &&`. Not a long-lived pty (a hung one would wedge the turn): each
9335
+ # line runs in the outer shell via `eval`, and an EXIT trap — which also fires on `exit 3` — saves
9336
+ # `export -p` and `pwd` to this turn's state file; the next call sources it and starts in that
9337
+ # folder. One turn only: the file is removed when the agent exits. An explicit workdir wins.
9338
+ _RUN_STATE = {"path": ""}
9339
+ # Builtins that change the shell itself. As argv they run nothing (`export` is no program; macOS's
9340
+ # /usr/bin/cd exits 0 having changed nothing) — with a shell, a line using one goes to bash.
9341
+ _SHELL_STATE_BUILTINS = {"cd", "export", "source", ".", "set", "unset", "alias", "unalias", "pushd",
9342
+ "popd", "umask", "shopt", "declare", "typeset", "readonly", "trap", "ulimit",
9343
+ "hash", "eval"}
9344
+ _RUN_STATE_SKIP = r"^declare -x (PWD|OLDPWD|SHLVL|_)="
9345
+
9346
+
9347
+ def _run_state_path() -> str:
9348
+ import atexit
9349
+ import time as _t
9350
+ if not _RUN_STATE["path"]:
9351
+ d = os.path.join(os.path.expanduser("~"), ".gonext", "run-state")
9352
+ os.makedirs(d, exist_ok=True)
9353
+ # A background shell that ends after its turn rewrites its file; sweep day-old ones.
9354
+ try:
9355
+ for n in os.listdir(d):
9356
+ fp = os.path.join(d, n)
9357
+ if _t.time() - os.path.getmtime(fp) > 86400:
9358
+ os.remove(fp)
9359
+ except OSError:
9360
+ pass
9361
+ _RUN_STATE["path"] = os.path.join(d, f"{os.getpid()}.sh")
9362
+
9363
+ def _clean(base=_RUN_STATE["path"]):
9364
+ for ext in ("", ".cwd", ".tmp"):
9365
+ try:
9366
+ os.remove(base + ext)
9367
+ except OSError:
9368
+ pass
9369
+ atexit.register(_clean)
9370
+ return _RUN_STATE["path"]
9371
+
9372
+
9373
+ def _run_state_cwd() -> str:
9374
+ """The folder the last shell line ended in, this turn — or ""."""
9375
+ p = _RUN_STATE["path"]
9376
+ if not p:
9377
+ return ""
9378
+ try:
9379
+ with open(p + ".cwd", encoding="utf-8") as fh:
9380
+ return fh.read().strip()
9381
+ except OSError:
9382
+ return ""
9383
+
9384
+
9385
+ def _run_state_set_cwd(cwd: str) -> None:
9386
+ """Record the folder an && chain ended in — the chain runs its `cd` itself, not in bash."""
9387
+ try:
9388
+ with open(_run_state_path() + ".cwd", "w", encoding="utf-8") as fh:
9389
+ fh.write(cwd)
9390
+ except OSError:
9391
+ pass
9392
+
9393
+
9394
+ def _run_state_wrapper() -> str:
9395
+ """The outer script: $1 the line, $2 the exit file, $3 the state file."""
9396
+ return ('[ -f "$3" ] && . "$3" >/dev/null 2>&1; '
9397
+ "trap '__gu_rc=$?; echo $__gu_rc > \"$2\"; "
9398
+ f"export -p | grep -vE \"{_RUN_STATE_SKIP}\" > \"$3.tmp\" 2>/dev/null && mv -f \"$3.tmp\" \"$3\"; "
9399
+ "pwd > \"$3.cwd\"; exit $__gu_rc' EXIT; "
9400
+ 'eval "$1"')
9401
+
9402
+
9198
9403
  def _ws_scrubbed_env() -> dict:
9199
9404
  """Minimal child env for run_command. The worker process env holds secrets
9200
9405
  (GONEXT_WORKER_KEY etc. via worker.env) — a repo's test script must NEVER see
@@ -14578,6 +14783,14 @@ def run_agent_chat(cfg):
14578
14783
  if os.path.isdir(os.path.join(_active, _stripped)):
14579
14784
  _wd_in = _stripped
14580
14785
  wd = _rag_read_allowed(_wd_in or _default_ws_root())
14786
+ _wd_before = wd
14787
+ if not _wd_in and _run_shell_bin():
14788
+ _kept = _run_state_cwd()
14789
+ if _kept and os.path.isdir(_kept):
14790
+ try:
14791
+ wd = _rag_read_allowed(_kept)
14792
+ except Exception: # noqa: BLE001 — left the allowed folders: start over
14793
+ wd = _wd_before
14581
14794
  w = _ws_for_path(wd)
14582
14795
  if w is None or not w.get("allowRun"):
14583
14796
  # Contextual nudge (task #102): the #1 reason the model shells out here is
@@ -14601,7 +14814,12 @@ def run_agent_chat(cfg):
14601
14814
  + (_HEREDOC_HINT if "quote" in str(_scan["error"]) else ""))
14602
14815
  _last_obs["text"] = msg
14603
14816
  return msg
14604
- _use_shell = bool(_scan) and _shell_needed(_scan)
14817
+ # Once this turn has shell state (a cd, an activated venv), every line goes through
14818
+ # the shell so it starts from that state — not only lines with shell syntax.
14819
+ _words = [c["argv"][0] for c in _shell_commands(_scan["tokens"]) if c["argv"]] if _scan else []
14820
+ _builtin = sorted({w for w in _words if w in _SHELL_STATE_BUILTINS})
14821
+ _use_shell = bool(_scan) and (_shell_needed(_scan) or bool(_builtin) or bool(
14822
+ _RUN_STATE["path"] and os.path.exists(_RUN_STATE["path"])))
14605
14823
  _bg_err = _shell_background_error(_scan) if _use_shell else None
14606
14824
  if _bg_err:
14607
14825
  _last_obs["text"] = _bg_err
@@ -14614,7 +14832,10 @@ def run_agent_chat(cfg):
14614
14832
  # AN `&&` CHAIN IS ONE STEP (task #274): each part runs through THIS function —
14615
14833
  # allowlist, risk gate, approval, server detection, all of it — in order, and
14616
14834
  # the first failure stops the rest. `cd DIR` sets the folder for what follows.
14617
- _chain = _and_chain_parts(command)
14835
+ # A chain runs each part as a program; `export`/`source` in it would run nothing,
14836
+ # so with a shell such a line goes to bash whole. (`cd` the chain does itself.)
14837
+ _chain = None if (_use_shell and [b for b in _builtin if b != "cd"]) \
14838
+ else _and_chain_parts(command)
14618
14839
  if _chain:
14619
14840
  _chain_start = _t.time()
14620
14841
  _cur_wd = wd
@@ -14658,6 +14879,10 @@ def run_agent_chat(cfg):
14658
14879
  + (": " + " && ".join(_rest) if _rest else "")
14659
14880
  if _stopped else f"CHAIN DONE — all {len(_chain)} parts ran")
14660
14881
  result = _summary + "\n" + "\n".join(_outs)
14882
+ if _shell_bin and _cur_wd != wd:
14883
+ _run_state_set_cwd(_cur_wd) # the next call starts where the chain ended
14884
+ result += (f"\n(the shell is now in {_cur_wd} — the next run_command "
14885
+ "without a workdir starts there)")
14661
14886
  _last_obs["text"] = result
14662
14887
  return result
14663
14888
  # REFUSE SHELL SYNTAX BEFORE SPAWNING (task #204). Without this, `cd X && Y`
@@ -14677,8 +14902,11 @@ def run_agent_chat(cfg):
14677
14902
  # Every simple command in the line, including those inside $( ), gets the
14678
14903
  # checks an argv command gets — allowlist, privilege, denylist, deletes —
14679
14904
  # plus the shell-only ones (redirect outside the project, `| sh`).
14905
+ _resolved = _shell_resolve_runner_vars(command, _scan)
14906
+ _risk_scan = _shell_scan(_resolved) if _resolved else _scan
14680
14907
  _refuse, _hard_reason, _ask_reason = _ws_shell_command_risk(
14681
- _scan, _WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
14908
+ _risk_scan if not _risk_scan.get("error") else _scan,
14909
+ _WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
14682
14910
  if _refuse:
14683
14911
  _last_obs["text"] = _refuse
14684
14912
  return _refuse
@@ -14775,12 +15003,13 @@ def run_agent_chat(cfg):
14775
15003
  if _use_shell:
14776
15004
  _cmd_text, _attrib_note = _with_attribution_text(
14777
15005
  command, _scan, _attribution_settings(wd))
14778
- # The exit code is written to a file so it outlives this turn's process —
14779
- # the line itself runs untouched as $1 (see BACKGROUND SHELLS).
15006
+ # The exit code is written to a file so it outlives this turn's process, and
15007
+ # the folder + exports are kept for the next call — the line itself runs
15008
+ # untouched as $1 (see BACKGROUND SHELLS, _run_state_wrapper).
14780
15009
  # Git Bash reads C:/x, not C:\x, inside the line it runs.
14781
15010
  _fs = (lambda x: x.replace("\\", "/")) if _IS_WINDOWS else (lambda x: x)
14782
- argv = [_shell_bin, "-c", '"$0" -c "$1"; rc=$?; echo $rc > "$2"; exit $rc',
14783
- _fs(_shell_bin), _cmd_text, _fs(exit_path)]
15011
+ argv = [_shell_bin, "-c", _run_state_wrapper(),
15012
+ _fs(_shell_bin), _cmd_text, _fs(exit_path), _fs(_run_state_path())]
14784
15013
  else:
14785
15014
  argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
14786
15015
  if _attrib_note:
@@ -14830,7 +15059,11 @@ def run_agent_chat(cfg):
14830
15059
  while True:
14831
15060
  rc = proc.poll()
14832
15061
  if rc is not None:
14833
- out = _ws_distill_output(_read_log())
15062
+ _raw = _read_log()
15063
+ out = _ws_distill_output(_raw)
15064
+ # The result FIRST (#362): counts and each failure with file:line, read
15065
+ # from the WHOLE output — the observation below is capped, this is not.
15066
+ _digest = _test_digest(_raw, wd)
14834
15067
  # Task #90 Phase 1 — truthful perception: never claim success the
14835
15068
  # tool didn't observe. exit 0 is a FACT about the process, not
14836
15069
  # proof of the GOAL (e.g. `npm start` exits 0 when its port is
@@ -14853,7 +15086,7 @@ def run_agent_chat(cfg):
14853
15086
  # what `cd X && Y` used to do — and the model read the silence as a
14854
15087
  # clean pass. An absence cannot be reasoned about; a stated fact can.
14855
15088
  result = (
14856
- f"{status}\n{out}" if out.strip()
15089
+ f"{status}\n" + (f"{_digest}\n\n" if _digest else "") + out if out.strip()
14857
15090
  else f"{status}\nNO OUTPUT: the command printed nothing on stdout or "
14858
15091
  "stderr. If you expected output, it probably did not do what "
14859
15092
  "you intended — check the command before repeating it."
@@ -14873,6 +15106,11 @@ def run_agent_chat(cfg):
14873
15106
  f"Command failed behind a pipe → {_trim_words(_hidden)}"})
14874
15107
  if rc != 0 and _use_shell and _SHELL_QUOTE_ERR_RE.search(out or ""):
14875
15108
  result += _HEREDOC_HINT
15109
+ # Say where the shell now is, so a next `cd sub` is not done twice (#362).
15110
+ _now = _run_state_cwd() if _use_shell else ""
15111
+ if _now and os.path.realpath(_now) != os.path.realpath(wd):
15112
+ result += (f"\n(the shell is now in {_now} — the next run_command "
15113
+ "without a workdir starts there)")
14876
15114
  if rc != 0 and _WS_PERM_ERR_RE.search(out or ""):
14877
15115
  _f = _ws_access_facts()
14878
15116
  result = f"{result}\n\n{_ws_access_report(_f, _ws_paths_in(command))}"
@@ -15088,8 +15326,23 @@ def run_agent_chat(cfg):
15088
15326
  if st["state"] == "exited":
15089
15327
  head += ("\nexit 0 alone does NOT prove the intended effect happened — check "
15090
15328
  "the output and VERIFY.")
15329
+ if st["state"] != "running":
15330
+ try:
15331
+ with open(sh.get("log") or "", encoding="utf-8", errors="replace") as fh:
15332
+ _dg = _test_digest(fh.read(), str(sh.get("workdir") or ""))
15333
+ except OSError:
15334
+ _dg = ""
15335
+ if _dg:
15336
+ head += "\n" + _dg
15337
+ tail_note = ""
15338
+ if not new.strip() and int(sh.get("read_offset") or 0) == 0:
15339
+ lf = _shell_log_file(str(sh.get("command") or ""), str(sh.get("workdir") or ""))
15340
+ lt = _file_tail(lf).strip() if lf else ""
15341
+ if lt:
15342
+ tail_note = (f"\nIt prints nothing itself; the end of the log it writes "
15343
+ f"({lf}):\n{lt}")
15091
15344
  msg = head + "\n" + (f"New output:\n{new}" if new.strip()
15092
- else "No new output since the last read.")
15345
+ else "No new output since the last read.") + tail_note
15093
15346
  _emit({"type": "step", "text":
15094
15347
  f"Shell {sh.get('id')} {_shell_status_text(st)} → {_trim_words(str(sh.get('command')))}"})
15095
15348
  _last_obs["text"] = msg
package/gu-repl.mjs CHANGED
@@ -139,7 +139,7 @@ import {
139
139
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
140
140
  import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
141
141
  import { clearLogsNoticeText } from "./clear-logs.mjs";
142
- import { killShell, logTail, readShells, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
142
+ import { commandLogFile, killShell, logTail, readShells, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
143
143
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
144
144
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
145
145
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
@@ -9510,8 +9510,18 @@ function printShellDetails(rec) {
9510
9510
  const width = Math.max(20, process.stdout.columns || 80);
9511
9511
  const st = shellState(rec);
9512
9512
  shellsViewed.add(String(rec.id));
9513
+ // Nothing printed of its own (Unity -batchmode -logFile …)? Show the log its command writes.
9514
+ let output = logTail(rec.log);
9515
+ let outputFrom = "";
9516
+ if (!output.length) {
9517
+ const lf = commandLogFile(rec.command, rec.workdir);
9518
+ if (lf) {
9519
+ output = logTail(lf);
9520
+ outputFrom = lf.startsWith(String(rec.workdir || "\0") + "/") ? lf.slice(String(rec.workdir).length + 1) : lf;
9521
+ }
9522
+ }
9513
9523
  const rows = renderShellDetails({
9514
- rec, st, output: logTail(rec.log), width: width - 1, gutter: GUTTER,
9524
+ rec, st, output, outputFrom, width: width - 1, gutter: GUTTER,
9515
9525
  style: { title: (t) => bold(accent(t)), label: bold, running: accent, ok: accent, bad: red, dim },
9516
9526
  });
9517
9527
  console.log("\n" + GUTTER + accent("─".repeat(Math.max(10, width - 1 - GUTTER.length))));
@@ -0,0 +1,352 @@
1
+ """Test-result digest for run_command (task #362, GitHub #142) — PURE.
2
+
3
+ THE MODEL READ TEST OUTPUT THROUGH `| tail` AND `| grep`. Measured 2026-09-23 → 10-14 in local
4
+ Mongo: 84 of 397 turns ran tests, 32 of them spent 4+ steps doing it, and test output was piped
5
+ into tail/head/grep to find the result — the pipe that hid `No module named pytest` and let a
6
+ commit go in untested (#140 review). The answer is not a smaller window onto the output; it is
7
+ the result first: the counts, then each failing test with its message and file:line, computed
8
+ from the WHOLE output (the observation itself is capped, a digest never is).
9
+
10
+ One parser per runner, each recognising its own summary line, so a line that runs two runners
11
+ (`python3 -m unittest … && node --test`, this repo's npm test) gets both. Nothing recognised →
12
+ "" and the output is shown exactly as before. Names are the ones the runner prints, which are the
13
+ ones its run-one-test form takes (unittest's dotted id, pytest's node id, go's TestName…).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+
20
+ MAX_FAILURES = 10 # per runner; the rest are counted
21
+ MAX_MESSAGE = 220 # chars of one failure's message
22
+
23
+
24
+ def _clip(text: str, n: int = MAX_MESSAGE) -> str:
25
+ # Line breaks become one space; runs of spaces INSIDE a line stay — 'GU AGENT' vs 'GU AGENT'
26
+ # is the whole failure, and collapsing it printed two identical strings.
27
+ text = re.sub(r"[ \t]*\n[ \t]*", " ", (text or "").strip())
28
+ return text if len(text) <= n else text[: n - 1] + "…"
29
+
30
+
31
+ def _rel(path: str, cwd: str) -> str:
32
+ path = (path or "").strip().strip("'\"")
33
+ if path.startswith("file://"):
34
+ path = path[7:]
35
+ if cwd:
36
+ for base in {cwd, os.path.realpath(cwd)}:
37
+ if base and path.startswith(base.rstrip("/") + "/"):
38
+ return path[len(base.rstrip("/")) + 1:]
39
+ return path
40
+
41
+
42
+ def _fail(name, message="", where="") -> dict:
43
+ return {"name": _clip(name, 160), "message": _clip(message), "where": where}
44
+
45
+
46
+ # ---- node --test, spec reporter (the default) ---------------------------------------------------
47
+ _NODE_SPEC_SUM = re.compile(r"^ℹ (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$", re.M)
48
+
49
+
50
+ def _node_spec(out: str, cwd: str):
51
+ counts = {}
52
+ for k, v in _NODE_SPEC_SUM.findall(out):
53
+ counts[k] = counts.get(k, 0) + int(v)
54
+ if "tests" not in counts:
55
+ return None
56
+ fails = []
57
+ block = out.split("✖ failing tests:", 1)
58
+ if len(block) == 2:
59
+ # "test at FILE:L:C" / "✖ name (ms)" / indented message lines, until the next "test at".
60
+ for m in re.finditer(r"^test at (\S+?):(\d+):\d+\n✖ (.+?) \([\d.]+m?s\)[^\n]*\n((?:[ \t].*\n?|\n)*)",
61
+ block[1], re.M):
62
+ body = [ln.strip() for ln in m.group(4).splitlines()]
63
+ msg = []
64
+ for ln in body:
65
+ if ln.startswith("at ") or ln.startswith("{") or ln.startswith("generatedMessage"):
66
+ break
67
+ if ln and ln not in ("+ actual - expected", "^"):
68
+ msg.append(ln)
69
+ text = " ".join(msg[:5])
70
+ if text.strip("'\"") == "test failed":
71
+ # A FILE that failed to load: the runner only says 'test failed'; the cause
72
+ # (ERR_MODULE_NOT_FOUND, a SyntaxError) was printed above the summary.
73
+ err = re.search(r"^(\w*Error\b[^\n]*)$", out, re.M)
74
+ text = _rel_in(err.group(1), cwd) if err else text
75
+ fails.append(_fail(m.group(3), text, f"{_rel(m.group(1), cwd)}:{m.group(2)}"))
76
+ else:
77
+ # A file that crashed before any test ran: "✖ tests/c.test.mjs (78ms)", the error above it.
78
+ err = re.search(r"^(\w*Error\b[^\n]*)$", out, re.M)
79
+ for m in re.finditer(r"^✖ (.+?) \([\d.]+m?s\)", out, re.M):
80
+ fails.append(_fail(m.group(1), _rel_in(err.group(1), cwd) if err else ""))
81
+ return {"runner": "node --test", "passed": counts.get("pass", 0), "failed": counts.get("fail", 0),
82
+ "skipped": counts.get("skipped", 0) + counts.get("todo", 0), "errors": counts.get("cancelled", 0),
83
+ "failures": fails}
84
+
85
+
86
+ def _rel_in(text: str, cwd: str) -> str:
87
+ """Absolute paths under cwd inside a message, shortened."""
88
+ if not cwd:
89
+ return text
90
+ for base in {cwd.rstrip("/"), os.path.realpath(cwd).rstrip("/")}:
91
+ text = text.replace("file://" + base + "/", "").replace(base + "/", "")
92
+ return text
93
+
94
+
95
+ # ---- node --test / anything TAP ------------------------------------------------------------------
96
+ _TAP_SUM = re.compile(r"^# (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$", re.M)
97
+
98
+
99
+ def _tap(out: str, cwd: str):
100
+ counts = {}
101
+ for k, v in _TAP_SUM.findall(out):
102
+ counts[k] = counts.get(k, 0) + int(v)
103
+ if "tests" not in counts or "TAP version" not in out and "# Subtest" not in out:
104
+ return None
105
+ fails = []
106
+ for m in re.finditer(r"^[ \t]*not ok \d+ - (.+?)(?: # .*)?\n((?:[ \t]{2,}.*\n?|[ \t]*\n)*)", out, re.M):
107
+ yaml = m.group(2)
108
+ loc = re.search(r"location: '([^']+?):(\d+):\d+'", yaml)
109
+ err = re.search(r"error: \|-?\n((?:[ \t]{4,}.*\n?|[ \t]*\n)+)", yaml) or re.search(r"error: '([^']*)'", yaml)
110
+ msg = ""
111
+ if err:
112
+ msg = " ".join(ln.strip() for ln in err.group(1).splitlines()
113
+ if ln.strip() and ln.strip() not in ("+ actual - expected", "^"))
114
+ fails.append(_fail(m.group(1), msg, f"{_rel(loc.group(1), cwd)}:{loc.group(2)}" if loc else ""))
115
+ return {"runner": "node --test (TAP)", "passed": counts.get("pass", 0), "failed": counts.get("fail", 0),
116
+ "skipped": counts.get("skipped", 0) + counts.get("todo", 0), "errors": counts.get("cancelled", 0),
117
+ "failures": fails}
118
+
119
+
120
+ # ---- python unittest ------------------------------------------------------------------------------
121
+ def _unittest(out: str, cwd: str):
122
+ ran = re.findall(r"^Ran (\d+) tests? in [\d.]+s\s*$", out, re.M)
123
+ if not ran:
124
+ return None
125
+ total = sum(int(x) for x in ran)
126
+ failures = errors = skipped = 0
127
+ for line in re.findall(r"^(?:OK|FAILED)(?: \((.*)\))?\s*$", out, re.M):
128
+ for k, v in re.findall(r"(\w[\w ]*?)=(\d+)", line or ""):
129
+ k = k.strip()
130
+ if k == "failures":
131
+ failures += int(v)
132
+ elif k == "errors":
133
+ errors += int(v)
134
+ elif k == "skipped":
135
+ skipped += int(v)
136
+ fails = []
137
+ for m in re.finditer(r"^(FAIL|ERROR): (\S+) \((\S+?)\)[^\n]*\n-{20,}\n(.*?)(?=^={20,}|^-{20,}\nRan |\Z)",
138
+ out, re.M | re.S):
139
+ kind, short, dotted, body = m.groups()
140
+ lines = [ln for ln in body.rstrip().splitlines() if ln.strip()]
141
+ msg = lines[-1].strip() if lines else kind
142
+ locs = [(f, ln) for f, ln in re.findall(r'File "([^"]+)", line (\d+)', body)
143
+ if "/lib/python" not in f and "unittest/" not in f]
144
+ where = f"{_rel(locs[-1][0], cwd)}:{locs[-1][1]}" if locs else ""
145
+ fails.append(_fail(dotted if "." in dotted else short, msg, where))
146
+ return {"runner": "unittest", "passed": max(0, total - failures - errors - skipped), "failed": failures,
147
+ "skipped": skipped, "errors": errors, "failures": fails}
148
+
149
+
150
+ # ---- pytest ---------------------------------------------------------------------------------------
151
+ _PYTEST_SUM = re.compile(r"^=+ ((?:\d+ \w+(?:, )?)+) in [\d.]+s(?: \([^)]*\))? =+\s*$", re.M)
152
+
153
+
154
+ def _pytest(out: str, cwd: str):
155
+ sums = _PYTEST_SUM.findall(out)
156
+ if not sums:
157
+ return None
158
+ c = {}
159
+ for k_v in ", ".join(sums).split(", "):
160
+ m = re.match(r"(\d+) (\w+)", k_v.strip())
161
+ if m:
162
+ c[m.group(2)] = c.get(m.group(2), 0) + int(m.group(1))
163
+ fails = []
164
+ for m in re.finditer(r"^(FAILED|ERROR) (\S+?)(?: - (.*))?$", out, re.M):
165
+ nodeid = m.group(2)
166
+ loc = re.search(rf"^({re.escape(nodeid.split('::')[0])}):(\d+): ", out, re.M)
167
+ fails.append(_fail(nodeid, m.group(3) or m.group(1), f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
168
+ return {"runner": "pytest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
169
+ "skipped": c.get("skipped", 0) + c.get("xfailed", 0), "errors": c.get("error", 0) + c.get("errors", 0),
170
+ "failures": fails}
171
+
172
+
173
+ # ---- jest / vitest --------------------------------------------------------------------------------
174
+ def _jest(out: str, cwd: str):
175
+ m = re.search(r"^Tests:\s+(.*\d+ total)\s*$", out, re.M)
176
+ if not m:
177
+ return None
178
+ c = {k: int(v) for v, k in re.findall(r"(\d+) (\w+)", m.group(1))}
179
+ fails = []
180
+ for f in re.finditer(r"^\s*● (.+?)\n\n((?:.*\n){1,6})", out, re.M):
181
+ if f.group(1).startswith("Console"):
182
+ continue
183
+ msg = next((ln.strip() for ln in f.group(2).splitlines() if ln.strip()), "")
184
+ loc = re.search(r"\(([^()\s]+?):(\d+):\d+\)", f.group(2)) or re.search(r"at .*?([\w./-]+\.\w+):(\d+):\d+", out)
185
+ fails.append(_fail(f.group(1).replace(" › ", " > "), msg, f"{_rel(loc.group(1), cwd)}:{loc.group(2)}" if loc else ""))
186
+ return {"runner": "jest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
187
+ "skipped": c.get("skipped", 0) + c.get("todo", 0), "errors": 0, "failures": fails}
188
+
189
+
190
+ def _vitest(out: str, cwd: str):
191
+ m = re.search(r"^\s*Tests\s+(.*?)\s*\((\d+)\)\s*$", out, re.M)
192
+ if not m or "|" not in m.group(1) and not re.search(r"\d+ (passed|failed)", m.group(1)):
193
+ return None
194
+ c = {k: int(v) for v, k in re.findall(r"(\d+) (\w+)", m.group(1))}
195
+ fails = []
196
+ for f in re.finditer(r"^[ \t]*FAIL[ \t]+(\S+) > (.+?)\n((?:.*\n){0,4})", out, re.M):
197
+ msg = next((ln.strip() for ln in f.group(3).splitlines() if re.search(r"Error|expected", ln)), "")
198
+ loc = re.search(r"❯ (\S+?):(\d+):\d+", f.group(3))
199
+ fails.append(_fail(f"{f.group(1)} > {f.group(2).strip()}", msg,
200
+ f"{loc.group(1)}:{loc.group(2)}" if loc else f.group(1)))
201
+ if not fails: # no "Failed Tests" section (a reporter without it): the × lines
202
+ for f in re.finditer(r"^[ \t]*× (.+?)(?:[ \t]+\d+ms)?\n[ \t]*→ (.*)$", out, re.M):
203
+ fails.append(_fail(f.group(1), f.group(2)))
204
+ return {"runner": "vitest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
205
+ "skipped": c.get("skipped", 0) + c.get("todo", 0), "errors": 0, "failures": fails}
206
+
207
+
208
+ # ---- go test ---------------------------------------------------------------------------------------
209
+ def _gotest(out: str, cwd: str):
210
+ pkgs = re.findall(r"^(ok|FAIL|---? FAIL)\s+(\S+)\s+(?:[\d.]+s|\(cached\)|\[)", out, re.M)
211
+ fails_raw = list(re.finditer(r"^[ \t]*--- FAIL: (\S+) \([\d.]+s\)", out, re.M))
212
+ if not pkgs and not fails_raw:
213
+ return None
214
+ passed = len(re.findall(r"^\s*--- PASS: ", out, re.M))
215
+ skipped = len(re.findall(r"^\s*--- SKIP: ", out, re.M))
216
+ fails = []
217
+ for f in fails_raw:
218
+ # Since Go 1.14 a test's t.Error lines are printed between its "=== RUN" and "--- FAIL".
219
+ run = re.search(rf"^=== RUN\s+{re.escape(f.group(1))}\n((?:[ \t]{{4,}}.*\n)*)", out, re.M)
220
+ first = next((ln.strip() for ln in (run.group(1) if run else "").splitlines() if ln.strip()), "")
221
+ loc = re.match(r"([\w./-]+\.go):(\d+): (.*)", first)
222
+ fails.append(_fail(f.group(1), loc.group(3) if loc else first, f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
223
+ ok_pkgs = sum(1 for k, _ in pkgs if k == "ok")
224
+ bad_pkgs = sum(1 for k, _ in pkgs if k == "FAIL")
225
+ return {"runner": "go test", "passed": passed, "failed": len(fails_raw) or bad_pkgs, "skipped": skipped,
226
+ "errors": 0, "failures": fails,
227
+ "note": f"{ok_pkgs} package(s) ok, {bad_pkgs} FAIL" if pkgs else ""}
228
+
229
+
230
+ # ---- cargo test ------------------------------------------------------------------------------------
231
+ def _cargo(out: str, cwd: str):
232
+ res = re.findall(r"^test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored", out, re.M)
233
+ if not res:
234
+ return None
235
+ p = sum(int(a) for a, _, _ in res)
236
+ f = sum(int(b) for _, b, _ in res)
237
+ s = sum(int(c) for _, _, c in res)
238
+ fails = []
239
+ for m in re.finditer(r"^---- (\S+) stdout ----\n((?:.*\n){1,4})", out, re.M):
240
+ body = m.group(2)
241
+ loc = re.search(r"panicked at ([^\s:]+):(\d+):\d+", body)
242
+ msg = [ln.strip() for ln in body.splitlines() if ln.strip() and "panicked at" not in ln]
243
+ fails.append(_fail(m.group(1), msg[0] if msg else "panicked", f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
244
+ return {"runner": "cargo test", "passed": p, "failed": f, "skipped": s, "errors": 0, "failures": fails}
245
+
246
+
247
+ # ---- tsc / eslint (checks, not tests — same shape: count, then the first problems) ----------------
248
+ def _tsc(out: str, cwd: str):
249
+ errs = re.findall(r"^(.+?)\((\d+),\d+\): error (TS\d+): (.+)$", out, re.M)
250
+ if not errs:
251
+ return None
252
+ fails = [_fail(code, msg, f"{_rel(f, cwd)}:{ln}") for f, ln, code, msg in errs]
253
+ return {"runner": "tsc", "check": True, "errors": len(errs),
254
+ "files": len({e[0] for e in errs}), "failures": fails}
255
+
256
+
257
+ def _eslint(out: str, cwd: str):
258
+ m = re.search(r"^✖ (\d+) problems? \((\d+) errors?, (\d+) warnings?\)", out, re.M)
259
+ if not m:
260
+ return None
261
+ fails, cur = [], ""
262
+ for ln in out.splitlines():
263
+ if ln and not ln.startswith(" ") and not ln.startswith("✖"):
264
+ cur = _rel(ln.strip(), cwd)
265
+ continue
266
+ e = re.match(r"^\s+(\d+):\d+\s+error\s+(.+?)\s{2,}(\S+)\s*$", ln)
267
+ if e and cur:
268
+ fails.append(_fail(e.group(3), e.group(2), f"{cur}:{e.group(1)}"))
269
+ return {"runner": "eslint", "check": True, "errors": int(m.group(2)), "warnings": int(m.group(3)),
270
+ "failures": fails}
271
+
272
+
273
+ _PARSERS = (_unittest, _pytest, _node_spec, _tap, _jest, _vitest, _gotest, _cargo, _tsc, _eslint)
274
+
275
+
276
+ def parse(output: str, cwd: str = "") -> list[dict]:
277
+ """Every runner whose summary appears in `output`, in that order. Never raises."""
278
+ found = []
279
+ for p in _PARSERS:
280
+ try:
281
+ r = p(output or "", cwd)
282
+ except Exception: # noqa: BLE001 — a digest must never break a command result
283
+ r = None
284
+ if r:
285
+ found.append(r)
286
+ return found
287
+
288
+
289
+ def _counts(r: dict) -> str:
290
+ if r.get("check"):
291
+ bits = [f"{r['errors']} error{'s' if r['errors'] != 1 else ''}"]
292
+ if r.get("warnings"):
293
+ bits.append(f"{r['warnings']} warning{'s' if r['warnings'] != 1 else ''}")
294
+ return " · ".join(bits)
295
+ bits = [f"{r['passed']} passed"]
296
+ if r["failed"]:
297
+ bits.append(f"{r['failed']} failed")
298
+ if r.get("errors"):
299
+ bits.append(f"{r['errors']} error{'s' if r['errors'] != 1 else ''}")
300
+ if r.get("skipped"):
301
+ bits.append(f"{r['skipped']} skipped")
302
+ if r.get("note"):
303
+ bits.append(r["note"])
304
+ return " · ".join(bits)
305
+
306
+
307
+ def digest(output: str, cwd: str = "") -> str:
308
+ """The block run_command puts ABOVE the output: counts per runner, then each failure as
309
+ `✗ name — message (file:line)`. "" when no runner was recognised."""
310
+ runs = parse(output, cwd)
311
+ if not runs:
312
+ return ""
313
+ bad = [r for r in runs if r.get("failed") or r.get("errors") or r.get("failures")]
314
+ if not bad:
315
+ return "TEST SUMMARY: " + " · ".join(f"{r['runner']} {_counts(r)}" for r in runs) + " — all passed."
316
+ lines = ["TEST SUMMARY (from the whole output — read this before the log below):"]
317
+ for r in runs:
318
+ lines.append(f" {r['runner']}: {_counts(r)}")
319
+ for f in r["failures"][:MAX_FAILURES]:
320
+ tail = f" ({f['where']})" if f["where"] else ""
321
+ lines.append(f" ✗ {f['name']}" + (f" — {f['message']}" if f["message"] else "") + tail)
322
+ if len(r["failures"]) > MAX_FAILURES:
323
+ lines.append(f" … and {len(r['failures']) - MAX_FAILURES} more")
324
+ return "\n".join(lines)
325
+
326
+
327
+ # ---- running ONE test ------------------------------------------------------------------------------
328
+ # Per runner, the form that runs a single test — what the model needs after the first full run, so
329
+ # a fix is checked in seconds and the suite runs once at the end. Keyed on what the project's own
330
+ # test command contains.
331
+ ONE_TEST = (
332
+ (r"\bnode --test\b", "node --test --test-name-pattern='<name>' <file>"),
333
+ (r"\bunittest\b", "python3 -m unittest <module>.<Class>.<test_name>"),
334
+ (r"\bpytest\b", "python3 -m pytest '<file>::<test>' -x"),
335
+ (r"\bvitest\b", "npx vitest run <file> -t '<name>'"),
336
+ (r"\bjest\b", "npx jest <file> -t '<name>'"),
337
+ (r"\bgo test\b", "go test -run '^<TestName>$' ./<pkg>"),
338
+ (r"\bcargo test\b", "cargo test <name>"),
339
+ (r"\bmvn\b.*\btest\b", "mvn test -Dtest=<Class>#<method>"),
340
+ (r"\bgradlew?\b.*\btest\b", "./gradlew test --tests '<Class.method>'"),
341
+ (r"\brspec\b", "bundle exec rspec <file>:<line>"),
342
+ )
343
+
344
+
345
+ def one_test_forms(commands) -> list[str]:
346
+ """Distinct run-one-test forms for the runners named in these test commands."""
347
+ seen = []
348
+ for cmd in commands or []:
349
+ for pat, form in ONE_TEST:
350
+ if re.search(pat, cmd or "") and form not in seen:
351
+ seen.append(form)
352
+ return seen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.831",
3
+ "version": "1.0.833",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -135,6 +135,7 @@
135
135
  "gonext_agent_chat.py",
136
136
  "gu_mcp.py",
137
137
  "gu_skills.py",
138
+ "gu_test_digest.py",
138
139
  "gu_hooks.py",
139
140
  "prompt_scaffold_check.py",
140
141
  "gonext_transcribe.py",
package/shell-details.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { join } from "node:path";
16
+ import { isAbsolute, join, normalize } from "node:path";
17
17
  import { spawnSync } from "node:child_process";
18
18
 
19
19
  export const shellsPath = (home = homedir()) => join(home, ".gonext", "shells.json");
@@ -135,6 +135,38 @@ export function logTail(path, { lines = 15, maxBytes = 64 * 1024 } = {}) {
135
135
  return out.slice(-lines);
136
136
  }
137
137
 
138
+ const unq = (w) => w.replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
139
+ const WORD = String.raw`("[^"]*"|'[^']*'|[^\s;&|<>]+)`;
140
+
141
+ /**
142
+ * The log file a command writes to, when the shell itself prints nothing (#362: Unity's
143
+ * `-logFile Logs/all.log` left Shell details on "No output available" for a 5-minute run).
144
+ * An explicit log flag first, then `tee`, then the last `>`/`>>` redirect — the same order as
145
+ * the agent's _shell_log_file. → an absolute path that exists, or "".
146
+ */
147
+ export function commandLogFile(command, workdir = "") {
148
+ const cmd = String(command ?? "");
149
+ const last = (re) => {
150
+ let m, v = "";
151
+ for (const r = new RegExp(re, "gi"); (m = r.exec(cmd)); ) v = m[1] ?? m[2] ?? v;
152
+ return v;
153
+ };
154
+ const flag = last(String.raw`(?:^|\s)--?(?:log-?file|logfile|log-path|log)(?:=` + WORD + String.raw`|\s+` + WORD + ")");
155
+ const tee = last(String.raw`\btee\s+(?:-a\s+)?` + WORD);
156
+ const redir = last(String.raw`(?:^|[^0-9&>])(?:1|&)?>>?\s*(?!&)` + WORD);
157
+ for (const raw of [flag, tee, redir]) {
158
+ if (!raw) continue;
159
+ let p = unq(raw).replace(/\$\{?PWD\}?/g, workdir || "");
160
+ if (p === "/dev/null" || p === "-") continue;
161
+ if (p.startsWith("~/")) p = join(homedir(), p.slice(2));
162
+ if (!isAbsolute(p)) p = join(workdir || process.cwd(), p);
163
+ try {
164
+ if (statSync(p).isFile()) return normalize(p);
165
+ } catch { /* not there (yet) */ }
166
+ }
167
+ return "";
168
+ }
169
+
138
170
  /** Word-wrap `text` to `width`; at most `maxRows`, the last ending in "…" when cut. */
139
171
  export function wrapClip(text, width, maxRows = 4) {
140
172
  const w = Math.max(10, width);
@@ -166,7 +198,7 @@ const plain = (s) => s;
166
198
  * The Shell details block, one string per row, no row wider than `width`.
167
199
  * `style`: {title, label, running, ok, bad, dim} paint functions (identity in tests).
168
200
  */
169
- export function renderShellDetails({ rec, st, output = [], width = 80, gutter = " ", style = {} }) {
201
+ export function renderShellDetails({ rec, st, output = [], outputFrom = "", width = 80, gutter = " ", style = {} }) {
170
202
  const p = { title: plain, label: plain, running: plain, ok: plain, bad: plain, dim: plain, ...style };
171
203
  const inner = Math.max(20, width - gutter.length);
172
204
  const valW = inner - LABEL_W;
@@ -183,7 +215,8 @@ export function renderShellDetails({ rec, st, output = [], width = 80, gutter =
183
215
  ...cmd.slice(1).map((c) => gutter + pad + c),
184
216
  ];
185
217
  if (rec?.workdir) rows.push(row("Folder:", p.dim(wrapClip(rec.workdir, valW, 1)[0])));
186
- rows.push("", gutter + p.label("Output:"));
218
+ // From the log file the command writes, when the shell printed nothing itself — say which.
219
+ rows.push("", gutter + p.label("Output:") + (outputFrom ? p.dim(` (from ${wrapClip(outputFrom, Math.max(10, inner - 20), 1)[0]})`) : ""));
187
220
  if (!output.length) rows.push(gutter + p.dim("No output available"));
188
221
  else for (const l of output) rows.push(gutter + (l.length > inner ? l.slice(0, inner - 1) + "…" : l));
189
222
  return rows;