@event4u/agent-config 1.13.0 → 1.14.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.
- package/.agent-src/commands/agent-handoff.md +3 -0
- package/.agent-src/commands/agent-status.md +3 -0
- package/.agent-src/commands/agents-audit.md +4 -0
- package/.agent-src/commands/agents-cleanup.md +6 -1
- package/.agent-src/commands/agents-prepare.md +3 -0
- package/.agent-src/commands/analyze-reference-repo.md +4 -0
- package/.agent-src/commands/bug-fix.md +5 -1
- package/.agent-src/commands/bug-investigate.md +4 -0
- package/.agent-src/commands/chat-history-checkpoint.md +126 -0
- package/.agent-src/commands/chat-history-clear.md +5 -0
- package/.agent-src/commands/chat-history-resume.md +5 -0
- package/.agent-src/commands/chat-history.md +5 -0
- package/.agent-src/commands/check-current-md.md +126 -0
- package/.agent-src/commands/commit-in-chunks.md +98 -0
- package/.agent-src/commands/commit.md +4 -0
- package/.agent-src/commands/compress.md +3 -0
- package/.agent-src/commands/context-create.md +4 -0
- package/.agent-src/commands/context-refactor.md +4 -0
- package/.agent-src/commands/copilot-agents-init.md +3 -0
- package/.agent-src/commands/copilot-agents-optimize.md +3 -0
- package/.agent-src/commands/create-pr-description.md +4 -0
- package/.agent-src/commands/create-pr.md +4 -0
- package/.agent-src/commands/do-and-judge.md +4 -1
- package/.agent-src/commands/do-in-steps.md +3 -0
- package/.agent-src/commands/e2e-heal.md +4 -0
- package/.agent-src/commands/e2e-plan.md +4 -0
- package/.agent-src/commands/estimate-ticket.md +4 -1
- package/.agent-src/commands/feature-dev.md +4 -0
- package/.agent-src/commands/feature-explore.md +4 -0
- package/.agent-src/commands/feature-plan.md +4 -0
- package/.agent-src/commands/feature-refactor.md +4 -0
- package/.agent-src/commands/feature-roadmap.md +6 -0
- package/.agent-src/commands/fix-ci.md +4 -0
- package/.agent-src/commands/fix-portability.md +3 -0
- package/.agent-src/commands/fix-pr-bot-comments.md +4 -0
- package/.agent-src/commands/fix-pr-comments.md +4 -0
- package/.agent-src/commands/fix-pr-developer-comments.md +4 -0
- package/.agent-src/commands/fix-references.md +3 -0
- package/.agent-src/commands/fix-seeder.md +4 -0
- package/.agent-src/commands/implement-ticket.md +39 -13
- package/.agent-src/commands/jira-ticket.md +4 -0
- package/.agent-src/commands/judge.md +3 -0
- package/.agent-src/commands/memory-add.md +5 -3
- package/.agent-src/commands/memory-full.md +5 -2
- package/.agent-src/commands/memory-promote.md +7 -6
- package/.agent-src/commands/mode.md +3 -0
- package/.agent-src/commands/module-create.md +4 -0
- package/.agent-src/commands/module-explore.md +4 -0
- package/.agent-src/commands/onboard.md +24 -0
- package/.agent-src/commands/optimize-agents.md +4 -0
- package/.agent-src/commands/optimize-augmentignore.md +3 -0
- package/.agent-src/commands/optimize-rtk-filters.md +3 -0
- package/.agent-src/commands/optimize-skills.md +4 -0
- package/.agent-src/commands/override-create.md +4 -0
- package/.agent-src/commands/override-manage.md +4 -0
- package/.agent-src/commands/package-reset.md +3 -0
- package/.agent-src/commands/package-test.md +3 -0
- package/.agent-src/commands/prepare-for-review.md +4 -0
- package/.agent-src/commands/project-analyze.md +4 -0
- package/.agent-src/commands/project-health.md +4 -0
- package/.agent-src/commands/propose-memory.md +6 -8
- package/.agent-src/commands/quality-fix.md +4 -0
- package/.agent-src/commands/refine-ticket.md +4 -1
- package/.agent-src/commands/review-changes.md +4 -0
- package/.agent-src/commands/review-routing.md +4 -0
- package/.agent-src/commands/roadmap-create.md +7 -0
- package/.agent-src/commands/roadmap-execute.md +12 -1
- package/.agent-src/commands/rule-compliance-audit.md +4 -0
- package/.agent-src/commands/set-cost-profile.md +3 -0
- package/.agent-src/commands/sync-agent-settings.md +3 -0
- package/.agent-src/commands/sync-gitignore.md +3 -0
- package/.agent-src/commands/tests-create.md +4 -0
- package/.agent-src/commands/tests-execute.md +4 -0
- package/.agent-src/commands/threat-model.md +4 -0
- package/.agent-src/commands/update-form-request-messages.md +4 -0
- package/.agent-src/commands/upstream-contribute.md +4 -0
- package/.agent-src/commands/work.md +161 -0
- package/.agent-src/guidelines/agent-infra/engineering-memory-data-format.md +2 -6
- package/.agent-src/guidelines/agent-infra/layered-settings.md +0 -1
- package/.agent-src/guidelines/agent-infra/memory-access.md +0 -7
- package/.agent-src/guidelines/agent-infra/role-contracts.md +2 -4
- package/.agent-src/guidelines/agent-infra/self-improvement-pipeline.md +0 -1
- package/.agent-src/guidelines/php/patterns/strategy.md +180 -2
- package/.agent-src/personas/README.md +0 -1
- package/.agent-src/rules/artifact-drafting-protocol.md +7 -2
- package/.agent-src/rules/artifact-engagement-recording.md +133 -0
- package/.agent-src/rules/ask-when-uncertain.md +18 -13
- package/.agent-src/rules/augment-portability.md +8 -0
- package/.agent-src/rules/autonomous-execution.md +158 -0
- package/.agent-src/rules/chat-history.md +147 -118
- package/.agent-src/rules/cli-output-handling.md +26 -3
- package/.agent-src/rules/command-suggestion.md +133 -0
- package/.agent-src/rules/commit-policy.md +99 -0
- package/.agent-src/rules/direct-answers.md +114 -0
- package/.agent-src/rules/docs-sync.md +36 -0
- package/.agent-src/rules/downstream-changes.md +10 -9
- package/.agent-src/rules/improve-before-implement.md +9 -6
- package/.agent-src/rules/language-and-tone.md +81 -6
- package/.agent-src/rules/non-destructive-by-default.md +117 -0
- package/.agent-src/rules/package-ci-checks.md +4 -0
- package/.agent-src/rules/preservation-guard.md +20 -0
- package/.agent-src/rules/roadmap-progress-sync.md +103 -30
- package/.agent-src/rules/scope-control.md +42 -1
- package/.agent-src/rules/size-enforcement.md +1 -3
- package/.agent-src/rules/skill-quality.md +3 -8
- package/.agent-src/rules/ui-audit-before-build.md +106 -0
- package/.agent-src/rules/user-interaction.md +82 -50
- package/.agent-src/scripts/update_roadmap_progress.py +17 -5
- package/.agent-src/skills/blade-ui/SKILL.md +30 -5
- package/.agent-src/skills/command-routing/SKILL.md +32 -0
- package/.agent-src/skills/command-writing/SKILL.md +41 -2
- package/.agent-src/skills/description-assist/SKILL.md +21 -0
- package/.agent-src/skills/estimate-ticket/SKILL.md +0 -1
- package/.agent-src/skills/existing-ui-audit/SKILL.md +187 -0
- package/.agent-src/skills/fe-design/SKILL.md +72 -60
- package/.agent-src/skills/finishing-a-development-branch/SKILL.md +4 -0
- package/.agent-src/skills/flux/SKILL.md +31 -4
- package/.agent-src/skills/guideline-writing/SKILL.md +24 -2
- package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +51 -9
- package/.agent-src/skills/livewire/SKILL.md +30 -4
- package/.agent-src/skills/md-language-check/SKILL.md +103 -0
- package/.agent-src/skills/php-coder/SKILL.md +24 -0
- package/.agent-src/skills/react-shadcn-ui/SKILL.md +121 -0
- package/.agent-src/skills/refine-prompt/SKILL.md +220 -0
- package/.agent-src/skills/refine-ticket/SKILL.md +2 -4
- package/.agent-src/skills/roadmap-management/SKILL.md +10 -3
- package/.agent-src/skills/rule-writing/SKILL.md +23 -1
- package/.agent-src/skills/skill-writing/SKILL.md +1 -3
- package/.agent-src/skills/upstream-contribute/SKILL.md +1 -1
- package/.agent-src/skills/using-git-worktrees/SKILL.md +3 -1
- package/.agent-src/templates/AGENTS.md +24 -6
- package/.agent-src/templates/agent-settings.md +149 -0
- package/.agent-src/templates/roadmaps.md +8 -2
- package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
- package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
- package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
- package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
- package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
- package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
- package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
- package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
- package/.agent-src/templates/scripts/telemetry_record.py +166 -0
- package/.agent-src/templates/scripts/telemetry_report.py +161 -0
- package/.agent-src/templates/scripts/telemetry_status.py +142 -0
- package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
- package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
- package/.agent-src/templates/scripts/work_engine/cli.py +592 -0
- package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +7 -0
- package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
- package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +2 -2
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +1 -1
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +1 -1
- package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +36 -4
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
- package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
- package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
- package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
- package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
- package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
- package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
- package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
- package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
- package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
- package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
- package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
- package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +199 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
- package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
- package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
- package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
- package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
- package/.agent-src/templates/scripts/work_engine/state.py +641 -0
- package/.claude-plugin/marketplace.json +105 -2
- package/AGENTS.md +36 -8
- package/CHANGELOG.md +534 -0
- package/README.md +125 -4
- package/config/agent-settings.template.yml +45 -0
- package/config/gitignore-block.txt +4 -0
- package/docs/architecture.md +28 -1
- package/docs/development.md +1 -1
- package/docs/getting-started.md +2 -2
- package/docs/installation.md +86 -0
- package/docs/showcase.md +204 -0
- package/package.json +1 -1
- package/scripts/agent-config +199 -0
- package/scripts/audit_cloud_compatibility.py +288 -0
- package/scripts/build_cloud_bundle.py +458 -0
- package/scripts/build_linear_digest.py +263 -0
- package/scripts/chat_history.py +796 -7
- package/scripts/check_compression.py +139 -0
- package/scripts/check_iron_law_prominence.py +143 -0
- package/scripts/check_md_language.py +159 -0
- package/scripts/check_portability.py +36 -0
- package/scripts/check_reply_consistency.py +140 -0
- package/scripts/command_suggester/__init__.py +51 -0
- package/scripts/command_suggester/cooldown.py +132 -0
- package/scripts/command_suggester/loader.py +70 -0
- package/scripts/command_suggester/match.py +180 -0
- package/scripts/command_suggester/rank.py +120 -0
- package/scripts/command_suggester/render.py +86 -0
- package/scripts/command_suggester/sanitize.py +113 -0
- package/scripts/command_suggester/settings.py +125 -0
- package/scripts/command_suggester/types.py +78 -0
- package/scripts/hooks/augment-chat-history.sh +56 -0
- package/scripts/install-hooks.sh +67 -0
- package/scripts/install.py +150 -33
- package/scripts/lint_marketplace.py +27 -0
- package/scripts/migrate_command_suggestions.py +151 -0
- package/scripts/schemas/command.schema.json +41 -0
- package/scripts/skill_linter.py +67 -0
- package/scripts/sync_agent_settings.py +42 -12
- package/templates/consumer-settings/augment-cli-hooks.json +54 -0
- package/templates/consumer-settings/claude-settings.json +55 -1
- package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
- package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
- package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
- package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
- /package/.agent-src/templates/scripts/{implement_ticket → work_engine}/persona_policy.py +0 -0
package/scripts/chat_history.py
CHANGED
|
@@ -17,6 +17,7 @@ Usage:
|
|
|
17
17
|
python3 scripts/chat_history.py init --first-user-msg "..." [--freq per_phase]
|
|
18
18
|
python3 scripts/chat_history.py append --type phase --json '{...}'
|
|
19
19
|
python3 scripts/chat_history.py status
|
|
20
|
+
python3 scripts/chat_history.py heartbeat --first-user-msg "..."
|
|
20
21
|
python3 scripts/chat_history.py check --first-user-msg "..."
|
|
21
22
|
python3 scripts/chat_history.py state --first-user-msg "..."
|
|
22
23
|
python3 scripts/chat_history.py adopt --first-user-msg "..."
|
|
@@ -41,12 +42,34 @@ from pathlib import Path
|
|
|
41
42
|
from typing import Any
|
|
42
43
|
|
|
43
44
|
DEFAULT_FILE = ".agent-chat-history"
|
|
45
|
+
DEFAULT_SETTINGS_FILE = ".agent-settings.yml"
|
|
44
46
|
SCHEMA_VERSION = 2
|
|
45
47
|
FORMER_FPS_CAP = 10
|
|
46
48
|
VALID_FREQS = {"per_turn", "per_phase", "per_tool"}
|
|
47
49
|
VALID_OVERFLOW = {"rotate", "compress"}
|
|
48
50
|
_WS_RE = re.compile(r"\s+")
|
|
49
51
|
|
|
52
|
+
# Exit codes for the CLI. Distinct codes let shell callers branch on state.
|
|
53
|
+
EXIT_OK = 0
|
|
54
|
+
EXIT_BAD_ARGS = 2
|
|
55
|
+
EXIT_OWNERSHIP_REFUSED = 3
|
|
56
|
+
EXIT_MISSING = 10
|
|
57
|
+
EXIT_FOREIGN = 11
|
|
58
|
+
EXIT_RETURNING = 12
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OwnershipError(RuntimeError):
|
|
62
|
+
"""Raised when an operation is rejected because the caller's session
|
|
63
|
+
does not own the chat-history file. `state` is one of
|
|
64
|
+
`foreign` | `returning` | `missing`."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, state: str, *, header_fp: str = "",
|
|
67
|
+
current_fp: str = "") -> None:
|
|
68
|
+
super().__init__(f"chat-history ownership refused: state={state}")
|
|
69
|
+
self.state = state
|
|
70
|
+
self.header_fp = header_fp
|
|
71
|
+
self.current_fp = current_fp
|
|
72
|
+
|
|
50
73
|
|
|
51
74
|
def file_path() -> Path:
|
|
52
75
|
return Path(os.environ.get("AGENT_CHAT_HISTORY_FILE") or DEFAULT_FILE)
|
|
@@ -119,14 +142,32 @@ def init(first_user_msg: str, freq: str = "per_phase", *,
|
|
|
119
142
|
return header
|
|
120
143
|
|
|
121
144
|
|
|
122
|
-
def append(entry: dict[str, Any], *, path: Path | None = None
|
|
123
|
-
|
|
145
|
+
def append(entry: dict[str, Any], *, path: Path | None = None,
|
|
146
|
+
first_user_msg: str | None = None) -> None:
|
|
147
|
+
"""Append one entry. Entry must be a dict; `ts` is auto-filled.
|
|
148
|
+
|
|
149
|
+
When `first_user_msg` is provided, the call validates ownership
|
|
150
|
+
before writing: only `state == match` proceeds. Any other state
|
|
151
|
+
(`foreign`, `returning`, `missing`) raises `OwnershipError`. This
|
|
152
|
+
is the second line of defense against silent writes to a foreign
|
|
153
|
+
session's file. Existing callers without `first_user_msg` keep the
|
|
154
|
+
legacy unguarded behavior for back-compat.
|
|
155
|
+
"""
|
|
124
156
|
if not isinstance(entry, dict) or not entry.get("t"):
|
|
125
157
|
raise ValueError("entry must be a dict with non-empty 't' key")
|
|
126
158
|
if entry["t"] == "header":
|
|
127
159
|
raise ValueError("use init() to write the header, not append()")
|
|
128
|
-
entry.setdefault("ts", _now())
|
|
129
160
|
p = path or file_path()
|
|
161
|
+
if first_user_msg is not None:
|
|
162
|
+
state = ownership_state(first_user_msg, path=p)
|
|
163
|
+
if state != "match":
|
|
164
|
+
header = read_header(p) or {}
|
|
165
|
+
raise OwnershipError(
|
|
166
|
+
state,
|
|
167
|
+
header_fp=str(header.get("fp", "")),
|
|
168
|
+
current_fp=fingerprint(first_user_msg),
|
|
169
|
+
)
|
|
170
|
+
entry.setdefault("ts", _now())
|
|
130
171
|
with p.open("a", encoding="utf-8") as fh:
|
|
131
172
|
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
132
173
|
|
|
@@ -334,6 +375,346 @@ def status(*, path: Path | None = None) -> dict[str, Any]:
|
|
|
334
375
|
}
|
|
335
376
|
|
|
336
377
|
|
|
378
|
+
def _read_chat_history_enabled(settings_path: Path) -> bool:
|
|
379
|
+
"""Read chat_history.enabled from .agent-settings.yml.
|
|
380
|
+
|
|
381
|
+
Returns False when the file is missing, malformed, lacks the
|
|
382
|
+
`chat_history` section, or sets enabled to false. Default-deny so
|
|
383
|
+
`turn-check` is safe to run from projects that have not opted in.
|
|
384
|
+
PyYAML is imported lazily — the rest of this module works without it.
|
|
385
|
+
"""
|
|
386
|
+
if not settings_path.is_file():
|
|
387
|
+
return False
|
|
388
|
+
try:
|
|
389
|
+
import yaml # type: ignore[import-untyped]
|
|
390
|
+
except ImportError:
|
|
391
|
+
return True # fail open: settings file present but no parser
|
|
392
|
+
try:
|
|
393
|
+
with settings_path.open(encoding="utf-8") as fh:
|
|
394
|
+
data = yaml.safe_load(fh) or {}
|
|
395
|
+
except (OSError, yaml.YAMLError):
|
|
396
|
+
return False
|
|
397
|
+
section = data.get("chat_history") if isinstance(data, dict) else None
|
|
398
|
+
if not isinstance(section, dict):
|
|
399
|
+
return False
|
|
400
|
+
return bool(section.get("enabled", False))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
VALID_HEARTBEAT_MODES = ("on", "off", "hybrid")
|
|
404
|
+
DRIFT_STATES = ("missing", "foreign", "returning")
|
|
405
|
+
|
|
406
|
+
# Hook events that the platform-hook wrapper accepts. Mapped to entry
|
|
407
|
+
# types in HOOK_EVENT_ENTRY_TYPE; cadence filtering in
|
|
408
|
+
# CADENCE_EVENTS decides whether the event actually lands in the log
|
|
409
|
+
# given chat_history.frequency.
|
|
410
|
+
VALID_HOOK_EVENTS = (
|
|
411
|
+
"session_start", "session_end", "user_prompt", "agent_response",
|
|
412
|
+
"tool_use", "phase", "stop",
|
|
413
|
+
)
|
|
414
|
+
HOOK_EVENT_ENTRY_TYPE = {
|
|
415
|
+
"user_prompt": "user",
|
|
416
|
+
"agent_response": "agent",
|
|
417
|
+
"tool_use": "tool",
|
|
418
|
+
"phase": "phase",
|
|
419
|
+
"stop": "agent",
|
|
420
|
+
"session_end": "phase",
|
|
421
|
+
}
|
|
422
|
+
# Which events actually trigger an append for each frequency. session_*
|
|
423
|
+
# events are control plane (sidecar / init), not log entries, so they
|
|
424
|
+
# are absent from these sets.
|
|
425
|
+
CADENCE_EVENTS = {
|
|
426
|
+
"per_turn": frozenset({"stop", "agent_response"}),
|
|
427
|
+
"per_phase": frozenset({"phase", "stop", "user_prompt"}),
|
|
428
|
+
"per_tool": frozenset({"tool_use"}),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
# Per-platform mapping from the platform's native hook event name to our
|
|
432
|
+
# internal VALID_HOOK_EVENTS. Used by hook_dispatch() to translate
|
|
433
|
+
# stdin JSON payloads coming from Claude Code, Augment Code, Cursor,
|
|
434
|
+
# Cline, Windsurf, and Gemini CLI into a unified entry-point. Sourced
|
|
435
|
+
# from agents/contexts/chat-history-platform-hooks.md.
|
|
436
|
+
PLATFORM_EVENT_MAP: dict[str, dict[str, str]] = {
|
|
437
|
+
"claude": {
|
|
438
|
+
"SessionStart": "session_start",
|
|
439
|
+
"UserPromptSubmit": "user_prompt",
|
|
440
|
+
"PostToolUse": "tool_use",
|
|
441
|
+
"Stop": "stop",
|
|
442
|
+
"SessionEnd": "session_end",
|
|
443
|
+
"PreCompact": "phase",
|
|
444
|
+
},
|
|
445
|
+
"augment": {
|
|
446
|
+
"SessionStart": "session_start",
|
|
447
|
+
"Stop": "stop",
|
|
448
|
+
"PostToolUse": "tool_use",
|
|
449
|
+
"SessionEnd": "session_end",
|
|
450
|
+
},
|
|
451
|
+
"cursor": {
|
|
452
|
+
"sessionStart": "session_start",
|
|
453
|
+
"sessionEnd": "session_end",
|
|
454
|
+
"afterAgentResponse": "agent_response",
|
|
455
|
+
"stop": "stop",
|
|
456
|
+
"postToolUse": "tool_use",
|
|
457
|
+
"beforeSubmitPrompt": "user_prompt",
|
|
458
|
+
},
|
|
459
|
+
"cline": {
|
|
460
|
+
"TaskStart": "session_start",
|
|
461
|
+
"TaskComplete": "session_end",
|
|
462
|
+
"UserPromptSubmit": "user_prompt",
|
|
463
|
+
"PostToolUse": "tool_use",
|
|
464
|
+
},
|
|
465
|
+
"windsurf": {
|
|
466
|
+
"pre_user_prompt": "user_prompt",
|
|
467
|
+
"post_cascade_response": "agent_response",
|
|
468
|
+
"post_cascade_response_with_transcript": "agent_response",
|
|
469
|
+
"post_setup_worktree": "phase",
|
|
470
|
+
},
|
|
471
|
+
"gemini": {
|
|
472
|
+
"SessionStart": "session_start",
|
|
473
|
+
"AfterAgent": "agent_response",
|
|
474
|
+
"AfterTool": "tool_use",
|
|
475
|
+
"SessionEnd": "session_end",
|
|
476
|
+
},
|
|
477
|
+
# Generic / pass-through — the caller already speaks our internal
|
|
478
|
+
# event vocabulary. Useful for shell snippets that want to invoke
|
|
479
|
+
# the dispatcher with a known event regardless of platform.
|
|
480
|
+
"generic": {ev: ev for ev in VALID_HOOK_EVENTS},
|
|
481
|
+
}
|
|
482
|
+
VALID_PLATFORMS = tuple(PLATFORM_EVENT_MAP.keys())
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _read_chat_history_frequency(settings_path: Path) -> str:
|
|
486
|
+
"""Read chat_history.frequency from .agent-settings.yml. Default per_phase."""
|
|
487
|
+
if not settings_path.is_file():
|
|
488
|
+
return "per_phase"
|
|
489
|
+
try:
|
|
490
|
+
import yaml # type: ignore[import-untyped]
|
|
491
|
+
except ImportError:
|
|
492
|
+
return "per_phase"
|
|
493
|
+
try:
|
|
494
|
+
with settings_path.open(encoding="utf-8") as fh:
|
|
495
|
+
data = yaml.safe_load(fh) or {}
|
|
496
|
+
except (OSError, yaml.YAMLError):
|
|
497
|
+
return "per_phase"
|
|
498
|
+
section = data.get("chat_history") if isinstance(data, dict) else None
|
|
499
|
+
if not isinstance(section, dict):
|
|
500
|
+
return "per_phase"
|
|
501
|
+
val = str(section.get("frequency", "per_phase")).lower()
|
|
502
|
+
return val if val in VALID_FREQS else "per_phase"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def sidecar_path(path: Path | None = None) -> Path:
|
|
506
|
+
"""Return the path to the session sidecar (.agent-chat-history.session).
|
|
507
|
+
|
|
508
|
+
Sidecar carries the first-user-msg for the active session so hook
|
|
509
|
+
invocations after `session_start` don't need the agent to pass it
|
|
510
|
+
on every call. Lives next to the JSONL file.
|
|
511
|
+
"""
|
|
512
|
+
base = path or file_path()
|
|
513
|
+
return base.with_name(base.name + ".session")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def read_sidecar(path: Path | None = None) -> dict[str, Any] | None:
|
|
517
|
+
"""Read and parse the sidecar; returns None on missing or malformed."""
|
|
518
|
+
sp = sidecar_path(path)
|
|
519
|
+
if not sp.is_file():
|
|
520
|
+
return None
|
|
521
|
+
try:
|
|
522
|
+
with sp.open(encoding="utf-8") as fh:
|
|
523
|
+
data = json.load(fh)
|
|
524
|
+
return data if isinstance(data, dict) else None
|
|
525
|
+
except (OSError, json.JSONDecodeError):
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def write_sidecar(first_user_msg: str, *,
|
|
530
|
+
path: Path | None = None) -> dict[str, Any]:
|
|
531
|
+
"""Write the session sidecar atomically. Overwrites on session_start."""
|
|
532
|
+
sp = sidecar_path(path)
|
|
533
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
534
|
+
payload = {
|
|
535
|
+
"first_user_msg": first_user_msg,
|
|
536
|
+
"fp": fingerprint(first_user_msg),
|
|
537
|
+
"started_at": _now(),
|
|
538
|
+
}
|
|
539
|
+
tmp = sp.with_suffix(sp.suffix + ".tmp")
|
|
540
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
541
|
+
json.dump(payload, fh, ensure_ascii=False)
|
|
542
|
+
tmp.replace(sp)
|
|
543
|
+
return payload
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _read_chat_history_heartbeat_mode(settings_path: Path) -> str:
|
|
547
|
+
"""Read chat_history.heartbeat from .agent-settings.yml.
|
|
548
|
+
|
|
549
|
+
Returns one of 'on' | 'off' | 'hybrid'. Default 'hybrid' (marker
|
|
550
|
+
surfaces only on drift states — missing/foreign/returning — and
|
|
551
|
+
stays silent on 'ok'/'disabled'). Unknown values fall back to
|
|
552
|
+
'hybrid'. Mirrors the default-deny policy of `_read_chat_history_enabled`
|
|
553
|
+
for the `enabled` flag, but here the default is the safer-by-design
|
|
554
|
+
hybrid mode rather than off.
|
|
555
|
+
"""
|
|
556
|
+
if not settings_path.is_file():
|
|
557
|
+
return "hybrid"
|
|
558
|
+
try:
|
|
559
|
+
import yaml # type: ignore[import-untyped]
|
|
560
|
+
except ImportError:
|
|
561
|
+
return "hybrid"
|
|
562
|
+
try:
|
|
563
|
+
with settings_path.open(encoding="utf-8") as fh:
|
|
564
|
+
data = yaml.safe_load(fh) or {}
|
|
565
|
+
except (OSError, yaml.YAMLError):
|
|
566
|
+
return "hybrid"
|
|
567
|
+
section = data.get("chat_history") if isinstance(data, dict) else None
|
|
568
|
+
if not isinstance(section, dict):
|
|
569
|
+
return "hybrid"
|
|
570
|
+
raw = section.get("heartbeat", "hybrid")
|
|
571
|
+
# YAML 1.1 (PyYAML default) booleanizes bare on/off to True/False.
|
|
572
|
+
# Coerce back so users can write `heartbeat: on` without quoting.
|
|
573
|
+
if raw is True:
|
|
574
|
+
return "on"
|
|
575
|
+
if raw is False:
|
|
576
|
+
return "off"
|
|
577
|
+
val = str(raw).lower()
|
|
578
|
+
if val in VALID_HEARTBEAT_MODES:
|
|
579
|
+
return val
|
|
580
|
+
return "hybrid"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def turn_check(first_user_msg: str, *, path: Path | None = None,
|
|
584
|
+
settings_path: Path | None = None) -> dict[str, Any]:
|
|
585
|
+
"""Compute the turn-start ownership state.
|
|
586
|
+
|
|
587
|
+
Returns a structured dict the CLI renders to stdout/stderr. Pure
|
|
588
|
+
function — no I/O outside the two paths it reads.
|
|
589
|
+
"""
|
|
590
|
+
sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
|
|
591
|
+
if not _read_chat_history_enabled(sp):
|
|
592
|
+
return {"state": "disabled", "exit": EXIT_OK}
|
|
593
|
+
p = path or file_path()
|
|
594
|
+
state = ownership_state(first_user_msg, path=p)
|
|
595
|
+
if state == "match":
|
|
596
|
+
st = status(path=p)
|
|
597
|
+
return {
|
|
598
|
+
"state": "ok",
|
|
599
|
+
"exit": EXIT_OK,
|
|
600
|
+
"entries": st.get("entries", 0),
|
|
601
|
+
}
|
|
602
|
+
header = read_header(p) or {}
|
|
603
|
+
out: dict[str, Any] = {
|
|
604
|
+
"state": state,
|
|
605
|
+
"current_fp": fingerprint(first_user_msg),
|
|
606
|
+
"header_fp": str(header.get("fp", "")),
|
|
607
|
+
"preview": str(header.get("preview", "")),
|
|
608
|
+
}
|
|
609
|
+
if state == "missing":
|
|
610
|
+
out["exit"] = EXIT_MISSING
|
|
611
|
+
elif state == "foreign":
|
|
612
|
+
out["exit"] = EXIT_FOREIGN
|
|
613
|
+
st = status(path=p)
|
|
614
|
+
out["entries"] = st.get("entries", 0)
|
|
615
|
+
else: # returning
|
|
616
|
+
out["exit"] = EXIT_RETURNING
|
|
617
|
+
st = status(path=p)
|
|
618
|
+
out["entries"] = st.get("entries", 0)
|
|
619
|
+
return out
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _format_age(seconds: int) -> str:
|
|
623
|
+
"""Render a relative duration as a compact human-readable string."""
|
|
624
|
+
if seconds < 0:
|
|
625
|
+
return "just now"
|
|
626
|
+
if seconds < 60:
|
|
627
|
+
return f"{seconds}s ago"
|
|
628
|
+
if seconds < 3600:
|
|
629
|
+
return f"{seconds // 60}m ago"
|
|
630
|
+
if seconds < 86400:
|
|
631
|
+
return f"{seconds // 3600}h ago"
|
|
632
|
+
return f"{seconds // 86400}d ago"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _last_entry_age_seconds(path: Path) -> int | None:
|
|
636
|
+
"""Return age of the latest non-header entry in seconds, or None.
|
|
637
|
+
|
|
638
|
+
Reads the file once, takes the last non-empty line, parses its `ts`
|
|
639
|
+
field. Tolerant of malformed lines and missing timestamps — returns
|
|
640
|
+
None instead of raising. Used by `heartbeat()` to surface stale
|
|
641
|
+
appends in the in-band marker.
|
|
642
|
+
"""
|
|
643
|
+
if not path.is_file():
|
|
644
|
+
return None
|
|
645
|
+
last_line: str | None = None
|
|
646
|
+
try:
|
|
647
|
+
with path.open(encoding="utf-8") as fh:
|
|
648
|
+
for raw in fh:
|
|
649
|
+
stripped = raw.strip()
|
|
650
|
+
if stripped:
|
|
651
|
+
last_line = stripped
|
|
652
|
+
except OSError:
|
|
653
|
+
return None
|
|
654
|
+
if not last_line:
|
|
655
|
+
return None
|
|
656
|
+
try:
|
|
657
|
+
obj = json.loads(last_line)
|
|
658
|
+
except json.JSONDecodeError:
|
|
659
|
+
return None
|
|
660
|
+
if not isinstance(obj, dict) or obj.get("t") == "header":
|
|
661
|
+
return None
|
|
662
|
+
ts = obj.get("ts")
|
|
663
|
+
if not ts or not isinstance(ts, str):
|
|
664
|
+
return None
|
|
665
|
+
try:
|
|
666
|
+
parsed = dt.datetime.fromisoformat(ts)
|
|
667
|
+
except ValueError:
|
|
668
|
+
return None
|
|
669
|
+
if parsed.tzinfo is None:
|
|
670
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
671
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
672
|
+
return int((now - parsed).total_seconds())
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def heartbeat(first_user_msg: str, *, path: Path | None = None,
|
|
676
|
+
settings_path: Path | None = None) -> dict[str, Any]:
|
|
677
|
+
"""Compute the in-band reply marker proving the rule was executed.
|
|
678
|
+
|
|
679
|
+
The marker is a single-line string the agent must include verbatim
|
|
680
|
+
at the end of every reply. Fields surface state, entry count,
|
|
681
|
+
cadence, and age of the last entry — so a stale gap (no append
|
|
682
|
+
across two replies at `per_turn`/`per_phase`) is immediately
|
|
683
|
+
visible to the user without any out-of-band tooling.
|
|
684
|
+
|
|
685
|
+
Always returns exit-equivalent 0; this is observability, not a
|
|
686
|
+
gate. Ownership refusal lives in `append`, the turn-start gate
|
|
687
|
+
lives in `turn_check`. `heartbeat` only reports.
|
|
688
|
+
"""
|
|
689
|
+
sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
|
|
690
|
+
if not _read_chat_history_enabled(sp):
|
|
691
|
+
return {"state": "disabled",
|
|
692
|
+
"marker": "📒 chat-history: disabled"}
|
|
693
|
+
p = path or file_path()
|
|
694
|
+
state = ownership_state(first_user_msg, path=p)
|
|
695
|
+
st = status(path=p)
|
|
696
|
+
entries = int(st.get("entries", 0)) if st.get("exists") else 0
|
|
697
|
+
header = st.get("header") or {}
|
|
698
|
+
freq = str(header.get("freq", "?")) if header else "?"
|
|
699
|
+
if state == "match":
|
|
700
|
+
age = _last_entry_age_seconds(p)
|
|
701
|
+
age_str = _format_age(age) if age is not None else "no entries"
|
|
702
|
+
marker = (f"📒 chat-history: ok · {entries} entries · "
|
|
703
|
+
f"{freq} · last {age_str}")
|
|
704
|
+
return {"state": "ok", "entries": entries, "freq": freq,
|
|
705
|
+
"last_age_seconds": age, "marker": marker}
|
|
706
|
+
if state == "missing":
|
|
707
|
+
return {"state": "missing",
|
|
708
|
+
"marker": "📒 chat-history: missing — run init"}
|
|
709
|
+
if state == "foreign":
|
|
710
|
+
return {"state": "foreign", "entries": entries,
|
|
711
|
+
"marker": (f"📒 chat-history: foreign · {entries} "
|
|
712
|
+
f"entries on file — render Foreign-Prompt")}
|
|
713
|
+
return {"state": "returning", "entries": entries,
|
|
714
|
+
"marker": (f"📒 chat-history: returning · {entries} "
|
|
715
|
+
f"entries on file — render Returning-Prompt")}
|
|
716
|
+
|
|
717
|
+
|
|
337
718
|
def overflow_handle(max_kb: int, mode: str = "rotate", *,
|
|
338
719
|
path: Path | None = None) -> dict[str, Any]:
|
|
339
720
|
"""Enforce max_kb. Returns {'action', 'kept', 'dropped'}.
|
|
@@ -378,20 +759,270 @@ def overflow_handle(max_kb: int, mode: str = "rotate", *,
|
|
|
378
759
|
return {"action": "compress_marked", "kept": len(entries), "dropped": 0}
|
|
379
760
|
|
|
380
761
|
|
|
762
|
+
def hook_append(event: str, *,
|
|
763
|
+
first_user_msg: str | None = None,
|
|
764
|
+
payload: dict[str, Any] | None = None,
|
|
765
|
+
path: Path | None = None,
|
|
766
|
+
settings_path: Path | None = None) -> dict[str, Any]:
|
|
767
|
+
"""Platform-hook entry point — wraps init/append/sidecar.
|
|
768
|
+
|
|
769
|
+
Designed for `SessionStart`, `UserPromptSubmit`, `PostToolUse`,
|
|
770
|
+
`Stop`, `SessionEnd` style hooks across platforms. Stateless: every
|
|
771
|
+
invocation reads the sidecar for the active session's first-user-msg.
|
|
772
|
+
The very first call (`event == "session_start"`) writes the sidecar
|
|
773
|
+
and initializes the JSONL header if missing.
|
|
774
|
+
|
|
775
|
+
Cadence-aware: events that don't match `chat_history.frequency`
|
|
776
|
+
are silently skipped. `enabled: false` short-circuits to a noop.
|
|
777
|
+
|
|
778
|
+
Returns a structured dict the CLI emits as JSON. Never raises for
|
|
779
|
+
non-fatal control-plane states (missing sidecar, cadence skip,
|
|
780
|
+
disabled) — these surface as `action` values so hooks can choose
|
|
781
|
+
fail_open vs fail_closed by inspecting the result.
|
|
782
|
+
"""
|
|
783
|
+
if event not in VALID_HOOK_EVENTS:
|
|
784
|
+
raise ValueError(f"event must be one of {sorted(VALID_HOOK_EVENTS)}")
|
|
785
|
+
sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
|
|
786
|
+
if not _read_chat_history_enabled(sp):
|
|
787
|
+
return {"action": "disabled", "event": event}
|
|
788
|
+
p = path or file_path()
|
|
789
|
+
payload = payload or {}
|
|
790
|
+
|
|
791
|
+
if event == "session_start":
|
|
792
|
+
if not first_user_msg:
|
|
793
|
+
return {"action": "skipped_no_first_user_msg", "event": event}
|
|
794
|
+
write_sidecar(first_user_msg, path=p)
|
|
795
|
+
if not p.is_file() or read_header(p) is None:
|
|
796
|
+
freq = _read_chat_history_frequency(sp)
|
|
797
|
+
init(first_user_msg, freq=freq, path=p)
|
|
798
|
+
return {"action": "initialized", "event": event,
|
|
799
|
+
"fp": fingerprint(first_user_msg)}
|
|
800
|
+
return {"action": "sidecar_written", "event": event,
|
|
801
|
+
"fp": fingerprint(first_user_msg)}
|
|
802
|
+
|
|
803
|
+
side = read_sidecar(p)
|
|
804
|
+
fum = first_user_msg or (side or {}).get("first_user_msg")
|
|
805
|
+
if not fum:
|
|
806
|
+
return {"action": "skipped_no_sidecar", "event": event,
|
|
807
|
+
"hint": "session_start hook never ran or sidecar was deleted"}
|
|
808
|
+
|
|
809
|
+
if event == "session_end":
|
|
810
|
+
# Control plane only — touch sidecar's last-seen but do not append.
|
|
811
|
+
return {"action": "session_end_noop", "event": event}
|
|
812
|
+
|
|
813
|
+
freq = _read_chat_history_frequency(sp)
|
|
814
|
+
if event not in CADENCE_EVENTS.get(freq, frozenset()):
|
|
815
|
+
return {"action": "skipped_cadence", "event": event, "frequency": freq}
|
|
816
|
+
|
|
817
|
+
entry_type = HOOK_EVENT_ENTRY_TYPE.get(event, "agent")
|
|
818
|
+
entry: dict[str, Any] = {"t": entry_type}
|
|
819
|
+
text = str(payload.get("text", "")).strip()
|
|
820
|
+
if text:
|
|
821
|
+
entry["text"] = _preview(text, 200)
|
|
822
|
+
if event == "tool_use":
|
|
823
|
+
tool = payload.get("tool")
|
|
824
|
+
if tool:
|
|
825
|
+
entry["tool"] = str(tool)
|
|
826
|
+
for k in ("source", "phase", "decision"):
|
|
827
|
+
if payload.get(k):
|
|
828
|
+
entry[k] = str(payload[k])
|
|
829
|
+
try:
|
|
830
|
+
append(entry, path=p, first_user_msg=fum)
|
|
831
|
+
except OwnershipError as exc:
|
|
832
|
+
return {"action": "ownership_refused", "event": event,
|
|
833
|
+
"state": exc.state,
|
|
834
|
+
"header_fp": exc.header_fp[:8],
|
|
835
|
+
"current_fp": exc.current_fp[:8]}
|
|
836
|
+
return {"action": "appended", "event": event, "type": entry_type}
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _extract_hook_text(payload: dict[str, Any]) -> str:
|
|
840
|
+
"""Pull a textual snippet out of a platform's hook payload.
|
|
841
|
+
|
|
842
|
+
Tries common field names across Claude Code, Augment Code, Cursor,
|
|
843
|
+
Cline, Windsurf, and Gemini CLI. Returns the first non-empty string
|
|
844
|
+
found, stripped; empty string when nothing usable is present.
|
|
845
|
+
"""
|
|
846
|
+
for key in ("prompt", "user_prompt", "first_user_msg", "firstUserMsg",
|
|
847
|
+
"userMessage", "user_message", "text", "response", "message",
|
|
848
|
+
"content"):
|
|
849
|
+
v = payload.get(key)
|
|
850
|
+
if isinstance(v, str) and v.strip():
|
|
851
|
+
return v.strip()
|
|
852
|
+
# Tool response wrappers (Claude PostToolUse, etc.) — best-effort.
|
|
853
|
+
tr = payload.get("tool_response") or payload.get("toolResponse")
|
|
854
|
+
if isinstance(tr, dict):
|
|
855
|
+
for key in ("output", "stdout", "result", "text"):
|
|
856
|
+
v = tr.get(key)
|
|
857
|
+
if isinstance(v, str) and v.strip():
|
|
858
|
+
return v.strip()
|
|
859
|
+
return ""
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _extract_hook_tool(payload: dict[str, Any]) -> str:
|
|
863
|
+
"""Pull the tool name out of a platform's hook payload."""
|
|
864
|
+
for key in ("tool_name", "toolName", "tool"):
|
|
865
|
+
v = payload.get(key)
|
|
866
|
+
if isinstance(v, str) and v.strip():
|
|
867
|
+
return v.strip()
|
|
868
|
+
return ""
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _extract_hook_event(payload: dict[str, Any]) -> str:
|
|
872
|
+
"""Pull the platform's native hook event name out of the payload."""
|
|
873
|
+
for key in ("hook_event_name", "event", "eventName", "event_name"):
|
|
874
|
+
v = payload.get(key)
|
|
875
|
+
if isinstance(v, str) and v.strip():
|
|
876
|
+
return v.strip()
|
|
877
|
+
return ""
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def hook_dispatch(platform: str, raw_json: str, *,
|
|
881
|
+
event_override: str | None = None,
|
|
882
|
+
path: Path | None = None,
|
|
883
|
+
settings_path: Path | None = None) -> dict[str, Any]:
|
|
884
|
+
"""Read a platform's stdin JSON, translate to our hook vocabulary, dispatch.
|
|
885
|
+
|
|
886
|
+
Used by `chat_history.py hook-dispatch --platform <name>` so consumer
|
|
887
|
+
projects can wire their `.claude/settings.json` / `.augment/settings.json`
|
|
888
|
+
/ `.cursor/hooks.json` etc. to a single command. The mapping comes from
|
|
889
|
+
PLATFORM_EVENT_MAP; unmapped events are silently skipped (returned as
|
|
890
|
+
`skipped_unmapped_event` so the caller can decide fail-open vs
|
|
891
|
+
fail-closed).
|
|
892
|
+
|
|
893
|
+
Bootstrap: when the platform fires the very first non-`session_start`
|
|
894
|
+
event (e.g. `UserPromptSubmit`) and no sidecar exists yet, the
|
|
895
|
+
dispatcher synthesizes a `session_start` first using the prompt as the
|
|
896
|
+
`first_user_msg`. This handles platforms whose `SessionStart` payload
|
|
897
|
+
does not carry the prompt itself.
|
|
898
|
+
"""
|
|
899
|
+
if platform not in PLATFORM_EVENT_MAP:
|
|
900
|
+
raise ValueError(
|
|
901
|
+
f"unknown platform: {platform!r}; "
|
|
902
|
+
f"expected one of {sorted(VALID_PLATFORMS)}"
|
|
903
|
+
)
|
|
904
|
+
raw = (raw_json or "").strip()
|
|
905
|
+
if not raw:
|
|
906
|
+
payload: dict[str, Any] = {}
|
|
907
|
+
else:
|
|
908
|
+
try:
|
|
909
|
+
payload = json.loads(raw)
|
|
910
|
+
except json.JSONDecodeError as exc:
|
|
911
|
+
raise ValueError(f"invalid JSON on stdin: {exc}") from exc
|
|
912
|
+
if not isinstance(payload, dict):
|
|
913
|
+
raise ValueError("stdin JSON must decode to an object")
|
|
914
|
+
|
|
915
|
+
raw_event = (event_override or _extract_hook_event(payload) or "").strip()
|
|
916
|
+
event = PLATFORM_EVENT_MAP[platform].get(raw_event)
|
|
917
|
+
if not event:
|
|
918
|
+
return {"action": "skipped_unmapped_event", "platform": platform,
|
|
919
|
+
"raw_event": raw_event}
|
|
920
|
+
|
|
921
|
+
text = _extract_hook_text(payload)
|
|
922
|
+
tool = _extract_hook_tool(payload)
|
|
923
|
+
# The user's first message is what we hash for ownership. We can only
|
|
924
|
+
# extract it from prompt-bearing events; for stop / tool_use / *_end
|
|
925
|
+
# the sidecar must already exist.
|
|
926
|
+
fum = text if event in {"session_start", "user_prompt"} else None
|
|
927
|
+
|
|
928
|
+
hook_payload: dict[str, Any] = {"source": f"hook:{platform}:{raw_event}"}
|
|
929
|
+
if text and event != "session_start":
|
|
930
|
+
hook_payload["text"] = text
|
|
931
|
+
if tool:
|
|
932
|
+
hook_payload["tool"] = tool
|
|
933
|
+
|
|
934
|
+
p = path or file_path()
|
|
935
|
+
|
|
936
|
+
if event == "session_start":
|
|
937
|
+
return hook_append("session_start", first_user_msg=fum,
|
|
938
|
+
path=path, settings_path=settings_path)
|
|
939
|
+
|
|
940
|
+
# Bootstrap: the first non-session_start event from a platform whose
|
|
941
|
+
# SessionStart did not carry the prompt (e.g. Claude Code) needs an
|
|
942
|
+
# implicit init so ownership and the sidecar exist before append.
|
|
943
|
+
side = read_sidecar(p)
|
|
944
|
+
if side is None and fum:
|
|
945
|
+
hook_append("session_start", first_user_msg=fum,
|
|
946
|
+
path=path, settings_path=settings_path)
|
|
947
|
+
|
|
948
|
+
return hook_append(event, first_user_msg=fum, payload=hook_payload,
|
|
949
|
+
path=path, settings_path=settings_path)
|
|
950
|
+
|
|
951
|
+
|
|
381
952
|
def _cmd_init(args) -> int:
|
|
382
953
|
h = init(args.first_user_msg, freq=args.freq)
|
|
383
954
|
print(json.dumps(h, ensure_ascii=False))
|
|
384
955
|
return 0
|
|
385
956
|
|
|
386
957
|
|
|
958
|
+
def _cmd_hook_append(args) -> int:
|
|
959
|
+
payload: dict[str, Any] = {}
|
|
960
|
+
if args.payload:
|
|
961
|
+
try:
|
|
962
|
+
payload = json.loads(args.payload)
|
|
963
|
+
except json.JSONDecodeError as exc:
|
|
964
|
+
print(f"error: --payload must be valid JSON: {exc}",
|
|
965
|
+
file=sys.stderr)
|
|
966
|
+
return EXIT_BAD_ARGS
|
|
967
|
+
if not isinstance(payload, dict):
|
|
968
|
+
print("error: --payload must decode to a JSON object",
|
|
969
|
+
file=sys.stderr)
|
|
970
|
+
return EXIT_BAD_ARGS
|
|
971
|
+
settings_path = Path(args.settings) if args.settings else None
|
|
972
|
+
try:
|
|
973
|
+
result = hook_append(
|
|
974
|
+
args.event,
|
|
975
|
+
first_user_msg=args.first_user_msg,
|
|
976
|
+
payload=payload,
|
|
977
|
+
settings_path=settings_path,
|
|
978
|
+
)
|
|
979
|
+
except ValueError as exc:
|
|
980
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
981
|
+
return EXIT_BAD_ARGS
|
|
982
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
983
|
+
if result.get("action") == "ownership_refused":
|
|
984
|
+
return EXIT_OWNERSHIP_REFUSED
|
|
985
|
+
return EXIT_OK
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _cmd_hook_dispatch(args) -> int:
|
|
989
|
+
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
|
|
990
|
+
settings_path = Path(args.settings) if args.settings else None
|
|
991
|
+
try:
|
|
992
|
+
result = hook_dispatch(
|
|
993
|
+
args.platform,
|
|
994
|
+
raw,
|
|
995
|
+
event_override=args.event,
|
|
996
|
+
settings_path=settings_path,
|
|
997
|
+
)
|
|
998
|
+
except ValueError as exc:
|
|
999
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1000
|
+
return EXIT_BAD_ARGS
|
|
1001
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
1002
|
+
if result.get("action") == "ownership_refused":
|
|
1003
|
+
return EXIT_OWNERSHIP_REFUSED
|
|
1004
|
+
return EXIT_OK
|
|
1005
|
+
|
|
1006
|
+
|
|
387
1007
|
def _cmd_append(args) -> int:
|
|
388
1008
|
entry = json.loads(args.json) if args.json else {}
|
|
389
1009
|
entry.setdefault("t", args.type)
|
|
390
1010
|
if not entry.get("t"):
|
|
391
|
-
print("error: --type or a 't' key in --json is required",
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
1011
|
+
print("error: --type or a 't' key in --json is required",
|
|
1012
|
+
file=sys.stderr)
|
|
1013
|
+
return EXIT_BAD_ARGS
|
|
1014
|
+
try:
|
|
1015
|
+
append(entry, first_user_msg=args.first_user_msg)
|
|
1016
|
+
except OwnershipError as exc:
|
|
1017
|
+
print(
|
|
1018
|
+
f"error: append refused — state={exc.state}; "
|
|
1019
|
+
f"header_fp={exc.header_fp[:8]} current_fp={exc.current_fp[:8]}. "
|
|
1020
|
+
f"Run `chat_history.py turn-check --first-user-msg \"...\"` "
|
|
1021
|
+
f"and resolve ownership before retrying.",
|
|
1022
|
+
file=sys.stderr,
|
|
1023
|
+
)
|
|
1024
|
+
return EXIT_OWNERSHIP_REFUSED
|
|
1025
|
+
return EXIT_OK
|
|
395
1026
|
|
|
396
1027
|
|
|
397
1028
|
def _cmd_status(_args) -> int:
|
|
@@ -409,6 +1040,75 @@ def _cmd_state(args) -> int:
|
|
|
409
1040
|
return 0
|
|
410
1041
|
|
|
411
1042
|
|
|
1043
|
+
def _format_turn_check_stdout(result: dict[str, Any]) -> str:
|
|
1044
|
+
"""Render turn_check() result as a single key=value line for shell parsing."""
|
|
1045
|
+
state = result["state"]
|
|
1046
|
+
parts = [f"state={state}"]
|
|
1047
|
+
if "entries" in result:
|
|
1048
|
+
parts.append(f"entries={result['entries']}")
|
|
1049
|
+
if state in {"foreign", "returning"}:
|
|
1050
|
+
parts.append(f"header_fp={str(result.get('header_fp', ''))[:8]}")
|
|
1051
|
+
parts.append(f"current_fp={str(result.get('current_fp', ''))[:8]}")
|
|
1052
|
+
preview = str(result.get("preview", "")).replace('"', "'")
|
|
1053
|
+
if preview:
|
|
1054
|
+
parts.append(f'preview="{preview[:80]}"')
|
|
1055
|
+
return " ".join(parts)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _turn_check_action_hint(state: str) -> str:
|
|
1059
|
+
"""Stderr hint telling the agent which prompt to render."""
|
|
1060
|
+
if state == "ok":
|
|
1061
|
+
return ""
|
|
1062
|
+
if state == "disabled":
|
|
1063
|
+
return ""
|
|
1064
|
+
if state == "missing":
|
|
1065
|
+
return ("ACTION REQUIRED: state=missing — run "
|
|
1066
|
+
"`chat_history.py init --first-user-msg \"...\" "
|
|
1067
|
+
"--freq <frequency-from-settings>` before any other reply.")
|
|
1068
|
+
if state == "foreign":
|
|
1069
|
+
return ("ACTION REQUIRED: state=foreign — render the Foreign-Prompt "
|
|
1070
|
+
"from the chat-history rule (3 numbered options: Resume / "
|
|
1071
|
+
"New start / Ignore) before any other reply. Do not append "
|
|
1072
|
+
"to this file until the user picks.")
|
|
1073
|
+
if state == "returning":
|
|
1074
|
+
return ("ACTION REQUIRED: state=returning — render the "
|
|
1075
|
+
"Returning-Prompt from the chat-history rule (3 numbered "
|
|
1076
|
+
"options: Merge / Replace / Continue) before any other "
|
|
1077
|
+
"reply. Do not append to this file until the user picks.")
|
|
1078
|
+
return f"ACTION REQUIRED: unknown state={state}"
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _cmd_turn_check(args) -> int:
|
|
1082
|
+
settings_path = Path(args.settings) if args.settings else None
|
|
1083
|
+
result = turn_check(args.first_user_msg, settings_path=settings_path)
|
|
1084
|
+
print(_format_turn_check_stdout(result))
|
|
1085
|
+
hint = _turn_check_action_hint(result["state"])
|
|
1086
|
+
if hint:
|
|
1087
|
+
print(hint, file=sys.stderr)
|
|
1088
|
+
return int(result["exit"])
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _cmd_heartbeat(args) -> int:
|
|
1092
|
+
settings_path = Path(args.settings) if args.settings else None
|
|
1093
|
+
result = heartbeat(args.first_user_msg, settings_path=settings_path)
|
|
1094
|
+
if args.json:
|
|
1095
|
+
# JSON consumers want the full record regardless of mode.
|
|
1096
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
1097
|
+
return EXIT_OK
|
|
1098
|
+
mode = _read_chat_history_heartbeat_mode(
|
|
1099
|
+
settings_path or Path(DEFAULT_SETTINGS_FILE)
|
|
1100
|
+
)
|
|
1101
|
+
state = str(result.get("state", ""))
|
|
1102
|
+
# off → never print. hybrid → only on drift states.
|
|
1103
|
+
# on → always (current behavior).
|
|
1104
|
+
if mode == "off":
|
|
1105
|
+
return EXIT_OK
|
|
1106
|
+
if mode == "hybrid" and state not in DRIFT_STATES:
|
|
1107
|
+
return EXIT_OK
|
|
1108
|
+
print(result["marker"])
|
|
1109
|
+
return EXIT_OK
|
|
1110
|
+
|
|
1111
|
+
|
|
412
1112
|
def _cmd_adopt(args) -> int:
|
|
413
1113
|
h = adopt(args.first_user_msg)
|
|
414
1114
|
print(json.dumps(h, ensure_ascii=False))
|
|
@@ -471,6 +1171,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
471
1171
|
p_app = sub.add_parser("append")
|
|
472
1172
|
p_app.add_argument("--type", help="entry type (t field)")
|
|
473
1173
|
p_app.add_argument("--json", help="JSON object with entry fields")
|
|
1174
|
+
p_app.add_argument(
|
|
1175
|
+
"--first-user-msg",
|
|
1176
|
+
default=None,
|
|
1177
|
+
help=("validate ownership before writing — refuses with exit "
|
|
1178
|
+
f"{EXIT_OWNERSHIP_REFUSED} on foreign/returning/missing"),
|
|
1179
|
+
)
|
|
474
1180
|
p_app.set_defaults(func=_cmd_append)
|
|
475
1181
|
sub.add_parser("status").set_defaults(func=_cmd_status)
|
|
476
1182
|
p_chk = sub.add_parser("check")
|
|
@@ -479,6 +1185,36 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
479
1185
|
p_state = sub.add_parser("state")
|
|
480
1186
|
p_state.add_argument("--first-user-msg", required=True)
|
|
481
1187
|
p_state.set_defaults(func=_cmd_state)
|
|
1188
|
+
p_tc = sub.add_parser(
|
|
1189
|
+
"turn-check",
|
|
1190
|
+
help=("turn-start ownership gate; exit 0=ok/disabled, "
|
|
1191
|
+
f"{EXIT_MISSING}=missing, {EXIT_FOREIGN}=foreign, "
|
|
1192
|
+
f"{EXIT_RETURNING}=returning"),
|
|
1193
|
+
)
|
|
1194
|
+
p_tc.add_argument("--first-user-msg", required=True)
|
|
1195
|
+
p_tc.add_argument(
|
|
1196
|
+
"--settings",
|
|
1197
|
+
default=None,
|
|
1198
|
+
help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
|
|
1199
|
+
)
|
|
1200
|
+
p_tc.set_defaults(func=_cmd_turn_check)
|
|
1201
|
+
p_hb = sub.add_parser(
|
|
1202
|
+
"heartbeat",
|
|
1203
|
+
help=("emit the in-band reply marker; always exit 0. "
|
|
1204
|
+
"Agent must include the stdout line verbatim in every reply."),
|
|
1205
|
+
)
|
|
1206
|
+
p_hb.add_argument("--first-user-msg", required=True)
|
|
1207
|
+
p_hb.add_argument(
|
|
1208
|
+
"--settings",
|
|
1209
|
+
default=None,
|
|
1210
|
+
help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
|
|
1211
|
+
)
|
|
1212
|
+
p_hb.add_argument(
|
|
1213
|
+
"--json",
|
|
1214
|
+
action="store_true",
|
|
1215
|
+
help="emit the full result dict instead of just the marker",
|
|
1216
|
+
)
|
|
1217
|
+
p_hb.set_defaults(func=_cmd_heartbeat)
|
|
482
1218
|
p_ado = sub.add_parser("adopt")
|
|
483
1219
|
p_ado.add_argument("--first-user-msg", required=True)
|
|
484
1220
|
p_ado.set_defaults(func=_cmd_adopt)
|
|
@@ -511,6 +1247,59 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
511
1247
|
p_rot.add_argument("--max-kb", type=int, default=256)
|
|
512
1248
|
p_rot.add_argument("--mode", default="rotate", choices=sorted(VALID_OVERFLOW))
|
|
513
1249
|
p_rot.set_defaults(func=_cmd_rotate)
|
|
1250
|
+
p_hook = sub.add_parser(
|
|
1251
|
+
"hook-append",
|
|
1252
|
+
help=("platform-hook entry point — wraps init/append/sidecar; "
|
|
1253
|
+
"stateless after the first session_start call"),
|
|
1254
|
+
)
|
|
1255
|
+
p_hook.add_argument(
|
|
1256
|
+
"--event",
|
|
1257
|
+
required=True,
|
|
1258
|
+
choices=sorted(VALID_HOOK_EVENTS),
|
|
1259
|
+
help="hook event name (session_start required first)",
|
|
1260
|
+
)
|
|
1261
|
+
p_hook.add_argument(
|
|
1262
|
+
"--first-user-msg",
|
|
1263
|
+
default=None,
|
|
1264
|
+
help=("required on session_start; subsequent events read it from "
|
|
1265
|
+
"the sidecar"),
|
|
1266
|
+
)
|
|
1267
|
+
p_hook.add_argument(
|
|
1268
|
+
"--payload",
|
|
1269
|
+
default=None,
|
|
1270
|
+
help=("JSON object with event-specific fields "
|
|
1271
|
+
"(text/tool/source/phase/decision)"),
|
|
1272
|
+
)
|
|
1273
|
+
p_hook.add_argument(
|
|
1274
|
+
"--settings",
|
|
1275
|
+
default=None,
|
|
1276
|
+
help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
|
|
1277
|
+
)
|
|
1278
|
+
p_hook.set_defaults(func=_cmd_hook_append)
|
|
1279
|
+
p_disp = sub.add_parser(
|
|
1280
|
+
"hook-dispatch",
|
|
1281
|
+
help=("platform-hook entry point — reads platform JSON from stdin, "
|
|
1282
|
+
"translates the native event name to our vocabulary, and "
|
|
1283
|
+
"invokes hook-append"),
|
|
1284
|
+
)
|
|
1285
|
+
p_disp.add_argument(
|
|
1286
|
+
"--platform",
|
|
1287
|
+
required=True,
|
|
1288
|
+
choices=sorted(VALID_PLATFORMS),
|
|
1289
|
+
help="source platform whose hook is firing",
|
|
1290
|
+
)
|
|
1291
|
+
p_disp.add_argument(
|
|
1292
|
+
"--event",
|
|
1293
|
+
default=None,
|
|
1294
|
+
help=("override the platform-native event name (default: read "
|
|
1295
|
+
"from stdin payload key hook_event_name / event)"),
|
|
1296
|
+
)
|
|
1297
|
+
p_disp.add_argument(
|
|
1298
|
+
"--settings",
|
|
1299
|
+
default=None,
|
|
1300
|
+
help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
|
|
1301
|
+
)
|
|
1302
|
+
p_disp.set_defaults(func=_cmd_hook_dispatch)
|
|
514
1303
|
args = ap.parse_args(argv)
|
|
515
1304
|
return args.func(args)
|
|
516
1305
|
|