strata-agent-memory 0.2.2__tar.gz → 0.2.4__tar.gz

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 (128) hide show
  1. {strata_agent_memory-0.2.2/strata_agent_memory.egg-info → strata_agent_memory-0.2.4}/PKG-INFO +2 -1
  2. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/README.md +1 -0
  3. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/__init__.py +1 -1
  4. strata_agent_memory-0.2.4/agent_memory/approvals.py +460 -0
  5. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/audit.py +113 -7
  6. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/cli.py +554 -42
  7. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/compiler.py +194 -11
  8. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/contracts.py +266 -77
  9. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/evidence.py +601 -31
  10. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/export.py +2 -1
  11. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/hooks.py +72 -20
  12. strata_agent_memory-0.2.4/agent_memory/instances.py +134 -0
  13. strata_agent_memory-0.2.4/agent_memory/kit.py +256 -0
  14. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/ledger.py +470 -69
  15. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/mcp_server.py +55 -4
  16. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/pathmap.py +78 -5
  17. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/recheck.py +3 -0
  18. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/relay/adapters.py +104 -19
  19. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/relay/cli.py +90 -16
  20. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/relay/client.py +439 -25
  21. strata_agent_memory-0.2.4/agent_memory/relay/core.py +1531 -0
  22. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/reliability.py +4 -4
  23. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/remote.py +155 -17
  24. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/render.py +114 -22
  25. strata_agent_memory-0.2.4/agent_memory/roster.py +277 -0
  26. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/serve.py +258 -18
  27. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/signing.py +14 -4
  28. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/trust.py +15 -7
  29. strata_agent_memory-0.2.4/deploy/mac/README.md +284 -0
  30. strata_agent_memory-0.2.4/deploy/mac/agent-keys.sh +279 -0
  31. strata_agent_memory-0.2.4/deploy/mac/agent-session.sh +398 -0
  32. strata_agent_memory-0.2.4/deploy/mac/backup-tokens.sh +136 -0
  33. strata_agent_memory-0.2.4/deploy/mac/install-hooks-mac.sh +301 -0
  34. strata_agent_memory-0.2.4/deploy/mac/install-relay-driver.sh +367 -0
  35. strata_agent_memory-0.2.4/deploy/mac/install-replica-driver.sh +575 -0
  36. strata_agent_memory-0.2.4/deploy/mac/install-runner-driver.sh +224 -0
  37. strata_agent_memory-0.2.4/deploy/mac/instances.example.json +27 -0
  38. strata_agent_memory-0.2.4/deploy/mac/onboard-agent.sh +601 -0
  39. strata_agent_memory-0.2.4/deploy/mac/relay-agent.sh +358 -0
  40. strata_agent_memory-0.2.4/deploy/mac/seed-board.sh +256 -0
  41. strata_agent_memory-0.2.4/deploy/mac/seed-mail-context.sh +212 -0
  42. strata_agent_memory-0.2.4/deploy/mac/seed-topics.sh +182 -0
  43. strata_agent_memory-0.2.4/deploy/mac/upgrade-driver.sh +319 -0
  44. strata_agent_memory-0.2.4/deploy/plugins/.claude-plugin/marketplace.json +12 -0
  45. strata_agent_memory-0.2.4/deploy/plugins/README.md +112 -0
  46. strata_agent_memory-0.2.4/deploy/plugins/claude-code/.claude-plugin/plugin.json +8 -0
  47. strata_agent_memory-0.2.4/deploy/plugins/claude-code/bin/agent-memory-approvals +235 -0
  48. strata_agent_memory-0.2.4/deploy/plugins/claude-code/hooks/hooks.json +16 -0
  49. strata_agent_memory-0.2.4/deploy/plugins/claude-code/rules/approvals.md +6 -0
  50. strata_agent_memory-0.2.4/deploy/plugins/claude-code/skills/approvals/SKILL.md +23 -0
  51. strata_agent_memory-0.2.4/deploy/plugins/claude-code/skills/approve/SKILL.md +25 -0
  52. strata_agent_memory-0.2.4/deploy/plugins/claude-code/skills/reject/SKILL.md +25 -0
  53. strata_agent_memory-0.2.4/deploy/plugins/codex/skills/agent-memory-answer/SKILL.md +22 -0
  54. strata_agent_memory-0.2.4/deploy/plugins/codex/skills/agent-memory-answer/agents/openai.yaml +6 -0
  55. strata_agent_memory-0.2.4/deploy/plugins/codex/skills/agent-memory-approvals/SKILL.md +20 -0
  56. strata_agent_memory-0.2.4/deploy/plugins/codex/skills/agent-memory-approvals/agents/openai.yaml +6 -0
  57. strata_agent_memory-0.2.4/deploy/plugins/codex/skills/agent-memory-approvals/rules/approvals.md +6 -0
  58. strata_agent_memory-0.2.4/deploy/plugins/codex/skills/agent-memory-approvals/scripts/agent-memory-approvals +235 -0
  59. strata_agent_memory-0.2.4/deploy/relay/README.md +68 -0
  60. strata_agent_memory-0.2.4/deploy/relay/agent-memory-relay@.service +46 -0
  61. strata_agent_memory-0.2.4/deploy/relay/install-relay-mac.sh +429 -0
  62. strata_agent_memory-0.2.4/deploy/relay/install-relay.sh +592 -0
  63. strata_agent_memory-0.2.4/deploy/relay/macos/com.strata.agent-memory.relay.plist.template +72 -0
  64. strata_agent_memory-0.2.4/deploy/relay/relay-launch.sh +98 -0
  65. strata_agent_memory-0.2.4/pyproject.toml +76 -0
  66. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4/strata_agent_memory.egg-info}/PKG-INFO +2 -1
  67. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/strata_agent_memory.egg-info/SOURCES.txt +47 -0
  68. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_audit.py +118 -1
  69. strata_agent_memory-0.2.4/tests/test_backlog_check.py +221 -0
  70. strata_agent_memory-0.2.4/tests/test_changelog_fragments.py +138 -0
  71. strata_agent_memory-0.2.4/tests/test_cli.py +526 -0
  72. strata_agent_memory-0.2.4/tests/test_compiler.py +187 -0
  73. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_compiler_render.py +397 -2
  74. strata_agent_memory-0.2.4/tests/test_config.py +296 -0
  75. strata_agent_memory-0.2.4/tests/test_contracts.py +1047 -0
  76. strata_agent_memory-0.2.4/tests/test_deploy_mac.py +3294 -0
  77. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_deploy_relay.py +86 -2
  78. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_deploy_tokens.py +52 -0
  79. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_evidence.py +407 -0
  80. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_hooks.py +192 -0
  81. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_issue_bridge.py +1 -2
  82. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_ledger.py +90 -2
  83. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_packaging.py +42 -0
  84. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_pathmap.py +78 -0
  85. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_phase2.py +83 -2
  86. strata_agent_memory-0.2.4/tests/test_plugins.py +437 -0
  87. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_records.py +39 -0
  88. strata_agent_memory-0.2.4/tests/test_relay_adapters.py +316 -0
  89. strata_agent_memory-0.2.4/tests/test_relay_client.py +2742 -0
  90. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_relay_core.py +673 -9
  91. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_remote.py +644 -14
  92. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_review_fixes.py +18 -13
  93. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_serve.py +557 -21
  94. strata_agent_memory-0.2.4/tests/test_trust.py +826 -0
  95. strata_agent_memory-0.2.2/agent_memory/relay/core.py +0 -821
  96. strata_agent_memory-0.2.2/pyproject.toml +0 -47
  97. strata_agent_memory-0.2.2/tests/test_cli.py +0 -223
  98. strata_agent_memory-0.2.2/tests/test_contracts.py +0 -511
  99. strata_agent_memory-0.2.2/tests/test_relay_client.py +0 -1420
  100. strata_agent_memory-0.2.2/tests/test_trust.py +0 -405
  101. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/LICENSE +0 -0
  102. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/__main__.py +0 -0
  103. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/compact.py +0 -0
  104. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/identity.py +0 -0
  105. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/memorizer.py +0 -0
  106. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/records.py +0 -0
  107. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/relay/__init__.py +0 -0
  108. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/agent_memory/stats.py +0 -0
  109. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/setup.cfg +0 -0
  110. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/strata_agent_memory.egg-info/dependency_links.txt +0 -0
  111. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/strata_agent_memory.egg-info/entry_points.txt +0 -0
  112. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/strata_agent_memory.egg-info/requires.txt +0 -0
  113. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/strata_agent_memory.egg-info/top_level.txt +0 -0
  114. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_compact.py +0 -0
  115. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_deploy_replica.py +0 -0
  116. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_identity.py +0 -0
  117. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_ledger_index.py +0 -0
  118. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_memorizer.py +0 -0
  119. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_recheck.py +0 -0
  120. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_redaction.py +0 -0
  121. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_relay_codex.py +0 -0
  122. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_reliability.py +0 -0
  123. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_remote_signing.py +0 -0
  124. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_review2_fixes.py +0 -0
  125. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_review3_fixes.py +0 -0
  126. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_scale.py +0 -0
  127. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_signing.py +0 -0
  128. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.4}/tests/test_stats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strata-agent-memory
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Portable, auditable, cross-vendor memory for LLM agents: evidence-bound claims, trust tiers, and compiled context views.
5
5
  Author: Strata Intelligence
6
6
  License-Expression: Apache-2.0
@@ -201,6 +201,7 @@ tests/ pytest suite; tests/sit/phase3/ is the CLI-driven system test
201
201
  - [docs/RESEARCH-SYNTHESIS.md](docs/RESEARCH-SYNTHESIS.md): the nine sources, what was taken from each, and what none of them had.
202
202
  - [docs/INTEGRATION.md](docs/INTEGRATION.md): setup per harness, the local verifier lane, the contract loop.
203
203
  - [docs/COMMS-BUS-PLAN.md](docs/COMMS-BUS-PLAN.md): how agents talk through the ledger without spending tokens while they wait, and [docs/RUNBOOK-RELAY.md](docs/RUNBOOK-RELAY.md) to run a relay per agent.
204
+ - [docs/HARNESS-MATRIX.md](docs/HARNESS-MATRIX.md): the M3 harness reach matrix: MCP, hooks, wake paths, compile injection and relay fitness for every harness surface, from vendor documentation (nothing run).
204
205
  - [docs/LANDSCAPE.md](docs/LANDSCAPE.md): the competing systems, their verified licenses, the standards receipts align with, and what none of them do.
205
206
  - [docs/UAT-PLAN.md](docs/UAT-PLAN.md): the one-week acceptance test across Claude Code, Codex, Antigravity and Grok.
206
207
  - [docs/UAT-SELF.md](docs/UAT-SELF.md): the same test for one person with two subscriptions, Claude Code and Codex CLI, with `scripts/uat/` to set it up and to run the cross-lineage verification.
@@ -177,6 +177,7 @@ tests/ pytest suite; tests/sit/phase3/ is the CLI-driven system test
177
177
  - [docs/RESEARCH-SYNTHESIS.md](docs/RESEARCH-SYNTHESIS.md): the nine sources, what was taken from each, and what none of them had.
178
178
  - [docs/INTEGRATION.md](docs/INTEGRATION.md): setup per harness, the local verifier lane, the contract loop.
179
179
  - [docs/COMMS-BUS-PLAN.md](docs/COMMS-BUS-PLAN.md): how agents talk through the ledger without spending tokens while they wait, and [docs/RUNBOOK-RELAY.md](docs/RUNBOOK-RELAY.md) to run a relay per agent.
180
+ - [docs/HARNESS-MATRIX.md](docs/HARNESS-MATRIX.md): the M3 harness reach matrix: MCP, hooks, wake paths, compile injection and relay fitness for every harness surface, from vendor documentation (nothing run).
180
181
  - [docs/LANDSCAPE.md](docs/LANDSCAPE.md): the competing systems, their verified licenses, the standards receipts align with, and what none of them do.
181
182
  - [docs/UAT-PLAN.md](docs/UAT-PLAN.md): the one-week acceptance test across Claude Code, Codex, Antigravity and Grok.
182
183
  - [docs/UAT-SELF.md](docs/UAT-SELF.md): the same test for one person with two subscriptions, Claude Code and Codex CLI, with `scripts/uat/` to set it up and to run the cross-lineage verification.
@@ -9,4 +9,4 @@ see, what did it claim, and can the proof be re-run.
9
9
  The core is dependency-free (Python 3.10+ standard library only).
10
10
  """
11
11
 
12
- __version__ = "0.2.2"
12
+ __version__ = "0.2.4"
@@ -0,0 +1,460 @@
1
+ """The human-only acts on a ledger, one decision each (docs/PROTOCOL.md section 10).
2
+
3
+ Agents verify and a PM closes (section 4.1). What is left for a person is small and
4
+ should cost a person one read and one word each: ``pending`` lists it, and ``answer``
5
+ files the person's record for it. An item is one of
6
+
7
+ close a submitted contract that is verified by derivation and that no PM may
8
+ close (``pm_may_close`` off, or ``config.pm`` names nobody). A gate.
9
+ pm-close a close a PM filed. Information, not a gate: the close stands; the
10
+ person acknowledges it, or objects in a note.
11
+ retraction a note tagged ``approval:retraction`` asking that a claim be withdrawn:
12
+ the claim is the note's ``derived_from`` (or the first claim id its
13
+ statement names), the reason is the note.
14
+ reopen a note tagged ``approval:reopen --contract <id>``: back to ``accepted``,
15
+ or to the status a ``status:<s>`` tag names.
16
+ reject a note tagged ``approval:reject --contract <id>``: the work will not be
17
+ done.
18
+ constraint a note tagged ``approval:constraint``: its statement becomes a
19
+ human-filed constraint.
20
+ needs-human a relay's breaker stopped a thread (section 11); the person's answer is
21
+ a human record on that thread, which is what releases it.
22
+
23
+ ``approval_mode`` decides who is asked for what. human (the default): all of the above.
24
+ auto: the agents close among themselves, so only constraints and retractions -- the acts
25
+ that change what every reader is told is true -- reach a person, and a stopped thread,
26
+ which is an alarm rather than an approval.
27
+
28
+ A person's answer carries the tags ``approval:approved`` or ``approval:rejected`` and
29
+ ``answers:<item id>``, and an item with such an answer from a human is no longer
30
+ pending. Every item carries five lines -- the title and the PR, what was done, the
31
+ evidence (receipt, who re-ran it, the result), the review verdict with its record id, the
32
+ risk -- and the expected answer: approve, reject or hold.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ from typing import Iterable, List, Optional
38
+
39
+ from . import contracts
40
+ from .relay.core import agent_name
41
+ from .records import ValidationError, new_record, scope_for_target, sort_key
42
+
43
+ GATE_KINDS = ("close", "retraction", "reopen", "reject", "constraint", "needs-human")
44
+ PROPOSAL_KINDS = ("retraction", "reopen", "reject", "constraint")
45
+ AUTO_KINDS = ("retraction", "constraint")
46
+ APPROVED, REJECTED = "approval:approved", "approval:rejected"
47
+ PR_URL = re.compile(r"https://github\.com/[\w.-]+/[\w.-]+/pull/(\d+)")
48
+ PR_REF = re.compile(r"\bPR\s*#?(\d+)\b", re.I)
49
+ REOPEN_STATUSES = ("proposed", "accepted", "in_progress")
50
+ LINE = 160 # a summary line a person reads at a glance
51
+ # Every summary is text an agent wrote, shown to the person whose answer it asks for: C0
52
+ # and C1 controls (ESC, CSI) would let it move the cursor and rewrite what the person
53
+ # reads, and bidirectional overrides would reorder it. They become spaces.
54
+ CONTROL = re.compile("[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069]")
55
+
56
+
57
+ def _plain(text) -> str:
58
+ return " ".join(CONTROL.sub(" ", str(text or "")).split())
59
+
60
+
61
+ def _short(text: Optional[str], limit: int = LINE) -> str:
62
+ text = _plain(text)
63
+ return text if len(text) <= limit else text[:limit - 1].rstrip() + "…"
64
+
65
+
66
+ def _first_sentence(text: Optional[str]) -> str:
67
+ text = _plain(text)
68
+ match = re.search(r"[.;:](\s|$)", text)
69
+ return _short(text[:match.start() + 1] if match and match.start() > 20 else text)
70
+
71
+
72
+ def _tags(rec: dict) -> List[str]:
73
+ return [t for t in (rec.get("tags") or []) if isinstance(t, str)]
74
+
75
+
76
+ def _human(rec: dict) -> bool:
77
+ return (rec.get("author") or {}).get("kind") == "human"
78
+
79
+
80
+ def _who(author: dict) -> str:
81
+ return f"{author.get('harness') or author.get('kind') or '?'}/{author.get('user') or '?'}"
82
+
83
+
84
+ def _on_thread(ledger, thread: str, author: dict) -> bool:
85
+ """Whether a note's author is on the thread its tag names: the root's author or one of
86
+ its addressees, as the relay that stopped the thread is."""
87
+ root = ledger.get(thread)
88
+ if not isinstance(root, dict):
89
+ return False
90
+ names = {agent_name(root.get("author") or {})}
91
+ names.update(t[len("to:"):] for t in _tags(root) if t.startswith("to:"))
92
+ return agent_name(author) in names
93
+
94
+
95
+ def _answered(ledger) -> tuple:
96
+ """({item id: the newest human answer's ts}, {thread: the newest human record's ts}).
97
+
98
+ An answer is explicit: a human record tagged ``answers:<id>``; an agent's record
99
+ carrying the tag answers nothing. A person who replied on a stopped thread some other
100
+ way has answered it too -- that reply is what released it (section 11) -- so the
101
+ newest human ``thread:`` record per thread is kept as well. Nothing else counts: a
102
+ human's unrelated update on a contract is not an answer to whether to close it.
103
+ """
104
+ ids, threads = {}, {}
105
+ for rec in ledger.all():
106
+ if not _human(rec):
107
+ continue
108
+ ts = rec.get("ts") or ""
109
+ for tag in _tags(rec):
110
+ if tag.startswith("answers:"):
111
+ key = tag[len("answers:"):]
112
+ ids[key] = max(ids.get(key, ""), ts)
113
+ elif tag.startswith("thread:"):
114
+ thread = tag[len("thread:"):]
115
+ threads[thread] = max(threads.get(thread, ""), ts)
116
+ return ids, threads
117
+
118
+
119
+ def _evidence_moved(ledger, contract_id: str, basis: dict) -> str:
120
+ """The newest ts among the records a close rests on: the latest acceptance run, the
121
+ standing outcome claims, their verifications, the contract's transitions. A person's
122
+ answer older than this was an answer to other evidence, so the item is asked again."""
123
+ stamps = [u.get("ts") or "" for u in ledger.contract_updates(contract_id)]
124
+ for cid in basis.get("outcomes") or []:
125
+ stamps.append((ledger.get(cid) or {}).get("ts") or "")
126
+ stamps += [v.get("ts") or "" for v in ledger.verifications_for(cid)]
127
+ return max(stamps, default="")
128
+
129
+
130
+ # --------------------------------------------------------------------------- the lines
131
+
132
+ def _pr(ledger, contract_id: Optional[str]) -> Optional[str]:
133
+ """The newest PR a contract's own records name: its updates' notes, then its claims."""
134
+ if not contract_id:
135
+ return None
136
+ texts = [(u["ts"], u["body"].get("note") or "") for u in ledger.contract_updates(contract_id)]
137
+ texts += [(c["ts"], c["body"].get("statement") or "") for c in ledger.claims_for_contract(contract_id)]
138
+ for _, text in sorted(texts, reverse=True):
139
+ url = PR_URL.search(text)
140
+ if url:
141
+ return f"PR #{url.group(1)} ({url.group(0)})"
142
+ ref = PR_REF.search(text)
143
+ if ref:
144
+ return f"PR #{ref.group(1)}"
145
+ return None
146
+
147
+
148
+ def _contract_notes(ledger, contract_id: Optional[str], tag: str) -> List[dict]:
149
+ """Claims about a contract carrying ``tag`` (or a ``tag:...`` form), newest first."""
150
+ if not contract_id:
151
+ return []
152
+ found = [c for c in ledger.claims_for_contract(contract_id)
153
+ if any(t == tag or t.startswith(tag + ":") for t in _tags(c))
154
+ and not ledger.retractions_for(c["id"])]
155
+ return sorted(found, key=sort_key, reverse=True)
156
+
157
+
158
+ def _review(ledger, contract_id: Optional[str]) -> dict:
159
+ """The newest review note on the contract: tag ``review``, verdict from a ``verdict:``
160
+ tag (approve, approve-with-nits, changes, reject) or the review's first words."""
161
+ notes = _contract_notes(ledger, contract_id, "review")
162
+ if not notes:
163
+ return {"verdict": None, "id": None, "line": "review: none on file"}
164
+ note = notes[0]
165
+ verdict = next((t[len("verdict:"):] for t in _tags(note) if t.startswith("verdict:")), None)
166
+ shown = verdict or _short(note["body"].get("statement"), 60)
167
+ return {"verdict": verdict, "id": note["id"],
168
+ "line": f"review: {shown} ({note['id']}, {_who(note['author'])})"}
169
+
170
+
171
+ def _risk(ledger, contract_id: Optional[str]) -> dict:
172
+ """The newest open risk or finding filed against the contract."""
173
+ notes = _contract_notes(ledger, contract_id, "risk") + _contract_notes(ledger, contract_id, "finding")
174
+ if not notes:
175
+ return {"id": None, "line": "risk: none recorded"}
176
+ note = sorted(notes, key=sort_key, reverse=True)[0]
177
+ return {"id": note["id"], "line": f"risk: {_short(note['body'].get('statement'), LINE - 20)} ({note['id']})"}
178
+
179
+
180
+ def _evidence(ledger, basis: Optional[dict]) -> str:
181
+ """Receipt, who re-ran it, the result -- for each outcome the contract rests on."""
182
+ if not basis or not basis.get("outcomes"):
183
+ return "evidence: none on file"
184
+ parts = []
185
+ for cid in basis["outcomes"]:
186
+ claim = ledger.get(cid) or {}
187
+ receipts = [e for e in (claim.get("body") or {}).get("evidence") or [] if isinstance(e, str)]
188
+ checks = [ledger.get(v) for v in basis["verifications"].get(cid, [])]
189
+ checks = [v for v in checks if v]
190
+ if checks:
191
+ v = checks[-1]
192
+ results = [r.get("outcome") for r in (v.get("body") or {}).get("results") or [] if isinstance(r, dict)]
193
+ parts.append(f"{', '.join(receipts) or cid} re-run by {_who(v['author'])} ({v['id']}): "
194
+ f"{(v.get('body') or {}).get('verdict')}{', ' + ', '.join(results) if results else ''}")
195
+ else:
196
+ parts.append(f"{', '.join(receipts) or cid}: not re-checked by another identity")
197
+ if basis.get("run"):
198
+ parts.append(f"acceptance {basis['run']}: " + ("passed" if basis.get("ok") else _short(basis.get("reason"), 80)))
199
+ return _short("evidence: " + "; ".join(parts), LINE * 2)
200
+
201
+
202
+ def _expected_for_close(review: dict, risk: dict) -> str:
203
+ if review["verdict"] in ("changes", "reject", "request-changes", "blocked"):
204
+ return "hold"
205
+ if review["id"] is None or risk["id"] is not None:
206
+ return "hold"
207
+ return "approve"
208
+
209
+
210
+ def _item(ledger, kind: str, rid: str, contract_id: Optional[str], title: str, done: str, evidence: str,
211
+ review: dict, risk: dict, expected: str, gate: bool, **extra) -> dict:
212
+ pr = _pr(ledger, contract_id)
213
+ # every field an agent wrote is cleaned here, in the item GET /approvals, memory_approvals
214
+ # and --json hand on, not only in the summary a person reads
215
+ title, done, evidence = _plain(title), _plain(done), _plain(evidence)
216
+ review_line, risk_line = _plain(review["line"]), _plain(risk["line"])
217
+ extra = {key: (_plain(value) if isinstance(value, str) else value) for key, value in extra.items()}
218
+ lines = [f"{title} -- {pr or 'no PR named'}", done, evidence, review_line, risk_line]
219
+ return {"id": rid, "kind": kind, "gate": gate, "contract": contract_id, "title": title, "pr": pr,
220
+ "done": done, "evidence": evidence, "review": review_line, "review_id": review["id"],
221
+ "risk": risk_line, "risk_id": risk["id"], "expected": expected,
222
+ "summary": [_short(line, LINE * 2) for line in lines], **extra}
223
+
224
+
225
+ # --------------------------------------------------------------------------- the list
226
+
227
+ def pending(ledger, assessment=None, reader: Optional[str] = None) -> dict:
228
+ """What waits for a person on this ledger, with the policy that put it there.
229
+
230
+ ``assessment`` is the trust assessment to read tiers from (computed once when
231
+ omitted). ``reader`` is the user asking: a user-scoped proposal is listed only to
232
+ its owner, as every other user-scoped record is.
233
+ """
234
+ config = ledger.config
235
+ mode = contracts.approval_mode(config)
236
+ tiers = assessment if assessment is not None else contracts._Lazy(ledger)
237
+ answered, released = _answered(ledger)
238
+ items: List[dict] = []
239
+
240
+ def visible(rec: dict) -> bool:
241
+ scope = rec.get("scope") or {}
242
+ return scope.get("level") != "user" or (reader is not None and scope.get("user") == reader)
243
+
244
+ no_pm = not contracts.pm_may_close(config) or not contracts.pm_users(config)
245
+ for rec in sorted(ledger.by_kind("contract"), key=sort_key):
246
+ cid = rec["id"]
247
+ filed = contracts.filed_status(ledger, cid)
248
+ title = _short(rec["body"].get("title"), 100)
249
+ if mode == "human" and filed in ("submitted", "verified") and no_pm:
250
+ # a `verified` filed before the rule is history the old machine judged: its
251
+ # close is still a person's, and the lines show what it rests on today. A
252
+ # person who declined it is asked again once the evidence moves.
253
+ basis = contracts.verification_basis(ledger, cid, tiers)
254
+ fresh = cid not in answered or answered[cid] < _evidence_moved(ledger, cid, basis)
255
+ if fresh and (basis["ok"] or filed == "verified"):
256
+ outcome = (ledger.get(basis["outcomes"][0]) if basis["outcomes"] else None) or {}
257
+ review, risk = _review(ledger, cid), _risk(ledger, cid)
258
+ items.append(_item(ledger, "close", cid, cid, title,
259
+ _first_sentence((outcome.get("body") or {}).get("statement")),
260
+ _evidence(ledger, basis), review, risk,
261
+ _expected_for_close(review, risk), gate=True))
262
+ if mode == "human" and filed == "closed":
263
+ close = contracts.transitions(ledger, cid)[-1]
264
+ # a PM's close, listed so a person sees it; an agent that closed before there
265
+ # was a PM rule is history, not something to acknowledge now
266
+ if contracts.is_pm(config, close["author"]) and close["id"] not in answered:
267
+ review, risk = _review(ledger, cid), _risk(ledger, cid)
268
+ basis = contracts.verification_basis(ledger, cid, tiers)
269
+ items.append(_item(ledger, "pm-close", close["id"], cid, title,
270
+ _short(f"closed by {_who(close['author'])}: {close['body'].get('note') or ''}"),
271
+ _evidence(ledger, basis), review, risk, "approve", gate=False))
272
+
273
+ kinds = AUTO_KINDS if mode == "auto" else PROPOSAL_KINDS
274
+ for note in sorted(ledger.by_kind("claim"), key=sort_key):
275
+ if note["id"] in answered or not visible(note) or ledger.retractions_for(note["id"]):
276
+ continue
277
+ tags = _tags(note)
278
+ if "needs-human" in tags: # an alarm in either mode: auto removes approvals, not alarms
279
+ thread = next((t[len("thread:"):] for t in tags if t.startswith("thread:")), None)
280
+ if thread and released.get(thread, "") <= (note.get("ts") or ""):
281
+ # a relay's note on a stop something released before the note was due (a
282
+ # contract update, section 11) is news for a person, and waits on nobody --
283
+ # when the relay that stopped is the one saying so: any token can file a note,
284
+ # and one tagged released by a stranger to the thread is still a stop
285
+ gone = "released" in tags and _on_thread(ledger, thread, note.get("author") or {})
286
+ items.append(_item(ledger, "needs-human", note["id"], (note.get("body") or {}).get("contract"),
287
+ f"thread {thread}", _short(note["body"].get("statement")),
288
+ f"evidence: the last message on it is {', '.join(note.get('derived_from') or []) or '?'}",
289
+ {"verdict": None, "id": None, "line": "review: none (a breaker, not work)"},
290
+ {"id": None, "line": ("risk: none -- the thread was released before this note, "
291
+ "and what it held goes to the agent with the release")
292
+ if gone else
293
+ "risk: the thread stays stopped until a person answers on it"},
294
+ "approve", gate=not gone, thread=thread))
295
+ continue
296
+ kind = next((t[len("approval:"):] for t in tags if t.startswith("approval:")), None)
297
+ if kind not in kinds:
298
+ continue
299
+ item = _proposal(ledger, note, kind, tiers)
300
+ if item is not None:
301
+ items.append(item)
302
+ return {"approval_mode": mode, "pm_may_close": contracts.pm_may_close(config),
303
+ "pm": contracts.pm_users(config), "items": items,
304
+ "waiting": sum(1 for i in items if i["gate"]), "information": sum(1 for i in items if not i["gate"])}
305
+
306
+
307
+ def _target_claim(ledger, note: dict) -> Optional[dict]:
308
+ """The one claim a retraction proposal names: its derived_from when that names any
309
+ record, else the claim ids in its statement -- and only when exactly one claim is
310
+ named. A note that names two would otherwise have the person retract whichever came
311
+ first (review, story 1); it waits, saying why, until it names one."""
312
+ named = [rid for rid in (note.get("derived_from") or []) if rid != note["id"]]
313
+ if not named:
314
+ named = [rid for rid in contracts.CLAIM_ID.findall(note["body"].get("statement") or "") if rid != note["id"]]
315
+ named = list(dict.fromkeys(named))
316
+ if len(named) != 1:
317
+ return None
318
+ rec = ledger.get(named[0])
319
+ return rec if rec is not None and rec.get("kind") == "claim" else None
320
+
321
+
322
+ def _proposal(ledger, note: dict, kind: str, tiers) -> Optional[dict]:
323
+ """A PM's note asking for a human-only act, as an item -- or None once the act has
324
+ happened by some other road (the claim already withdrawn, the contract already there)."""
325
+ body = note.get("body") or {}
326
+ cid = body.get("contract")
327
+ title = _short((ledger.get(cid) or {}).get("body", {}).get("title"), 100) if cid else "(no contract)"
328
+ review, risk = _review(ledger, cid), _risk(ledger, cid)
329
+ asked = f"{_who(note['author'])} asks: {_short(body.get('statement'), LINE - 30)}"
330
+ if kind == "retraction":
331
+ target = _target_claim(ledger, note)
332
+ if target is None:
333
+ return _item(ledger, kind, note["id"], cid, title if cid else "retract (no single claim named)", asked,
334
+ "evidence: the note names no single claim to retract; name exactly one in derived_from",
335
+ review, risk, "reject", gate=True, target=None)
336
+ if ledger.retractions_for(target["id"]):
337
+ return None
338
+ tier = tiers.get(target["id"], {}).get("tier", "?")
339
+ evidence = f"evidence: {target['id']} by {_who(target['author'])} is {tier}"
340
+ return _item(ledger, kind, note["id"], cid, title if cid else f"retract {target['id']}", asked, evidence,
341
+ review, risk, "approve", gate=True, target=target["id"])
342
+ if kind in ("reopen", "reject"):
343
+ if not cid or ledger.get(cid) is None:
344
+ return None
345
+ filed = contracts.filed_status(ledger, cid)
346
+ if kind == "reject" and filed == "rejected":
347
+ return None
348
+ if kind == "reopen" and filed not in contracts.TERMINAL_STATUSES:
349
+ return None
350
+ status = "rejected" if kind == "reject" else next(
351
+ (t[len("status:"):] for t in _tags(note) if t.startswith("status:")
352
+ and t[len("status:"):] in REOPEN_STATUSES), "accepted")
353
+ return _item(ledger, kind, note["id"], cid, title, asked,
354
+ f"evidence: the contract is {filed}; this moves it to {status}",
355
+ review, risk, "approve", gate=True, to_status=status)
356
+ if kind == "constraint":
357
+ # the whole statement is what becomes the constraint, so the whole of it is shown:
358
+ # a summary cut at 160 characters would let a tail nobody read be approved
359
+ return _item(ledger, kind, note["id"], cid, title if cid else "a constraint for every reader", asked,
360
+ "evidence: a constraint is what a person says always applies; nothing to re-run",
361
+ review, risk, "approve", gate=True, text=_plain(body.get("statement")))
362
+ return None
363
+
364
+
365
+ # --------------------------------------------------------------------------- the answer
366
+
367
+ def answer(ledger, author: dict, scope: dict, item_id: str, verdict: str = "approve",
368
+ note: Optional[str] = None, assessment=None) -> List[dict]:
369
+ """File a person's answer to one pending item; returns the records filed.
370
+
371
+ Refused for anything but a human identity: an agent may explain and recommend, and
372
+ the record this files is the one thing it may not. ``reject`` needs a note (the reason
373
+ is the record); ``approve`` takes one. Raises ValidationError with the refusal, or
374
+ KeyError when nothing is pending under that id.
375
+ """
376
+ if author.get("kind") != "human":
377
+ raise ValidationError(
378
+ f"approving is a person's act: this identity is an agent's ({_who(author)}); file the answer under "
379
+ f"a human token (agent-memory approve --as-human locally, or a human's token over --remote)")
380
+ if verdict not in ("approve", "reject"):
381
+ raise ValidationError("an answer is approve or reject; hold is filing nothing")
382
+ note = (note or "").strip() or None
383
+ if verdict == "reject" and not note:
384
+ raise ValidationError("a rejection needs a note: the reason is the record")
385
+ # one assessment for the list and the close's basis: each would otherwise compute its own
386
+ assessment = assessment if assessment is not None else contracts._Lazy(ledger)
387
+ view = pending(ledger, assessment)
388
+ item = next((i for i in view["items"] if i["id"] == item_id), None)
389
+ if item is None:
390
+ raise KeyError(f"nothing pending under {item_id} (agent-memory approvals lists what is)")
391
+ tags = [APPROVED if verdict == "approve" else REJECTED, f"answers:{item_id}"]
392
+ said = f": {note}" if note else ""
393
+ if verdict == "reject" or item["kind"] == "pm-close":
394
+ return [_answer_note(ledger, author, scope, item, verdict, note, tags)]
395
+ kind = item["kind"]
396
+ if kind == "close":
397
+ basis = contracts.verification_basis(ledger, item["contract"], assessment)
398
+ relied = [f"{c} re-checked in {', '.join(basis['verifications'].get(c, [])) or '-'}" for c in basis["outcomes"]]
399
+ return [contracts.update(ledger, author, scope, item["contract"], "closed",
400
+ note=f"approved: {'; '.join(relied)}{said}", tags=tags)]
401
+ if kind in ("reopen", "reject"):
402
+ proposal = ledger.get(item_id)
403
+ reason = note or (proposal or {}).get("body", {}).get("statement")
404
+ return [contracts.update(ledger, author, scope, item["contract"], item["to_status"],
405
+ note=f"approved {item_id}: {_short(reason, 400)}", tags=tags)]
406
+ if kind == "retraction":
407
+ if not item.get("target"):
408
+ raise ValidationError(f"{item_id} names no single claim to retract: reject it, and ask for a note "
409
+ f"that names exactly one in derived_from")
410
+ proposal = ledger.get(item_id) or {}
411
+ target = ledger.get(item["target"])
412
+ reason = f"approved {item_id}: {proposal.get('body', {}).get('statement')}{said}"
413
+ rec = new_record("retraction", author, scope_for_target(target, scope),
414
+ {"claim": target["id"], "reason": _short(reason, 1900)},
415
+ derived_from=[target["id"]], tags=tags)
416
+ return [ledger.append(rec)]
417
+ if kind == "constraint":
418
+ proposal = ledger.get(item_id) or {}
419
+ body = {"type": "constraint", "statement": proposal["body"]["statement"]}
420
+ if proposal["body"].get("contract"):
421
+ body["contract"] = proposal["body"]["contract"]
422
+ return [ledger.append(new_record("claim", author, scope, body, derived_from=[item_id], tags=tags))]
423
+ if kind == "needs-human":
424
+ tags.append(f"thread:{item['thread']}")
425
+ body = {"type": "note", "statement": f"released thread {item['thread']}{said or ': read and answered'}"}
426
+ return [ledger.append(new_record("claim", author, scope, body, derived_from=[item_id], tags=tags))]
427
+ raise ValidationError(f"no answer is defined for a {kind} item")
428
+
429
+
430
+ def _answer_note(ledger, author: dict, scope: dict, item: dict, verdict: str, note: Optional[str],
431
+ tags: Iterable[str]) -> dict:
432
+ """The record for an acknowledgement or a rejection: a note, on the contract when the
433
+ item has one, deriving from the item when the item is a claim (rule 10)."""
434
+ word = "acknowledged" if verdict == "approve" else "declined"
435
+ body = {"type": "note", "statement": _short(f"{word} {item['kind']} {item['id']}{': ' + note if note else ''}", 1900)}
436
+ if item.get("contract"):
437
+ body["contract"] = item["contract"]
438
+ target = ledger.get(item["id"])
439
+ derived = [item["id"]] if target is not None and target.get("kind") == "claim" else []
440
+ return ledger.append(new_record("claim", author, scope, body, derived_from=derived, tags=list(tags)))
441
+
442
+
443
+ def render(view: dict) -> str:
444
+ """The text a person reads: a header, then five lines and the expected answer per item."""
445
+ lines = [f"approvals: approval_mode {view['approval_mode']}, pm_may_close "
446
+ f"{'on' if view['pm_may_close'] else 'off'}, PM {', '.join(view['pm']) or 'none'}; "
447
+ f"{view['waiting']} waiting, {view['information']} for information"]
448
+ for item in view["items"]:
449
+ lines.append("")
450
+ lines.append(f"[{item['kind']}] {item['id']}" + ("" if item["gate"] else " (information: the close stands)"))
451
+ for n, text in enumerate(item["summary"], 1):
452
+ lines.append(f" {n} {text}")
453
+ if item.get("text"):
454
+ lines.append(f" the constraint, in full: {item['text']}")
455
+ lines.append(f" -> {item['expected']}: agent-memory approve {item['id']} | "
456
+ f"agent-memory reject {item['id']} --note \"...\"")
457
+ if not view["items"]:
458
+ lines.append("nothing waits for a person")
459
+ # the view may come from a replica (GET /approvals): the text is cleaned here as well
460
+ return "\n".join(CONTROL.sub(" ", line) for line in lines)