From 46040295b813d0e91007be9e8df4544e0c8d417e Mon Sep 17 00:00:00 2001 From: Alexvy Date: Sun, 26 Jul 2026 00:28:22 -0600 Subject: [PATCH] fix(hooks): correct save-nudge timezone parsing and first-observation gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_epoch() in the claude-code and codex user-prompt-submit hooks parsed sqlite's naive UTC timestamps (`started_at`, observation `created_at`) as local time. On any host not in UTC+0, this skews the computed epoch by the host's offset, so the >5min session-age gate and the >15min stale-save threshold compare against a wrong reference point — negative offsets (e.g. UTC-6) push the parsed time into the future and the nudge never fires; positive offsets (e.g. UTC+1/+2) push it into the past and the nudge fires too eagerly. Force -u on every naive-timestamp parse path so it's always read as UTC, matching how it was written. Separately, all three hook implementations (claude-code, codex, opencode) treated "no observations yet for this project" as "session just started" and skipped the nudge unconditionally. For a brand-new project this means the nudge that is supposed to prompt the very first save can never fire, since it only fires once at least one observation already exists. Treat "never saved" as maximally stale instead, so first-time projects can still get nudged after the existing 5-minute session-age gate has passed. Verified by exercising the hook against a live engram server with fixture sessions/observations for both directions of the timezone offset (UTC-6 and UTC+1/+2) and for the zero-observation case; go/node toolchains were not available in this environment to run the existing test suite. --- .../claude-code/scripts/user-prompt-submit.sh | 39 ++++++++++++------- plugin/codex/scripts/user-prompt-submit.sh | 39 ++++++++++++------- plugin/opencode/engram.ts | 13 ++++--- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/plugin/claude-code/scripts/user-prompt-submit.sh b/plugin/claude-code/scripts/user-prompt-submit.sh index a6e31396..333c2d81 100755 --- a/plugin/claude-code/scripts/user-prompt-submit.sh +++ b/plugin/claude-code/scripts/user-prompt-submit.sh @@ -148,9 +148,14 @@ parse_epoch() { date -j -u -f "%Y-%m-%dT%H:%M:%S" "$Z_TS" "+%s" 2>/dev/null && return 0 fi - date -j -f "%Y-%m-%dT%H:%M:%S" "$TS" "+%s" 2>/dev/null \ - || date -j -f "%Y-%m-%d %H:%M:%S" "$TS" "+%s" 2>/dev/null \ - || date -d "$TS" "+%s" 2>/dev/null + # No timezone marker at all — these are naive timestamps from sqlite's + # datetime('now'), which is always UTC. Must parse with -u; parsing as + # local time skews the result by the host's UTC offset (e.g. on a UTC-6 + # host, session age comes out ~6h in the future, so it's always seen as + # "just started" and the save-nudge can never fire). + date -j -u -f "%Y-%m-%dT%H:%M:%S" "$TS" "+%s" 2>/dev/null \ + || date -j -u -f "%Y-%m-%d %H:%M:%S" "$TS" "+%s" 2>/dev/null \ + || date -u -d "$TS" "+%s" 2>/dev/null } # Default: no injection @@ -238,20 +243,24 @@ fi LAST_SAVE_AT=$(echo "$LAST_SAVE_JSON" | jq -r '.[0].created_at // empty' 2>/dev/null) -if [ -z "$LAST_SAVE_AT" ]; then - # No observations yet — no nudge (session might just be starting) - echo "$OUTPUT" - exit 0 -fi +NOW_EPOCH=$(date "+%s") -# Parse last save timestamp and compare to now -LAST_EPOCH=$(parse_epoch "$LAST_SAVE_AT") -if [ -z "$LAST_EPOCH" ]; then - echo "$OUTPUT" - exit 0 +if [ -z "$LAST_SAVE_AT" ]; then + # No observations exist yet for this project. This is the "never saved" + # case, not "session just started" (session age was already gated to + # >= 5 minutes above) — treat it as maximally stale so the nudge can fire + # and break the cycle where a project with zero observations can never + # reach its first one. + ELAPSED=901 +else + # Parse last save timestamp and compare to now + LAST_EPOCH=$(parse_epoch "$LAST_SAVE_AT") + if [ -z "$LAST_EPOCH" ]; then + echo "$OUTPUT" + exit 0 + fi + ELAPSED=$(( NOW_EPOCH - LAST_EPOCH )) fi -NOW_EPOCH=$(date "+%s") -ELAPSED=$(( NOW_EPOCH - LAST_EPOCH )) # Nudge if last save was > 15 minutes ago (900 seconds), but debounce so we do # not repeat the reminder on every message while the agent has nothing to save. diff --git a/plugin/codex/scripts/user-prompt-submit.sh b/plugin/codex/scripts/user-prompt-submit.sh index fd7c3c95..ab7efc3c 100755 --- a/plugin/codex/scripts/user-prompt-submit.sh +++ b/plugin/codex/scripts/user-prompt-submit.sh @@ -79,9 +79,14 @@ parse_epoch() { date -j -u -f "%Y-%m-%dT%H:%M:%S" "$Z_TS" "+%s" 2>/dev/null && return 0 fi - date -j -f "%Y-%m-%dT%H:%M:%S" "$TS" "+%s" 2>/dev/null \ - || date -j -f "%Y-%m-%d %H:%M:%S" "$TS" "+%s" 2>/dev/null \ - || date -d "$TS" "+%s" 2>/dev/null + # No timezone marker at all — these are naive timestamps from sqlite's + # datetime('now'), which is always UTC. Must parse with -u; parsing as + # local time skews the result by the host's UTC offset (e.g. on a UTC-6 + # host, session age comes out ~6h in the future, so it's always seen as + # "just started" and the save-nudge can never fire). + date -j -u -f "%Y-%m-%dT%H:%M:%S" "$TS" "+%s" 2>/dev/null \ + || date -j -u -f "%Y-%m-%d %H:%M:%S" "$TS" "+%s" 2>/dev/null \ + || date -u -d "$TS" "+%s" 2>/dev/null } # Default: no injection @@ -169,20 +174,24 @@ fi LAST_SAVE_AT=$(echo "$LAST_SAVE_JSON" | jq -r '.[0].created_at // empty' 2>/dev/null) -if [ -z "$LAST_SAVE_AT" ]; then - # No observations yet — no nudge (session might just be starting) - echo "$OUTPUT" - exit 0 -fi +NOW_EPOCH=$(date "+%s") -# Parse last save timestamp and compare to now -LAST_EPOCH=$(parse_epoch "$LAST_SAVE_AT") -if [ -z "$LAST_EPOCH" ]; then - echo "$OUTPUT" - exit 0 +if [ -z "$LAST_SAVE_AT" ]; then + # No observations exist yet for this project. This is the "never saved" + # case, not "session just started" (session age was already gated to + # >= 5 minutes above) — treat it as maximally stale so the nudge can fire + # and break the cycle where a project with zero observations can never + # reach its first one. + ELAPSED=901 +else + # Parse last save timestamp and compare to now + LAST_EPOCH=$(parse_epoch "$LAST_SAVE_AT") + if [ -z "$LAST_EPOCH" ]; then + echo "$OUTPUT" + exit 0 + fi + ELAPSED=$(( NOW_EPOCH - LAST_EPOCH )) fi -NOW_EPOCH=$(date "+%s") -ELAPSED=$(( NOW_EPOCH - LAST_EPOCH )) # Nudge if last save was > 15 minutes ago (900 seconds), but debounce so we do # not repeat the reminder on every message while the agent has nothing to save. diff --git a/plugin/opencode/engram.ts b/plugin/opencode/engram.ts index c5567087..a6f19ef8 100644 --- a/plugin/opencode/engram.ts +++ b/plugin/opencode/engram.ts @@ -478,11 +478,14 @@ export const Engram: Plugin = async (ctx) => { return } - // No observations yet — nothing to nudge about - if (lastObsEpoch === 0) return - - // Only nudge if last save was more than 15 minutes ago - if (nowSecs - lastObsEpoch < 900) return + // No observations exist yet for this project. This is the "never + // saved" case, not "session just started" (session age was already + // gated to >= 5 minutes above) — treat it as maximally stale so the + // nudge can fire and break the cycle where a project with zero + // observations can never reach its first one. + // + // Only nudge if last save was more than 15 minutes ago (or never). + if (lastObsEpoch !== 0 && nowSecs - lastObsEpoch < 900) return // Append the nudge to the last system message const nudge =