Skip to content

agent forget how to store the logs #1063

Description

@userVY911

I usually ask the agent to store updates to the code in a dedicated format in daily MD files.

After a while, however, the agent with Deepseek V4 Flash 0731 forgot the instructions and started storing the trace logs in random MD files, sometimes in existing files and sometimes in new files stored in /tmp.

I fixed this by forcing the agent to create a CI script that required a commentary for the log. Then the CI script stored the log in the expected format and file.

I share my fix with you, but I'm not sure how you want the harness to manage this with the agent.

# BASH :: ci.sh
#!/usr/bin/env sh
# project local CI — shared by pre-commit/pre-push hooks and humans.
#
# Usage: scripts/ci.sh {lint|types|frontend|test|e2e|e2e-fast|spec-inventory|docs-links|logbook|all}
#   lint     ruff check + format check + docs-links + spec-inventory + logbook
#            (fast; used by pre-commit)
#   types    pyright type checking
#   frontend frontend typecheck + lint + tests + build
#   test     pytest unit/contract tests
#   e2e      Playwright browser tests (boots an isolated backend; needs Redis)
#   e2e-fast quick E2E iteration: failure stack + ONE healthy spec
#            (default: navigation; pass a spec name as second arg, e.g.
#            scripts/ci.sh e2e-fast scenes-board)
#   spec-inventory  every e2e *.spec.ts must have a row in the docs inventory
#   docs-links  every relative .md link in docs/ must resolve to an existing file
#   logbook   every .agent/LOGBOOKS/*.md entry header must be
#             '[UTC timestamp] [agent] [task] [event type]' (LOGBOOK.md)
#   all      lint + types + frontend + test (used by pre-push)
#
# LOG COMMENTARY (mandatory for lint and all):
#   The lint (pre-commit) and all (pre-push) targets REQUIRE a short free-form
#   commentary describing what was done/updated since the last CI execution.
#   Pass it via --log "<text>" or the CI_LOG_COMMENT env var (the pre-commit/
#   pre-push hooks inherit it from `git commit` / `git push`). Without it the
#   target FAILS with:
#     "ci: FAIL - a log commentary about what has been updated is mandatory and
#      expected to validate the CI"
#   On success ci.sh APPENDS a properly formatted entry (header '[UTC timestamp]
#   [agent] [task] [event type]' + the commentary body) to today's day file
#   .agent/LOGBOOKS/<YYYY-MM-DD>.md (rule: .agent/LOGBOOK.md), and inside the
#   pre-commit hook stages that day file so the entry rides along with the
#   commit. Optional tag overrides: --agent NAME (default: buffy), --task TEXT
#   (default: current commit subject), --event TYPE (default mapped from the
#   task prefix, e.g. feat->implementation, fix->bugfix, docs->documentation,
#   test->testing, refactor->refactoring, chore->maintenance, perf->performance,
#   else implementation). Env equivalents: CI_LOG_AGENT, CI_LOG_TASK,
#   CI_LOG_EVENT.
#
# NOTE: `all` deliberately excludes `e2e`: it boots servers and needs Redis, so
# it stays an explicit, opt-in target rather than part of the fast pre-push
# loop. Run `scripts/ci.sh e2e` to include the browser suite.
#
# Every step degrades gracefully while there is no source/tests/deps yet, so
# the hooks never block an empty repository bootstrap.
set -u

# --- Self-snapshot: run from a stable private copy ---------------------------
# dash reads script files incrementally while executing. If this file is
# rewritten IN PLACE by a parallel process during a long run (e.g. an editor
# or a concurrent agent touching scripts/ci.sh while `all` runs its several-
# minute suites), dash's tail reads see mixed old/new content and die with a
# spurious 'Syntax error: ";;" unexpected' at EOF even though every check
# passed. Re-exec from a snapshot so the running instance always parses the
# exact content it started with, whatever happens to the working-tree file.
if [ "${NE_CI_SNAPSHOT:-}" != "1" ]; then
  NE_CI_SNAPSHOT=1
  NE_CI_SELF="$0"
  SNAPSHOT="$(mktemp "${TMPDIR:-/tmp}/ci.XXXXXX.sh")" || exit 1
  NE_CI_SNAPSHOT_PATH="$SNAPSHOT"
  NE_CI_ROOT="$(cd "$(dirname "$0")/.." && pwd 2>/dev/null || pwd)"
  export NE_CI_SNAPSHOT NE_CI_SELF NE_CI_SNAPSHOT_PATH NE_CI_ROOT
  if ! cat "$0" > "$SNAPSHOT" 2>/dev/null; then
    rm -f "$SNAPSHOT"
    exit 1
  fi
  chmod +x "$SNAPSHOT" 2>/dev/null || true
  exec "$SNAPSHOT" "$@"
fi
trap 'rm -f "${NE_CI_SNAPSHOT_PATH:-}"' EXIT

ROOT="${NE_CI_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
VENV="$ROOT/.venv/bin"
cd "$ROOT" || exit 1

# --- CLI arg parsing (target + optional --log/--agent/--task/--event) -------
target="${1:-all}"
shift 2>/dev/null || true
LOG_COMMENT=""
LOG_AGENT=""
LOG_TASK=""
LOG_EVENT=""
E2E_SPEC=""
while [ "$#" -gt 0 ]; do
  case "$1" in
    --log) LOG_COMMENT="${2:-}"; shift 2 2>/dev/null || break ;;
    --agent) LOG_AGENT="${2:-}"; shift 2 2>/dev/null || break ;;
    --task) LOG_TASK="${2:-}"; shift 2 2>/dev/null || break ;;
    --event) LOG_EVENT="${2:-}"; shift 2 2>/dev/null || break ;;
    *)
      if [ -z "$E2E_SPEC" ]; then
        E2E_SPEC="$1"
      else
        echo "ci: unexpected argument: $1" >&2
        exit 2
      fi
      shift
      ;;
  esac
done
# Env-var fallbacks (the pre-commit/pre-push hooks inherit these from
# `git commit` / `git push`): --log beats CI_LOG_COMMENT, etc.
LOG_COMMENT="${LOG_COMMENT:-${CI_LOG_COMMENT:-}}"
LOG_AGENT="${LOG_AGENT:-${CI_LOG_AGENT:-buffy}}"
LOG_TASK="${LOG_TASK:-${CI_LOG_TASK:-}}"
LOG_EVENT="${LOG_EVENT:-${CI_LOG_EVENT:-}}"

count_py() {
  find src tests scripts -name '*.py' -type f 2>/dev/null | wc -l
}
count_tests() {
  find tests \( -name 'test_*.py' -o -name '*_test.py' \) -type f 2>/dev/null | wc -l
}

logbook_reminder() {
  # Unmissable nudge for AI dev agents: every pre-commit/pre-push run prints
  # the logbook rule, so a session cannot forget to record its trace log in
  # today's day file (rule: .agent/LOGBOOK.md). Printed unconditionally.
  echo ""
  echo "/!\ IMPORTANT RULE: save the trace log in today's logbook file as defined in .agent/LOGBOOK.md"
  echo "    day file: .agent/LOGBOOKS/<YYYY-MM-DD>.md (UTC day)"
  echo "    entry format: [UTC timestamp] [agent] [task] [event type] + what happened / why / next action"
  echo "    lint/all now REQUIRE a commentary: pass --log \"<text>\" or CI_LOG_COMMENT='<text>'"
  echo "    (ci.sh appends it formatted to today's day file and stages it in pre-commit)"
  echo ""
}

# --- Mandatory log commentary (lint = pre-commit, all = pre-push) -----------
# The gate targets refuse to run without a commentary describing what was done
# since the last CI execution, so an AI agent cannot commit/push without
# logging (rule: .agent/LOGBOOK.md). On success the entry is appended to the
# day file in the exact tagged format the cmd_logbook guard validates.

require_ci_log() {
  # Fail hard when a gate target runs without a (non-whitespace) commentary.
  if [ -n "$LOG_COMMENT" ] && \
     [ -n "$(printf '%s' "$LOG_COMMENT" | tr -d '[:space:]' 2>/dev/null)" ]; then
    return 0
  fi
  echo ""
  echo "ci: FAIL - a log commentary about what has been updated is mandatory and expected to validate the CI" >&2
  echo "    describe what was done/updated since the last CI execution and re-run with the commentary, e.g.:" >&2
  echo "      CI_LOG_COMMENT='implemented X, fixed Y' git commit -m '...'   (hooks inherit this env var)" >&2
  echo "      CI_LOG_COMMENT='implemented X, fixed Y' git push" >&2
  echo "      scripts/ci.sh $target --log \"implemented X, fixed Y\"        (direct runs)" >&2
  echo "    ci.sh will store it formatted in .agent/LOGBOOKS/<YYYY-MM-DD>.md (see .agent/LOGBOOK.md)" >&2
  echo "" >&2
  if [ -n "$LOG_TASK" ] || [ -n "$LOG_EVENT" ]; then
    echo "ci: note: --task/--event were given but --log was not; the commentary text is the required part." >&2
  fi
  return 1
}

log_event_from_task() {
  # Map a task/commit-subject prefix to a logbook event type.
  case "$1" in
    feat*|add*) echo "implementation" ;;
    fix*) echo "bugfix" ;;
    docs*) echo "documentation" ;;
    test*) echo "testing" ;;
    refactor*) echo "refactoring" ;;
    chore*) echo "maintenance" ;;
    perf*) echo "performance" ;;
    *) echo "implementation" ;;
  esac
}

current_commit_subject() {
  # Best available task label, in order of preference:
  #   1. an explicit --task/CI_LOG_TASK
  #   2. the first line of the commentary (self-describing; also the only
  #      reliable source inside the pre-commit hook — git does NOT rewrite
  #      .git/COMMIT_EDITMSG with the new message before pre-commit runs, so
  #      it would yield the PREVIOUS commit's subject)
  #   3. the latest commit subject (pre-push, where the commit already exists)
  #   4. a generic label
  if [ -n "$LOG_TASK" ]; then
    echo "$LOG_TASK"
    return 0
  fi
  if [ -n "$LOG_COMMENT" ]; then
    subject="$(printf '%s' "$LOG_COMMENT" | head -n1 | cut -c1-120)"
    [ -n "$subject" ] && { echo "$subject"; return 0; }
  fi
  subject="$(git log -1 --format=%s 2>/dev/null)"
  if [ -n "$subject" ]; then
    echo "$subject"
    return 0
  fi
  echo "ci run"
}

append_logbook_entry() {
  # Persist the mandatory commentary as a properly formatted entry in today's
  # day file. Called ONLY after the gate target fully succeeded. Inside the
  # pre-commit hook the day file is staged so the entry rides the commit.
  [ -n "$LOG_COMMENT" ] || return 0
  [ -d ".agent/LOGBOOKS" ] || return 0
  day="$(date -u +%Y-%m-%d)"
  logfile=".agent/LOGBOOKS/$day.md"
  ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  agent="${LOG_AGENT:-buffy}"
  task="$(current_commit_subject)"
  # Tags must be single-line and free of ']' to satisfy the logbook guard.
  agent="$(printf '%s' "$agent" | tr -d '\r\n]' | cut -c1-64)"
  task="$(printf '%s' "$task" | tr -d '\r\n]' | cut -c1-120)"
  event="${LOG_EVENT:-$(log_event_from_task "$task")}"
  event="$(printf '%s' "$event" | tr -d '\r\n]' | cut -c1-32)"
  header="[$ts] [$agent] [$task] [$event]"
  # Avoid duplicate entries when a re-run carries the same commentary: use the
  # first line of the commentary as a stable signature (multi-line-safe).
  sig="$(printf '%s' "$LOG_COMMENT" | head -n1 | cut -c1-120)"
  if [ -f "$logfile" ] && [ -n "$sig" ] && grep -Fq -- "$sig" "$logfile"; then
    echo "ci: logbook entry already present (identical commentary) - not appending again"
  else
    # Create the day file with its standard header on first entry of the day.
    if [ ! -f "$logfile" ]; then
      printf '%s\n' "<!-- ./.agent/LOGBOOKS/$day.md -->" > "$logfile"
      printf '%s\n' "# Agent Log - $day" >> "$logfile"
      printf '%s\n' "" >> "$logfile"
      printf '%s\n' "Per-day agent log file. Rule and entry format: see \`../LOGBOOK.md\`." >> "$logfile"
      printf '%s\n' "" >> "$logfile"
    fi
    {
      printf '%s\n\n' "---"
      printf '%s\n' "$header"
      # Body lines starting with '[' must be indented: the logbook guard treats
      # any line starting with '[' as a tagged entry header, so a raw '[x]' line
      # in the commentary would fail the next cmd_logbook run.
      printf '%s\n' "$LOG_COMMENT" | tr -d '\r' | while IFS= read -r bline; do
        case "$bline" in
          \[*) printf '  %s\n' "$bline" ;;
          *) printf '%s\n' "$bline" ;;
        esac
      done
      printf '%s\n' ""
    } >> "$logfile"
    echo "ci: logbook entry appended to $logfile ($header)"
  fi
  # Stage inside the pre-commit hook so the entry travels with the commit (also
  # on the dedupe path: a manual run may have already appended the entry).
  if [ "${NE_CI_IS_HOOK:-}" = "pre-commit" ]; then
    git add "$logfile" 2>/dev/null || true
  fi
  return 0
}

cmd_lint() {
  logbook_reminder
  if [ "$(count_py)" -eq 0 ]; then
    echo "ci: no Python source yet, skipping lint"
    return 0
  fi
  "$VENV/ruff" check . || return 1
  "$VENV/ruff" format --check . || return 1
  cmd_docs_links || return 1
  cmd_spec_inventory || return 1
  cmd_logbook || return 1
}

cmd_types() {
  if [ "$(count_py)" -eq 0 ]; then
    echo "ci: no Python source yet, skipping type check"
    return 0
  fi
  "$VENV/pyright" . || return 1
}

cmd_frontend() {
  if [ ! -f "src/frontend/package.json" ]; then
    echo "ci: no frontend yet, skipping frontend checks"
    return 0
  fi
  if [ ! -d "src/frontend/node_modules" ]; then
    echo "ci: frontend dependencies missing — run 'cd src/frontend && npm install' or 'scripts/setup.sh'" >&2
    return 1
  fi
  cmd_spec_inventory || return 1
  (cd src/frontend && npm run typecheck && npm run lint && npm run test && npm run build) || return 1
}

cmd_test() {
  if [ "$(count_tests)" -eq 0 ]; then
    echo "ci: no tests yet, skipping test run"
    return 0
  fi
  "$VENV/pytest" || return 1
}

cmd_spec_inventory() {
  # Every Playwright spec under src/frontend/e2e must have a row in the spec
  # inventory table of docs/04_OPERATIONS/12_Testing_Quality_Gates.md (§4.4) and
  # that row's 'Covers (user-visible acceptance, GATE 6)' cell must be non-empty,
  # so a new *.spec.ts cannot land without its docs row + acceptance note. Runs
  # on `frontend`, `e2e`, `e2e-fast`, `all` and standalone as `spec-inventory`.
  e2e_dir="src/frontend/e2e"
  inventory_doc="docs/04_OPERATIONS/12_Testing_Quality_Gates.md"
  if [ ! -d "$e2e_dir" ] || [ ! -f "$inventory_doc" ]; then
    echo "ci: spec-inventory skipped (no e2e specs or inventory doc yet)"
    return 0
  fi
  missing=""
  empty_covers=""
  for spec in "$e2e_dir"/*.spec.ts; do
    [ -f "$spec" ] || continue
    name="$(basename "$spec")"
    # Inventory rows use a backticked cell: | `queues-cpu-busy.spec.ts` |
    if ! grep -q "| \`$name\` |" "$inventory_doc"; then
      missing="$missing $name"
      continue
    fi
    # Covers is the 6th table column (awk field 7: rows lead with '|').
    covers="$(grep -F "| \`$name\` |" "$inventory_doc" | \
      awk -F'|' '{ gsub(/^[ \t]+|[ \t]+$/, "", $7); print $7 }' | head -1)"
    if [ -z "$covers" ]; then
      empty_covers="$empty_covers $name"
    fi
  done
  if [ -n "$missing" ] || [ -n "$empty_covers" ]; then
    [ -n "$missing" ] && \
      echo "ci: FAIL — E2E spec(s) missing from the inventory table (§4.4 of $inventory_doc):$missing" >&2
    [ -n "$empty_covers" ] && \
      echo "ci: FAIL — E2E spec(s) with an EMPTY 'Covers (user-visible acceptance, GATE 6)' cell:$empty_covers" >&2
    echo "ci: add/complete the row per spec (name → page → stack → fixture → acceptance note) before pushing." >&2
    return 1
  fi
  echo "ci: spec-inventory OK ($(ls "$e2e_dir"/*.spec.ts 2>/dev/null | wc -l) specs documented, covers present)"
  return 0
}

cmd_logbook() {
  # Enforce .agent/LOGBOOK.md: every entry must start with the tagged header
  # line '[UTC timestamp] [agent] [task] [event type]'. Two rules:
  #   1. Any line starting with '[' must be a full 4-tag header with a UTC ISO
  #      timestamp (Z or ±00:00, optional fractional seconds) — a future
  #      session cannot drop the timestamp or tags.
  #   2. Bullets ('- '/'* ') and '##'-level headings are NOT the entry format;
  #      they are flagged while they appear BEFORE the first tagged entry
  #      (a day file written entirely in bullet/heading form, e.g. without any
  #      timestamp at all, fails). Inside the body of a tagged entry (after the
  #      first header) free-form prose is allowed, and thematic breaks
  #      (---/***/___) are fine anywhere, so legacy day files whose tails were
  #      bulk-written before the format was enforced keep passing.
  # Runs standalone as `logbook` and inside `lint` (pre-commit).
  if [ ! -d ".agent/LOGBOOKS" ]; then
    echo "ci: logbook skipped (no .agent/LOGBOOKS directory yet)"
    return 0
  fi
  header_re='^\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-]00:00)\] \[[^]]+\] \[[^]]+\] \[[^]]+\][[:space:]]*$'
  bad=""
  files=0
  entries=0
  for log in .agent/LOGBOOKS/*.md; do
    [ -f "$log" ] || continue
    files=$((files + 1))
    seen_header=0
    lineno=0
    while IFS= read -r line; do
      lineno=$((lineno + 1))
      case "$line" in
        \[*)
          if printf '%s\n' "$line" | grep -Eq "$header_re"; then
            seen_header=1
            entries=$((entries + 1))
          else
            bad="$bad\n  $log:$lineno -> $line (does not match the 4-tag UTC header format)"
          fi
          ;;
        \-\-\-* | \*\*\** | ___*)
          # Thematic break (---, ***, ___): fine anywhere.
          ;;
        *)
          if [ "$seen_header" -eq 0 ]; then
            case "$line" in
              -*) bad="$bad\n  $log:$lineno -> $line (bullet before any tagged entry)" ;;
              \**) bad="$bad\n  $log:$lineno -> $line (bullet before any tagged entry)" ;;
              +*) bad="$bad\n  $log:$lineno -> $line (bullet before any tagged entry)" ;;
              \#\#*) bad="$bad\n  $log:$lineno -> $line (heading before any tagged entry)" ;;
            esac
          fi
          ;;
      esac
    done < "$log"
  done
  if [ -n "$bad" ]; then
    echo "ci: FAIL — logbook entries must start with '[UTC timestamp] [agent] [task] [event type]' (see .agent/LOGBOOK.md):" >&2
    printf '%b\n' "$bad" >&2
    echo "ci: fix the offending line(s) above before pushing." >&2
    return 1
  fi
  echo "ci: logbook OK ($files day file(s), $entries entry header(s) conform)"
  return 0
}

cmd_docs_links() {
  # Every relative link target in docs/*.md must resolve to an existing file.
  # Anchors (#...), http(s), mailto, scheme and protocol-relative targets are
  # skipped. Runs standalone as `docs-links` and inside `lint` (pre-commit).
  if [ ! -d "docs" ]; then
    echo "ci: docs-links skipped (no docs directory yet)"
    return 0
  fi
  broken=""
  checked=0
  for doc in $(find docs -name '*.md' -type f); do
    dir="$(dirname "$doc")"
    for target in $(grep -oE '\]\([^)]*\)' "$doc" 2>/dev/null | sed -n 's/^\](//; s/)$//p'); do
      case "$target" in
        \#* | http://* | https://* | mailto:* | //* | /*) continue ;;
        *://*) continue ;;
      esac
      clean="${target%%#*}" # drop an optional #anchor fragment
      checked=$((checked + 1))
      if [ -n "$clean" ] && [ ! -e "$dir/$clean" ]; then
        broken="$broken\n  $doc -> $target"
      fi
    done
  done
  if [ -n "$broken" ]; then
    echo "ci: FAIL — broken relative links in docs/ ($checked targets checked):" >&2
    printf '%b\n' "$broken" >&2
    echo "ci: fix or re-target the links above before pushing." >&2
    return 1
  fi
  echo "ci: docs-links OK ($checked relative link targets checked)"
  return 0
}

cmd_e2e() {
  if [ ! -f "src/frontend/playwright.config.ts" ]; then
    echo "ci: no playwright config, skipping e2e"
    return 0
  fi
  if [ ! -d "src/frontend/node_modules" ]; then
    echo "ci: frontend dependencies missing — run 'cd src/frontend && npm install' or 'scripts/setup.sh'" >&2
    return 1
  fi
  # The E2E suite boots an isolated backend that needs a local Redis.
  if ! redis-cli ping >/dev/null 2>&1; then
    echo "ci: e2e skipped (Redis is not running)"
    return 0
  fi
  cmd_spec_inventory || return 1
  (cd src/frontend && npx playwright test) || return 1
  # The analysis-failure spec runs against a dedicated failing-mock backend.
  (cd src/frontend && npx playwright test --config=playwright.failure.config.ts) || return 1
}

cmd_e2e_fast() {
  # Quick local iteration: one healthy spec (default: navigation) plus the
  # failure stack. Each boot still takes a few seconds, but only one healthy
  # spec runs instead of the whole suite. Optional second arg overrides the
  # spec (e.g. scripts/ci.sh e2e-fast volumes-compile).
  if [ ! -f "src/frontend/playwright.config.ts" ]; then
    echo "ci: no playwright config, skipping e2e-fast"
    return 0
  fi
  if [ ! -d "src/frontend/node_modules" ]; then
    echo "ci: frontend dependencies missing — run 'cd src/frontend && npm install' or 'scripts/setup.sh'" >&2
    return 1
  fi
  if ! redis-cli ping >/dev/null 2>&1; then
    echo "ci: e2e-fast skipped (Redis is not running)"
    return 0
  fi
  cmd_spec_inventory || return 1
  spec="${1:-navigation}" # plain variable: POSIX sh has no `local`
  echo "ci: e2e-fast — healthy spec '$spec' + failure config"
  (cd src/frontend && npx playwright test "$spec") || return 1
  (cd src/frontend && npx playwright test --config=playwright.failure.config.ts) || return 1
}

case "$target" in
  lint)
    require_ci_log || exit 1
    if cmd_lint; then
      append_logbook_entry
    else
      exit 1
    fi
    ;;
  types) cmd_types ;;
  frontend) cmd_frontend ;;
  test) cmd_test ;;
  e2e) cmd_e2e ;;
  e2e-fast) cmd_e2e_fast "${E2E_SPEC:-navigation}" ;;
  spec-inventory) cmd_spec_inventory ;;
  docs-links) cmd_docs_links ;;
  logbook) cmd_logbook ;;
  all)
    require_ci_log || exit 1
    if cmd_lint && cmd_types && cmd_frontend && cmd_test; then
      append_logbook_entry
    else
      exit 1
    fi
    ;;
  *)
    echo "usage: ${NE_CI_SELF:-$0} {lint|types|frontend|test|e2e|e2e-fast|spec-inventory|docs-links|logbook|all} [--log TEXT] [--agent NAME] [--task TEXT] [--event TYPE]" >&2
    echo "       scripts/ci.sh e2e-fast [spec] — one healthy spec + failure stack" >&2
    echo "       lint/all require --log TEXT or CI_LOG_COMMENT (logbook commentary, see .agent/LOGBOOK.md)" >&2
    exit 2
    ;;
esac

Metadata

Metadata

Assignees

No one assigned

    Labels

    bot:triagedClassified by the community triage bottype:featureA request for new behavior

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions