Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to this project are documented in this file. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
project adheres to [Semantic Versioning](https://semver.org/).

## [1.12.0] - 2026-09-21

### Changed

- The fold prompt now tells the synthesizer what the shared file is FOR: it is
cross-project practice, so a new memory contributes the lesson it teaches and
not the project status it records. Deploy and release state, resume points,
backlog and percentage counts, open/blocked/muted lists, run and workflow ids
and anything else that must be edited when work moves are dropped rather than
folded, a memory that is entirely status contributes nothing, and the
fallback "Promoted from <agent>" section is no longer a home for status the
rules just told the model to drop. Without this the synthesizer did as it was
asked and merged a project's resume point and deploy state into the file every
agent reads, which then had to be cut out by hand.
- A fold whose new memories are all project status now has a way to say so.
The model prints `NOTHING TO FOLD`, the import blocks are dropped and the
curated text is left untouched. Before, an answer with no changed section was
refused as a failure, so every synthesizer that obeyed the new rules was
treated as broken and the raw status stayed in the file to be retried on the
next sync. That answer also skips the identifier check, which it would
otherwise always fail: the emitted document is the context itself, so the
only identifiers it can be missing are the ones the dropped import brought.

## [1.11.0] - 2026-09-05

### Added
Expand Down
50 changes: 45 additions & 5 deletions bin/agent
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ if [ "${AGENT_SYNC_ACTIVE:-0}" = "1" ]; then
exit 0
fi

VERSION="1.11.0"
VERSION="1.12.0"
AGENT_HOME="${AGENT_SYNC_HOME:-$HOME}"
DEFAULT_SRC="$AGENT_HOME/.claude/CLAUDE.md"
SRC="${AGENT_SYNC_SOURCE:-$DEFAULT_SRC}"
Expand Down Expand Up @@ -55,6 +55,11 @@ SYNTH_SCOPE=full
SYNTH_ACCEPTED=0
FOLD_SRC=""
FOLD_NEW=0
# What the model prints when the scope rules leave no section to change.
FOLD_NOOP_TOKEN="NOTHINGTOFOLD"
# Set by fold_assemble when the answer was that token: the emitted document is
# the context itself, so nothing the curated file carried can have been lost.
FOLD_WAS_NOOP=0
FOLD_DONE=0

# Colour is strictly additive: the escapes stay empty unless stdout is a
Expand Down Expand Up @@ -861,11 +866,18 @@ try_synthesizer() {
fi
invoke_synthesizer "$kind" "$prompt" "$out" "$ts_model" || return 1
strip_synth_preamble "$out"
FOLD_WAS_NOOP=0
if [ "$SYNTH_SCOPE" = fold ]; then
fold_assemble "$out" || return 1
fi
valid_synth_output "$out" "$SRC" || return 1
ensure_private_tmp
# A no-op fold emitted the context verbatim, so the only identifiers it can
# be "missing" are the ones the dropped import brought — which is what the
# scope rules asked for. Checking it would refuse every compliant answer.
if [ "$FOLD_WAS_NOOP" -eq 1 ]; then
return 0
fi
identifier_source "$SRC" >"$PRIVATE_TMP/synth-ids"
ts_missing=$(missing_identifiers "$PRIVATE_TMP/synth-ids" "$out")
if [ -n "$ts_missing" ]; then
Expand Down Expand Up @@ -1080,12 +1092,28 @@ EOF
as it should now read: everything already there that the rules keep, with
the new facts folded in where they belong. Do not print a section that does
not change, and do not print the file's title or anything else.
- A new memory that fits no existing section goes into the section whose
heading starts with "$FOLD_HEADING <agent>" if the file has one (print that
whole section), otherwise into a new section headed exactly
"$FOLD_HEADING <agent>".
- A new memory that SURVIVES the practice test below and fits no existing
section goes into the section whose heading starts with
"$FOLD_HEADING <agent>" if the file has one (print that whole section),
otherwise into a new section headed exactly "$FOLD_HEADING <agent>". Never
use that section as a home for status a rule below tells you to drop.
- Resolve a conflict in favor of the newest information. Do not print the new
memories as a block; do not print their file paths or YAML frontmatter.
- This file is CROSS-PROJECT PRACTICE: how to work on this machine, plus hard
technical facts that stay true. Fold in the PRACTICE a new memory teaches,
never the project status it records. A fact belongs only if it would still
be true and useful in six months with nobody updating it.
- So drop, and print no section for: what is deployed, released or merged;
where work stopped; resume points and "nothing in flight" notes; backlog,
percentage and open/closed counts; open, blocked, muted, parked or
"do not re-raise" item lists; run, task and workflow ids; and anything that
has to be edited when work moves on. A memory that is entirely status
contributes nothing to this file -- fold none of it. If that leaves no
section to change at all, print exactly "NOTHING TO FOLD" and nothing else.
- Never open a section named after a project, and never add project status to
a section that exists. Where a memory teaches a durable lesson through a
project's specifics, keep the lesson and the technical identifiers it needs,
and leave the project's progress out.
EOF
synth_dropped_note "${2:-}"
echo
Expand Down Expand Up @@ -1130,6 +1158,18 @@ fold_assemble() {
END { if (n > 0) close(file) }
' "$fa_out"
[ -f "$fa_dir/headings" ] || : >"$fa_dir/headings"
# Every memory in the import was status the scope rules tell the model to
# drop, so no section changes. That is a correct answer, not a failed one:
# accept it and let fold_emit write the file back without the import blocks.
if [ "$(tr -d '[:space:]' <"$fa_out")" = "$FOLD_NOOP_TOKEN" ]; then
FOLD_WAS_NOOP=1
: >"$fa_dir/headings"
: >"$fa_dir/stray"
fold_emit "$fa_dir" "$fa_out.doc" || return 1
mv "$fa_out.doc" "$fa_out"
echo "${C_DIM}fold: every new memory was project status; nothing folded${C_OFF}" >&2
return 0
fi
if [ -s "$fa_dir/stray" ]; then
echo "${C_ERR}agent:${C_OFF} the fold answered with text before its first section; refusing it" >&2
return 1
Expand Down
36 changes: 36 additions & 0 deletions tests/fold_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,40 @@ assert_contains "$TEST_ROOT/dry.out" 'dry-run: would synthesize'
cmp -s "$CANON" "$TEST_ROOT/before-dry.md" || fail 'a dry run changed the file'
[ "$(claude_calls)" -eq 0 ] || fail 'a dry run called the model'

# The fold prompt carries the practice-not-status scope rules. The mock cannot
# test that a model obeys them, so what is pinned is that they are actually
# sent: without them the model folds project status into a cross-project file,
# which is what put a project's resume point and deploy state into the canon.
printf 'fold\n' >"$FOLD_MODE"
printf 'alpha scope probe\n' >"$CLAUDE_A"
run_fold sync >/dev/null 2>&1
assert_contains "$FOLD_PROMPT" 'CROSS-PROJECT PRACTICE'
assert_contains "$FOLD_PROMPT" 'never the project status it records'
assert_contains "$FOLD_PROMPT" 'true and useful in six months'
assert_contains "$FOLD_PROMPT" 'Never open a section named after a project'
assert_contains "$FOLD_PROMPT" 'SURVIVES the practice test'
assert_contains "$FOLD_PROMPT" 'NOTHING TO FOLD'

# An import that is entirely status legitimately changes no section. The model
# says so, and the sync accepts it: the import block is dropped, the curated
# text is untouched, and the run does not fall through to the next synthesizer.
cat >"$MOCK_BIN/claude" <<'NOOPMOCK'
#!/bin/sh
printf 'claude %s\n' "$*" >>"$SYNTH_LOG"
cat >/dev/null
printf 'NOTHING TO FOLD\n'
NOOPMOCK
chmod +x "$MOCK_BIN/claude"
# The status carries a long alphanumeric id, the shape stable mode protects.
# Without the no-op exemption the guard reports it missing and the run falls
# down the ladder, so this token is what makes the assertion below real.
printf 'status only: run wf92024077a93b deployed on Tuesday, nothing in flight\n' >"$CLAUDE_A"
cp "$CANON" "$TEST_ROOT/before-noop.md"
: >"$SYNTH_LOG"
run_fold sync >"$TEST_ROOT/noop.out" 2>&1
assert_not_contains "$CANON" 'agent-sync:begin imported'
assert_not_contains "$CANON" 'nothing in flight'
grep -q 'v9.9.9' "$CANON" && fail 'a status-only import was folded into the file'
[ "$(claude_calls)" -eq 1 ] || fail 'a no-op fold should be accepted, not retried down the rungs'

echo 'fold tests passed'
Loading