@event4u/agent-config 1.9.1 → 1.12.0

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.
Files changed (77) hide show
  1. package/.agent-src/commands/agent-handoff.md +15 -0
  2. package/.agent-src/commands/chat-history-clear.md +98 -0
  3. package/.agent-src/commands/chat-history-resume.md +178 -0
  4. package/.agent-src/commands/chat-history.md +102 -0
  5. package/.agent-src/commands/compress.md +9 -9
  6. package/.agent-src/commands/copilot-agents-init.md +1 -1
  7. package/.agent-src/commands/fix-portability.md +2 -2
  8. package/.agent-src/commands/fix-pr-bot-comments.md +1 -1
  9. package/.agent-src/commands/fix-pr-developer-comments.md +1 -1
  10. package/.agent-src/commands/fix-references.md +2 -2
  11. package/.agent-src/commands/mode.md +5 -5
  12. package/.agent-src/commands/onboard.md +171 -0
  13. package/.agent-src/commands/roadmap-create.md +7 -2
  14. package/.agent-src/commands/roadmap-execute.md +2 -2
  15. package/.agent-src/commands/set-cost-profile.md +101 -0
  16. package/.agent-src/commands/sync-agent-settings.md +122 -0
  17. package/.agent-src/commands/sync-gitignore.md +104 -0
  18. package/.agent-src/commands/tests-execute.md +6 -6
  19. package/.agent-src/commands/upstream-contribute.md +5 -4
  20. package/.agent-src/contexts/augment-infrastructure.md +2 -2
  21. package/.agent-src/contexts/override-system.md +1 -1
  22. package/.agent-src/contexts/subagent-configuration.md +3 -3
  23. package/.agent-src/guidelines/agent-infra/layered-settings.md +48 -5
  24. package/.agent-src/rules/ask-when-uncertain.md +56 -3
  25. package/.agent-src/rules/augment-portability.md +52 -1
  26. package/.agent-src/rules/augment-source-of-truth.md +10 -10
  27. package/.agent-src/rules/chat-history.md +171 -0
  28. package/.agent-src/rules/docker-commands.md +5 -7
  29. package/.agent-src/rules/docs-sync.md +13 -9
  30. package/.agent-src/rules/improve-before-implement.md +2 -0
  31. package/.agent-src/rules/onboarding-gate.md +94 -0
  32. package/.agent-src/rules/package-ci-checks.md +6 -5
  33. package/.agent-src/rules/roadmap-progress-sync.md +24 -13
  34. package/.agent-src/rules/size-enforcement.md +1 -1
  35. package/.agent-src/rules/skill-quality.md +1 -1
  36. package/.agent-src/rules/think-before-action.md +1 -0
  37. package/.agent-src/scripts/update_roadmap_progress.py +26 -9
  38. package/.agent-src/skills/check-refs/SKILL.md +1 -1
  39. package/.agent-src/skills/command-routing/SKILL.md +1 -1
  40. package/.agent-src/skills/command-writing/SKILL.md +4 -3
  41. package/.agent-src/skills/file-editor/SKILL.md +2 -2
  42. package/.agent-src/skills/guideline-writing/SKILL.md +4 -3
  43. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +2 -2
  44. package/.agent-src/skills/lint-skills/SKILL.md +1 -1
  45. package/.agent-src/skills/roadmap-management/SKILL.md +13 -10
  46. package/.agent-src/skills/rtk-output-filtering/SKILL.md +20 -30
  47. package/.agent-src/skills/rule-writing/SKILL.md +5 -5
  48. package/.agent-src/skills/terragrunt/SKILL.md +0 -8
  49. package/.agent-src/skills/upstream-contribute/SKILL.md +5 -4
  50. package/.agent-src/templates/agent-settings.md +86 -34
  51. package/.claude-plugin/marketplace.json +1 -1
  52. package/AGENTS.md +2 -2
  53. package/CHANGELOG.md +296 -0
  54. package/CONTRIBUTING.md +89 -40
  55. package/README.md +3 -3
  56. package/composer.json +2 -1
  57. package/config/agent-settings.template.yml +45 -6
  58. package/config/gitignore-block.txt +24 -0
  59. package/config/profiles/balanced.ini +5 -0
  60. package/config/profiles/full.ini +5 -0
  61. package/config/profiles/minimal.ini +5 -0
  62. package/docs/customization.md +30 -4
  63. package/docs/getting-started.md +52 -3
  64. package/docs/mcp.md +15 -4
  65. package/package.json +13 -2
  66. package/scripts/agent-config +155 -0
  67. package/scripts/chat_history.py +519 -0
  68. package/scripts/check_portability.py +151 -1
  69. package/scripts/install.py +55 -3
  70. package/scripts/install.sh +50 -21
  71. package/scripts/mcp_render.py +30 -16
  72. package/scripts/release.py +588 -0
  73. package/scripts/sync_agent_settings.py +211 -0
  74. package/scripts/sync_gitignore.py +226 -0
  75. package/templates/agent-config-wrapper.sh +47 -0
  76. package/.agent-src/commands/config-agent-settings.md +0 -126
  77. package/.agent-src/skills/eloquent/evals/last-run.json +0 -99
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env bash
2
+ # agent-config — consumer-facing CLI for the event4u/agent-config package.
3
+ #
4
+ # This is the MASTER entrypoint shipped inside the package
5
+ # (node_modules/@event4u/agent-config/scripts/agent-config or
6
+ # vendor/event4u/agent-config/scripts/agent-config). A thin wrapper at
7
+ # the consumer's repo root (`./agent-config`) delegates here.
8
+ #
9
+ # Commands are strictly consumer-facing. Maintainer workflows stay in
10
+ # Taskfile.yml and are NOT exposed here.
11
+ #
12
+ # Invariants:
13
+ # * CWD on entry is the consumer's repo root — we keep it that way
14
+ # so underlying scripts resolve paths correctly.
15
+ # * PACKAGE_ROOT is derived from this script's location, used only to
16
+ # locate the package-internal Python scripts (mcp_render.py, …).
17
+ # * Unknown arguments are forwarded verbatim to the underlying script.
18
+
19
+ set -euo pipefail
20
+
21
+ # Resolve symlinks in BASH_SOURCE so PACKAGE_ROOT points at the real
22
+ # package directory even when invoked via a symlink (global npm install,
23
+ # vendor/bin symlink, user-placed symlink on PATH, …).
24
+ SOURCE="${BASH_SOURCE[0]}"
25
+ while [ -L "$SOURCE" ]; do
26
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
27
+ SOURCE="$(readlink "$SOURCE")"
28
+ [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
29
+ done
30
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
31
+ PACKAGE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
32
+ CONSUMER_ROOT="$(pwd)"
33
+
34
+ VERSION_FILE="$PACKAGE_ROOT/package.json"
35
+
36
+ usage() {
37
+ cat <<'EOF'
38
+ agent-config — event4u/agent-config CLI
39
+
40
+ Usage:
41
+ ./agent-config <command> [options]
42
+
43
+ Commands:
44
+ mcp:render Render mcp.json → .cursor/mcp.json, .windsurf/mcp.json
45
+ (pass --claude-desktop to also write user-scope config)
46
+ mcp:check Dry-run mcp:render; exit non-zero if targets are stale
47
+ roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
48
+ roadmap:progress-check Fail if agents/roadmaps-progress.md is stale (for CI)
49
+ first-run Guided first-run setup — cost profile, settings, tooling
50
+ help Show this help
51
+ --version, -V Print package version
52
+
53
+ Examples:
54
+ ./agent-config mcp:render
55
+ ./agent-config mcp:render --claude-desktop
56
+ ./agent-config mcp:check
57
+ ./agent-config roadmap:progress
58
+ ./agent-config first-run
59
+
60
+ All commands operate on the CURRENT DIRECTORY (your project root).
61
+ The CLI is strictly consumer-facing. Maintainer tasks live in Taskfile.yml.
62
+ EOF
63
+ }
64
+
65
+ print_version() {
66
+ if [[ -f "$VERSION_FILE" ]] && command -v python3 >/dev/null 2>&1; then
67
+ python3 -c "import json; print(json.load(open('$VERSION_FILE'))['version'])"
68
+ else
69
+ echo "unknown"
70
+ fi
71
+ }
72
+
73
+ require_python3() {
74
+ if ! command -v python3 >/dev/null 2>&1; then
75
+ echo "❌ agent-config: python3 not found on PATH" >&2
76
+ echo " Install Python 3.10+ and retry." >&2
77
+ exit 127
78
+ fi
79
+ }
80
+
81
+ # Locate a script. First argument is relative to PACKAGE_ROOT, second is
82
+ # an optional fallback relative to CONSUMER_ROOT (for scripts that ship
83
+ # to the consumer via .augment/, e.g. update_roadmap_progress.py).
84
+ resolve_script() {
85
+ local pkg_rel="$1"
86
+ local consumer_rel="${2-}"
87
+ local pkg_abs="$PACKAGE_ROOT/$pkg_rel"
88
+ if [[ -f "$pkg_abs" ]]; then
89
+ printf '%s' "$pkg_abs"
90
+ return 0
91
+ fi
92
+ if [[ -n "$consumer_rel" && -f "$CONSUMER_ROOT/$consumer_rel" ]]; then
93
+ printf '%s' "$CONSUMER_ROOT/$consumer_rel"
94
+ return 0
95
+ fi
96
+ echo "❌ agent-config: script not found: $pkg_rel" >&2
97
+ [[ -n "$consumer_rel" ]] && echo " (also tried: $consumer_rel in $CONSUMER_ROOT)" >&2
98
+ return 1
99
+ }
100
+
101
+ cmd_mcp_render() {
102
+ require_python3
103
+ local script
104
+ script="$(resolve_script "scripts/mcp_render.py")"
105
+ exec python3 "$script" "$@"
106
+ }
107
+
108
+ cmd_mcp_check() {
109
+ require_python3
110
+ local script
111
+ script="$(resolve_script "scripts/mcp_render.py")"
112
+ exec python3 "$script" --check "$@"
113
+ }
114
+
115
+ cmd_roadmap_progress() {
116
+ require_python3
117
+ local script
118
+ script="$(resolve_script ".agent-src/scripts/update_roadmap_progress.py" ".augment/scripts/update_roadmap_progress.py")"
119
+ exec python3 "$script" "$@"
120
+ }
121
+
122
+ cmd_roadmap_progress_check() {
123
+ require_python3
124
+ local script
125
+ script="$(resolve_script ".agent-src/scripts/update_roadmap_progress.py" ".augment/scripts/update_roadmap_progress.py")"
126
+ exec python3 "$script" --check "$@"
127
+ }
128
+
129
+ cmd_first_run() {
130
+ local script
131
+ script="$(resolve_script "scripts/first-run.sh")"
132
+ exec bash "$script" "$@"
133
+ }
134
+
135
+ main() {
136
+ local cmd="${1-}"
137
+ [[ $# -gt 0 ]] && shift || true
138
+
139
+ case "$cmd" in
140
+ mcp:render) cmd_mcp_render "$@" ;;
141
+ mcp:check) cmd_mcp_check "$@" ;;
142
+ roadmap:progress) cmd_roadmap_progress "$@" ;;
143
+ roadmap:progress-check) cmd_roadmap_progress_check "$@" ;;
144
+ first-run) cmd_first_run "$@" ;;
145
+ help|--help|-h|"") usage ;;
146
+ --version|-V) print_version ;;
147
+ *)
148
+ echo "❌ agent-config: unknown command: $cmd" >&2
149
+ echo " Run \`./agent-config help\` for the command list." >&2
150
+ exit 2
151
+ ;;
152
+ esac
153
+ }
154
+
155
+ main "$@"
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env python3
2
+ """Persistent chat-history log for crash recovery.
3
+
4
+ Maintains `.agent-chat-history` in the project root — a JSONL file whose
5
+ first line is a header (session id, fingerprint, frequency mode) and
6
+ whose remaining lines are append-only entries (user messages, phases,
7
+ tool calls, questions, answers, decisions, commits).
8
+
9
+ Ownership is established via SHA-256 of the first user message in the
10
+ conversation, stored in the header. Agents read this on every turn to
11
+ detect whether the file belongs to the current conversation.
12
+
13
+ File path defaults to `.agent-chat-history` in CWD and can be overridden
14
+ via `$AGENT_CHAT_HISTORY_FILE` (used by tests).
15
+
16
+ Usage:
17
+ python3 scripts/chat_history.py init --first-user-msg "..." [--freq per_phase]
18
+ python3 scripts/chat_history.py append --type phase --json '{...}'
19
+ python3 scripts/chat_history.py status
20
+ python3 scripts/chat_history.py check --first-user-msg "..."
21
+ python3 scripts/chat_history.py state --first-user-msg "..."
22
+ python3 scripts/chat_history.py adopt --first-user-msg "..."
23
+ python3 scripts/chat_history.py reset --first-user-msg "..." --entries-json '[...]' [--freq per_phase]
24
+ python3 scripts/chat_history.py prepend --entries-json '[...]'
25
+ python3 scripts/chat_history.py read [--last N | --all]
26
+ python3 scripts/chat_history.py clear
27
+ python3 scripts/chat_history.py rotate --max-kb 256 --mode rotate
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import datetime as dt
34
+ import hashlib
35
+ import json
36
+ import os
37
+ import re
38
+ import sys
39
+ import uuid
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ DEFAULT_FILE = ".agent-chat-history"
44
+ SCHEMA_VERSION = 2
45
+ FORMER_FPS_CAP = 10
46
+ VALID_FREQS = {"per_turn", "per_phase", "per_tool"}
47
+ VALID_OVERFLOW = {"rotate", "compress"}
48
+ _WS_RE = re.compile(r"\s+")
49
+
50
+
51
+ def file_path() -> Path:
52
+ return Path(os.environ.get("AGENT_CHAT_HISTORY_FILE") or DEFAULT_FILE)
53
+
54
+
55
+ def _now() -> str:
56
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
57
+
58
+
59
+ def fingerprint(first_user_msg: str) -> str:
60
+ """SHA-256 of the normalized first user message (whitespace collapsed)."""
61
+ normalized = _WS_RE.sub(" ", first_user_msg or "").strip()
62
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
63
+
64
+
65
+ def _preview(msg: str, n: int = 80) -> str:
66
+ flat = _WS_RE.sub(" ", msg or "").strip()
67
+ return flat[:n]
68
+
69
+
70
+ def read_header(path: Path | None = None) -> dict[str, Any] | None:
71
+ """Read the header. Migrates v1 headers in memory (adds `former_fps: []`).
72
+
73
+ The on-disk file is not rewritten by this read; migration is lazy and
74
+ happens on the next write (init/adopt/reset).
75
+ """
76
+ p = path or file_path()
77
+ if not p.is_file() or p.stat().st_size == 0:
78
+ return None
79
+ try:
80
+ with p.open(encoding="utf-8") as fh:
81
+ first = fh.readline().strip()
82
+ if not first:
83
+ return None
84
+ obj = json.loads(first)
85
+ if not (isinstance(obj, dict) and obj.get("t") == "header"):
86
+ return None
87
+ obj.setdefault("former_fps", [])
88
+ if not isinstance(obj["former_fps"], list):
89
+ obj["former_fps"] = []
90
+ return obj
91
+ except (json.JSONDecodeError, OSError):
92
+ return None
93
+
94
+
95
+ def _build_header(first_user_msg: str, freq: str,
96
+ former_fps: list[str] | None = None) -> dict[str, Any]:
97
+ return {
98
+ "t": "header",
99
+ "v": SCHEMA_VERSION,
100
+ "session": str(uuid.uuid4()),
101
+ "started": _now(),
102
+ "fp": fingerprint(first_user_msg),
103
+ "preview": _preview(first_user_msg),
104
+ "freq": freq,
105
+ "former_fps": list(former_fps or []),
106
+ }
107
+
108
+
109
+ def init(first_user_msg: str, freq: str = "per_phase", *,
110
+ path: Path | None = None) -> dict[str, Any]:
111
+ """Overwrite the file with a fresh header for a new session."""
112
+ if freq not in VALID_FREQS:
113
+ raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
114
+ p = path or file_path()
115
+ header = _build_header(first_user_msg, freq)
116
+ p.parent.mkdir(parents=True, exist_ok=True)
117
+ with p.open("w", encoding="utf-8") as fh:
118
+ fh.write(json.dumps(header, ensure_ascii=False) + "\n")
119
+ return header
120
+
121
+
122
+ def append(entry: dict[str, Any], *, path: Path | None = None) -> None:
123
+ """Append one entry. Entry must be a dict; `ts` is auto-filled."""
124
+ if not isinstance(entry, dict) or not entry.get("t"):
125
+ raise ValueError("entry must be a dict with non-empty 't' key")
126
+ if entry["t"] == "header":
127
+ raise ValueError("use init() to write the header, not append()")
128
+ entry.setdefault("ts", _now())
129
+ p = path or file_path()
130
+ with p.open("a", encoding="utf-8") as fh:
131
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
132
+
133
+
134
+ def check_ownership(first_user_msg: str, *,
135
+ path: Path | None = None) -> str:
136
+ """Return 'match', 'mismatch', or 'missing' (legacy 3-state).
137
+
138
+ Kept for backward compatibility. Prefer `ownership_state()` for the
139
+ 4-state view that distinguishes foreign from returning sessions.
140
+ """
141
+ header = read_header(path)
142
+ if not header:
143
+ return "missing"
144
+ return "match" if header.get("fp") == fingerprint(first_user_msg) else "mismatch"
145
+
146
+
147
+ def ownership_state(first_user_msg: str, *,
148
+ path: Path | None = None) -> str:
149
+ """Return 'match', 'returning', 'foreign', or 'missing'.
150
+
151
+ `match` — current fp equals header.fp (silent append)
152
+ `returning` — current fp appears in header.former_fps (this chat once
153
+ owned the file; another session took it over since)
154
+ `foreign` — current fp is neither match nor former (new chat finds
155
+ an existing file from an unknown session)
156
+ `missing` — no file or no valid header
157
+ """
158
+ header = read_header(path)
159
+ if not header:
160
+ return "missing"
161
+ fp = fingerprint(first_user_msg)
162
+ if header.get("fp") == fp:
163
+ return "match"
164
+ former = header.get("former_fps") or []
165
+ return "returning" if fp in former else "foreign"
166
+
167
+
168
+ def _push_former_fp(former_fps: list[str], old_fp: str,
169
+ new_fp: str) -> list[str]:
170
+ """Move old_fp into former_fps with dedup + cap. Never include new_fp."""
171
+ seen: list[str] = []
172
+ for fp in [old_fp, *former_fps]:
173
+ if fp and fp != new_fp and fp not in seen:
174
+ seen.append(fp)
175
+ return seen[:FORMER_FPS_CAP]
176
+
177
+
178
+ def adopt(first_user_msg: str, *, path: Path | None = None) -> dict[str, Any]:
179
+ """Rewrite the header's fingerprint to the current conversation's.
180
+
181
+ Preserves all body entries. Pushes the previous `fp` onto
182
+ `former_fps` (dedup, capped at FORMER_FPS_CAP) so this former owner
183
+ can later be detected as 'returning' if the original chat comes back.
184
+ """
185
+ p = path or file_path()
186
+ header = read_header(p)
187
+ if not header:
188
+ raise FileNotFoundError(f"no header in {p}")
189
+ old_fp = header.get("fp", "")
190
+ new_fp = fingerprint(first_user_msg)
191
+ header["v"] = SCHEMA_VERSION
192
+ header["fp"] = new_fp
193
+ header["preview"] = _preview(first_user_msg)
194
+ header["adopted_at"] = _now()
195
+ header["former_fps"] = _push_former_fp(
196
+ header.get("former_fps") or [], old_fp, new_fp,
197
+ )
198
+ with p.open(encoding="utf-8") as fh:
199
+ lines = fh.readlines()
200
+ lines[0] = json.dumps(header, ensure_ascii=False) + "\n"
201
+ tmp = p.with_suffix(p.suffix + ".tmp")
202
+ tmp.write_text("".join(lines), encoding="utf-8")
203
+ tmp.replace(p)
204
+ return header
205
+
206
+
207
+ def _normalize_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
208
+ """Validate + fill `ts` on each entry. Reject headers and empty `t`."""
209
+ out: list[dict[str, Any]] = []
210
+ for raw in entries or []:
211
+ if not isinstance(raw, dict) or not raw.get("t"):
212
+ raise ValueError("each entry must be a dict with non-empty 't' key")
213
+ if raw["t"] == "header":
214
+ raise ValueError("entries must not contain headers")
215
+ e = dict(raw)
216
+ e.setdefault("ts", _now())
217
+ out.append(e)
218
+ return out
219
+
220
+
221
+ def reset_with_entries(first_user_msg: str,
222
+ entries: list[dict[str, Any]],
223
+ freq: str = "per_phase", *,
224
+ former_fps: list[str] | None = None,
225
+ path: Path | None = None) -> dict[str, Any]:
226
+ """Discard current file contents and rewrite with a fresh header + entries.
227
+
228
+ Used for the 'Replace' flow: the in-memory history supersedes whatever
229
+ is on disk. If `former_fps` is None and a header exists, the old fp is
230
+ preserved via `_push_former_fp` so the returning/foreign state logic
231
+ still works on later switches.
232
+ """
233
+ if freq not in VALID_FREQS:
234
+ raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
235
+ p = path or file_path()
236
+ new_fp = fingerprint(first_user_msg)
237
+ if former_fps is None:
238
+ existing = read_header(p)
239
+ if existing:
240
+ former_fps = _push_former_fp(
241
+ existing.get("former_fps") or [],
242
+ existing.get("fp", ""),
243
+ new_fp,
244
+ )
245
+ else:
246
+ former_fps = []
247
+ header = _build_header(first_user_msg, freq, former_fps=former_fps)
248
+ body = _normalize_entries(entries)
249
+ p.parent.mkdir(parents=True, exist_ok=True)
250
+ lines = [json.dumps(header, ensure_ascii=False)]
251
+ lines += [json.dumps(e, ensure_ascii=False) for e in body]
252
+ tmp = p.with_suffix(p.suffix + ".tmp")
253
+ tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
254
+ tmp.replace(p)
255
+ return header
256
+
257
+
258
+ def prepend_entries(entries: list[dict[str, Any]], *,
259
+ path: Path | None = None) -> int:
260
+ """Insert entries right after the header, before existing body entries.
261
+
262
+ Used for the 'Merge' flow: the in-memory history (older) is placed
263
+ before the file's existing body (newer from the adopting session).
264
+ Returns the number of entries prepended. Header untouched.
265
+ """
266
+ p = path or file_path()
267
+ if not p.is_file():
268
+ raise FileNotFoundError(f"no file at {p}")
269
+ with p.open(encoding="utf-8") as fh:
270
+ existing = fh.readlines()
271
+ if not existing:
272
+ raise ValueError(f"empty file at {p}")
273
+ header_line = existing[0]
274
+ body = existing[1:]
275
+ new_lines = [json.dumps(e, ensure_ascii=False) + "\n"
276
+ for e in _normalize_entries(entries)]
277
+ tmp = p.with_suffix(p.suffix + ".tmp")
278
+ tmp.write_text(header_line + "".join(new_lines) + "".join(body),
279
+ encoding="utf-8")
280
+ tmp.replace(p)
281
+ return len(new_lines)
282
+
283
+
284
+ def clear(*, path: Path | None = None) -> None:
285
+ p = path or file_path()
286
+ if p.exists():
287
+ p.unlink()
288
+
289
+
290
+ def read_entries(last: int | None = None, *,
291
+ path: Path | None = None) -> list[dict[str, Any]]:
292
+ """Return entries (excluding the header) as a list of dicts.
293
+
294
+ `last=None` returns all entries; `last=N` returns the trailing N.
295
+ Malformed lines are skipped silently.
296
+ """
297
+ p = path or file_path()
298
+ if not p.is_file():
299
+ return []
300
+ entries: list[dict[str, Any]] = []
301
+ with p.open(encoding="utf-8") as fh:
302
+ for i, line in enumerate(fh):
303
+ line = line.strip()
304
+ if not line:
305
+ continue
306
+ try:
307
+ obj = json.loads(line)
308
+ except json.JSONDecodeError:
309
+ continue
310
+ if i == 0 and isinstance(obj, dict) and obj.get("t") == "header":
311
+ continue
312
+ if isinstance(obj, dict):
313
+ entries.append(obj)
314
+ if last is not None and last >= 0:
315
+ entries = entries[-last:]
316
+ return entries
317
+
318
+
319
+ def status(*, path: Path | None = None) -> dict[str, Any]:
320
+ p = path or file_path()
321
+ if not p.is_file():
322
+ return {"exists": False, "path": str(p)}
323
+ header = read_header(p)
324
+ size = p.stat().st_size
325
+ with p.open(encoding="utf-8") as fh:
326
+ entry_count = sum(1 for _ in fh) - (1 if header else 0)
327
+ return {
328
+ "exists": True,
329
+ "path": str(p),
330
+ "size_bytes": size,
331
+ "size_kb": round(size / 1024, 1),
332
+ "entries": max(entry_count, 0),
333
+ "header": header,
334
+ }
335
+
336
+
337
+ def overflow_handle(max_kb: int, mode: str = "rotate", *,
338
+ path: Path | None = None) -> dict[str, Any]:
339
+ """Enforce max_kb. Returns {'action', 'kept', 'dropped'}.
340
+
341
+ Rotate: drop oldest entries (keep header) until file ≤ max_kb.
342
+ Compress: mark oldest 50% as stale and leave a `needs_compress`
343
+ marker entry for the agent to rewrite on next turn.
344
+ """
345
+ if mode not in VALID_OVERFLOW:
346
+ raise ValueError(f"mode must be one of {sorted(VALID_OVERFLOW)}")
347
+ p = path or file_path()
348
+ if not p.is_file() or p.stat().st_size <= max_kb * 1024:
349
+ return {"action": "noop", "kept": None, "dropped": 0}
350
+ with p.open(encoding="utf-8") as fh:
351
+ lines = fh.readlines()
352
+ if not lines:
353
+ return {"action": "noop", "kept": 0, "dropped": 0}
354
+ header_line = lines[0]
355
+ entries = lines[1:]
356
+ if mode == "rotate":
357
+ budget = max_kb * 1024 - len(header_line.encode("utf-8"))
358
+ kept: list[str] = []
359
+ total = 0
360
+ for line in reversed(entries):
361
+ size = len(line.encode("utf-8"))
362
+ if total + size > budget:
363
+ break
364
+ kept.append(line)
365
+ total += size
366
+ kept.reverse()
367
+ dropped = len(entries) - len(kept)
368
+ tmp = p.with_suffix(p.suffix + ".tmp")
369
+ tmp.write_text(header_line + "".join(kept), encoding="utf-8")
370
+ tmp.replace(p)
371
+ return {"action": "rotate", "kept": len(kept), "dropped": dropped}
372
+ marker = {
373
+ "t": "needs_compress",
374
+ "ts": _now(),
375
+ "reason": f"file exceeded {max_kb} KB, compress-mode requested",
376
+ }
377
+ append(marker, path=p)
378
+ return {"action": "compress_marked", "kept": len(entries), "dropped": 0}
379
+
380
+
381
+ def _cmd_init(args) -> int:
382
+ h = init(args.first_user_msg, freq=args.freq)
383
+ print(json.dumps(h, ensure_ascii=False))
384
+ return 0
385
+
386
+
387
+ def _cmd_append(args) -> int:
388
+ entry = json.loads(args.json) if args.json else {}
389
+ entry.setdefault("t", args.type)
390
+ if not entry.get("t"):
391
+ print("error: --type or a 't' key in --json is required", file=sys.stderr)
392
+ return 2
393
+ append(entry)
394
+ return 0
395
+
396
+
397
+ def _cmd_status(_args) -> int:
398
+ print(json.dumps(status(), ensure_ascii=False, indent=2))
399
+ return 0
400
+
401
+
402
+ def _cmd_check(args) -> int:
403
+ print(check_ownership(args.first_user_msg))
404
+ return 0
405
+
406
+
407
+ def _cmd_state(args) -> int:
408
+ print(ownership_state(args.first_user_msg))
409
+ return 0
410
+
411
+
412
+ def _cmd_adopt(args) -> int:
413
+ h = adopt(args.first_user_msg)
414
+ print(json.dumps(h, ensure_ascii=False))
415
+ return 0
416
+
417
+
418
+ def _load_entries_arg(args) -> list[dict[str, Any]]:
419
+ if getattr(args, "entries_stdin", False):
420
+ raw = sys.stdin.read()
421
+ else:
422
+ raw = args.entries_json or "[]"
423
+ try:
424
+ data = json.loads(raw)
425
+ except json.JSONDecodeError as exc:
426
+ raise ValueError(f"invalid JSON for entries: {exc}") from exc
427
+ if not isinstance(data, list):
428
+ raise ValueError("entries must be a JSON array")
429
+ return data
430
+
431
+
432
+ def _cmd_reset(args) -> int:
433
+ entries = _load_entries_arg(args)
434
+ h = reset_with_entries(args.first_user_msg, entries, freq=args.freq)
435
+ print(json.dumps(h, ensure_ascii=False))
436
+ return 0
437
+
438
+
439
+ def _cmd_prepend(args) -> int:
440
+ entries = _load_entries_arg(args)
441
+ n = prepend_entries(entries)
442
+ print(json.dumps({"prepended": n}, ensure_ascii=False))
443
+ return 0
444
+
445
+
446
+ def _cmd_clear(_args) -> int:
447
+ clear()
448
+ return 0
449
+
450
+
451
+ def _cmd_read(args) -> int:
452
+ last = None if args.all else args.last
453
+ entries = read_entries(last=last)
454
+ print(json.dumps(entries, ensure_ascii=False, indent=2))
455
+ return 0
456
+
457
+
458
+ def _cmd_rotate(args) -> int:
459
+ result = overflow_handle(args.max_kb, mode=args.mode)
460
+ print(json.dumps(result, ensure_ascii=False))
461
+ return 0
462
+
463
+
464
+ def main(argv: list[str] | None = None) -> int:
465
+ ap = argparse.ArgumentParser(description=__doc__)
466
+ sub = ap.add_subparsers(dest="cmd", required=True)
467
+ p_init = sub.add_parser("init")
468
+ p_init.add_argument("--first-user-msg", required=True)
469
+ p_init.add_argument("--freq", default="per_phase", choices=sorted(VALID_FREQS))
470
+ p_init.set_defaults(func=_cmd_init)
471
+ p_app = sub.add_parser("append")
472
+ p_app.add_argument("--type", help="entry type (t field)")
473
+ p_app.add_argument("--json", help="JSON object with entry fields")
474
+ p_app.set_defaults(func=_cmd_append)
475
+ sub.add_parser("status").set_defaults(func=_cmd_status)
476
+ p_chk = sub.add_parser("check")
477
+ p_chk.add_argument("--first-user-msg", required=True)
478
+ p_chk.set_defaults(func=_cmd_check)
479
+ p_state = sub.add_parser("state")
480
+ p_state.add_argument("--first-user-msg", required=True)
481
+ p_state.set_defaults(func=_cmd_state)
482
+ p_ado = sub.add_parser("adopt")
483
+ p_ado.add_argument("--first-user-msg", required=True)
484
+ p_ado.set_defaults(func=_cmd_adopt)
485
+ p_reset = sub.add_parser("reset")
486
+ p_reset.add_argument("--first-user-msg", required=True)
487
+ p_reset.add_argument("--freq", default="per_phase",
488
+ choices=sorted(VALID_FREQS))
489
+ g_r = p_reset.add_mutually_exclusive_group(required=True)
490
+ g_r.add_argument("--entries-json",
491
+ help="JSON array of entry dicts")
492
+ g_r.add_argument("--entries-stdin", action="store_true",
493
+ help="read JSON array from stdin")
494
+ p_reset.set_defaults(func=_cmd_reset)
495
+ p_prep = sub.add_parser("prepend")
496
+ g_p = p_prep.add_mutually_exclusive_group(required=True)
497
+ g_p.add_argument("--entries-json",
498
+ help="JSON array of entry dicts")
499
+ g_p.add_argument("--entries-stdin", action="store_true",
500
+ help="read JSON array from stdin")
501
+ p_prep.set_defaults(func=_cmd_prepend)
502
+ sub.add_parser("clear").set_defaults(func=_cmd_clear)
503
+ p_read = sub.add_parser("read")
504
+ grp = p_read.add_mutually_exclusive_group()
505
+ grp.add_argument("--last", type=int, default=5,
506
+ help="return the trailing N entries (default: 5)")
507
+ grp.add_argument("--all", action="store_true",
508
+ help="return all entries")
509
+ p_read.set_defaults(func=_cmd_read)
510
+ p_rot = sub.add_parser("rotate")
511
+ p_rot.add_argument("--max-kb", type=int, default=256)
512
+ p_rot.add_argument("--mode", default="rotate", choices=sorted(VALID_OVERFLOW))
513
+ p_rot.set_defaults(func=_cmd_rotate)
514
+ args = ap.parse_args(argv)
515
+ return args.func(args)
516
+
517
+
518
+ if __name__ == "__main__":
519
+ sys.exit(main())