@passioncode-ai/passioncode 0.2.15 → 0.2.17

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 (35) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/family.json +2 -2
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +3 -3
  5. package/payload/manifest.json +12 -12
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  9. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -3
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +8 -0
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +9 -4
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -2
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +4 -0
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -1
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +7 -1
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +33 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +12 -5
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +16 -3
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +32 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +18 -4
  22. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  23. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  24. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
  26. package/payload/plugins/observatory-log/.claude-plugin/plugin.json +1 -1
  27. package/payload/plugins/observatory-log/hooks/ask-why.py +141 -36
  28. package/payload/plugins/observatory-log/hooks/hooks.json +13 -1
  29. package/payload/plugins/observatory-log/hooks/prompt-why.py +180 -0
  30. package/payload/plugins/observatory-log/hooks/record-turn.sh +31 -15
  31. package/payload/plugins/observatory-log/hooks/session-start.sh +10 -4
  32. package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +16 -5
  33. package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +83 -13
  34. package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +1 -1
  35. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -1356,7 +1356,7 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
1356
1356
  #: Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`, DEC-0067); a test
1357
1357
  #: holds it equal to the contract's register at the pin.
1358
1358
  PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
1359
- "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
1359
+ "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "coding-agents": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
1360
1360
  "entitlement-key-set": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
1361
1361
  "fabric-activity": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
1362
1362
  "fabric-interop": ("0.1", "0.2"), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
@@ -1364,7 +1364,7 @@ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
1364
1364
  "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
1365
1365
  "fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "pairing-message": ("1",), "pipeline": ("0.1",),
1366
1366
  "revocation-delta": ("1",), "session-handoff": ("1",), "share-registration": ("1",),
1367
- "share-registration-receipt": ("1",), "window-lease": ("1",),
1367
+ "share-registration-receipt": ("1",), "support-quality-daily": ("1",), "window-lease": ("1",),
1368
1368
  }
1369
1369
  MAX_PROTOCOLS = 64
1370
1370
  _REVISION = re.compile(r"([a-z][a-z0-9.-]{0,63})/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})", re.ASCII)
@@ -1622,9 +1622,13 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
1622
1622
  cache_read_tokens: int = 0, cache_write_tokens: int = 0,
1623
1623
  cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
1624
1624
  at: Optional[str] = None, route: Optional[str] = None,
1625
- project_id: Optional[str] = None, task_run_id: Optional[str] = None) -> Dict[str, Any]:
1625
+ project_id: Optional[str] = None, task_run_id: Optional[str] = None,
1626
+ tokens_measured: bool = True) -> Dict[str, Any]:
1626
1627
  """One model call, from the provider's own usage numbers. No prompt, output or caller.
1627
1628
 
1629
+ `tokens_measured=False` marks a call whose token counts are a lower bound or unknown (the provider returned none,
1630
+ the stream was cut): the report counts it in `unmeasuredCalls` and its token totals are a lower bound (DEC-0091).
1631
+
1628
1632
  `project_id` (a URI, kept in its normal form, DEC-0076) and `task_run_id` (the service's own run id) attribute the
1629
1633
  call to the project the service ran it for (DEC-0065); they never name a caller, session or person.
1630
1634
 
@@ -1650,6 +1654,10 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
1650
1654
  raise ServiceError("route is subscription, key or platform-metered (DEC-0046, DEC-0061).")
1651
1655
  receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
1652
1656
  "costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
1657
+ if tokens_measured is not True:
1658
+ if tokens_measured is not False:
1659
+ raise ServiceError("tokens_measured is True or False.")
1660
+ receipt["tokensMeasured"] = False
1653
1661
  if route is not None:
1654
1662
  receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
1655
1663
  if task_run_id is not None and project_id is None:
@@ -1703,6 +1711,8 @@ def _by_project(receipts: List[Dict[str, Any]], first: str, last: str) -> List[D
1703
1711
 
1704
1712
  def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
1705
1713
  row["calls"] += 1
1714
+ if receipt.get("tokensMeasured") is False:
1715
+ row["unmeasuredCalls"] = row.get("unmeasuredCalls", 0) + 1 # written only when above 0: absent is 0 (DEC-0091)
1706
1716
  for name in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
1707
1717
  row[name] += int(receipt.get(name) or 0)
1708
1718
  if receipt.get("costUsd") is None:
@@ -1760,6 +1770,8 @@ def _priced_row(row: Dict[str, Any], at: str) -> List[Dict[str, str]]:
1760
1770
  flag = lambda path, message: [{"code": "FAC-SEM-025", "instancePath": path, "message": message}] # noqa: E731
1761
1771
  if unpriced > calls:
1762
1772
  return flag(at + "/unpricedCalls", "more unpriced calls than calls")
1773
+ if _usage_count(row.get("unmeasuredCalls")) > calls: # DEC-0091: unknown token counts are some of the calls
1774
+ return flag(at + "/unmeasuredCalls", "more calls with unknown tokens than calls")
1763
1775
  if calls > 0 and unpriced == calls and row.get("costUsd", _MISSING_COST) is not None:
1764
1776
  return flag(at + "/costUsd", "every call is unpriced, so the cost is unknown (null), not a number")
1765
1777
  if unpriced < calls and row.get("costUsd", _MISSING_COST) is None:
@@ -1800,7 +1812,7 @@ def usage_findings(report: Any) -> List[Dict[str, str]]:
1800
1812
  if spent:
1801
1813
  flag(at + "/byModel", "a day with calls, tokens or cost names the models they belong to")
1802
1814
  continue
1803
- for field in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
1815
+ for field in ("calls", "unpricedCalls", "unmeasuredCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
1804
1816
  total = sum(m.get(field) or 0 for m in models if isinstance(m.get(field) or 0, (int, float)))
1805
1817
  if (day.get(field) or 0) != total:
1806
1818
  flag("%s/%s" % (at, field), "the day's %s is not the sum of its models (%s ≠ %s)" % (field, day.get(field) or 0, total))
@@ -2001,6 +2013,8 @@ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instanc
2001
2013
  for m in models:
2002
2014
  for name in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
2003
2015
  day[name] += m[name]
2016
+ if m.get("unmeasuredCalls"):
2017
+ day["unmeasuredCalls"] = day.get("unmeasuredCalls", 0) + m["unmeasuredCalls"]
2004
2018
  day["_cost"] += m["costUsd"] or 0.0
2005
2019
  day = _close_totals(day)
2006
2020
  day["byModel"] = models
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.38.0"
8
+ version: "0.39.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "07042181bfae2a481aa0da1a5651f24c76669c48"
10
+ contract-commit: "c55f2d5a11c9a0197e958e87212fca7ae1154769"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -141,7 +141,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
141
141
  The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
142
142
  does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
143
143
 
144
- Pin exactly contract `0.1.0` at commit `07042181bfae2a481aa0da1a5651f24c76669c48` and
144
+ Pin exactly contract `0.1.0` at commit `c55f2d5a11c9a0197e958e87212fca7ae1154769` and
145
145
  read the pinned guide before implementing protocol details. If this skill is installed
146
146
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
147
147
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
@@ -30,7 +30,8 @@ The normative rules are the Fabric Agent Contract's, at the commit this plugin p
30
30
 
31
31
  Every step up is paid as cloud only: the person or the organization pays for the cloud its agents run in, and the
32
32
  order says which hosting it holds (`personal-cloud`, `org-cloud`, `enterprise-cloud` in `entitlement/1`). An agent never
33
- checks a price; it may read whether its context's entitlement is current and degrade honestly when it is not.
33
+ checks a price; it may read whether its context's entitlement is current and degrade honestly when it is not. An agent placed
34
+ into a customer's cloud is part of that customer's tier, not a product of its own.
34
35
 
35
36
  ## Contexts: one agent, the person and each organization
36
37
 
@@ -19,7 +19,7 @@ compatibility: >-
19
19
  this plugin; Codex/Kimi use command hooks; Hermes/OpenCode/OpenClaw/Pi use native context plugins after opt-in.
20
20
  metadata:
21
21
  author: PassionCode.ai
22
- version: "0.38.0"
22
+ version: "0.39.0"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -65,7 +65,7 @@ WATCH_MIN_INTERVAL_S = 10
65
65
  WATCH_MAX_BACKOFF_S = 900
66
66
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
67
67
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
68
- VERSION = "0.38.0"
68
+ VERSION = "0.39.0"
69
69
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
70
70
  "hermes": "Hermes", "opencode": "OpenCode", "openclaw": "OpenClaw", "pi": "Pi"}
71
71
  NATIVE_PLUGIN_HOSTS = frozenset(("hermes", "opencode", "openclaw", "pi"))
@@ -3,7 +3,7 @@
3
3
  "name": "observatory-log",
4
4
  "displayName": "Observatory Log",
5
5
  "description": "Records what changed in a watched project at the end of every turn, and asks for the one thing a diff cannot contain: why.",
6
- "version": "0.18.0",
6
+ "version": "0.26.0",
7
7
  "license": "AGPL-3.0-only OR LicenseRef-PassionCode-Commercial",
8
8
  "author": {
9
9
  "name": "PassionCode.ai",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Turn a record_turn.py result into the Stop hook's structured output.
2
+ """Turn a record_turn.py result into the request for a `why`, delivered with the next prompt.
3
3
 
4
4
  Standard library only, and silent on anything unexpected: a hook that reports
5
5
  its own confusion as a failure teaches the operator to disable hooks.
@@ -9,43 +9,45 @@ cooperation, so the only thing left to ask for is the `why` — and asking is
9
9
  worth more than a veto that costs a turn. An absent `why` stays visible in the
10
10
  ledger as a proposed record with a null field, which is accountability without
11
11
  obstruction.
12
+
13
+ **Once, where the model reads it.** This printed the ask as a Stop
14
+ `systemMessage` after every changed turn: measured over fourteen days of one
15
+ machine's transcripts, 3,593 asks for 34 answers. A Stop `systemMessage` is a
16
+ notice for the person, never rendered into the model's input, and a session is
17
+ ONE record revised per turn, so every ask after the first repeated the question.
18
+ Now the recorder decides (`ask` is false once a record was asked about, and when
19
+ the turn wrote its own rationale), and this hook leaves the ask in a private
20
+ per-session file that `prompt-why.py` hands to the model as the next prompt's
21
+ `additionalContext`, once. If that file cannot be written the ask falls back to
22
+ the old Stop notice: an ask is never lost to a full disk.
12
23
  """
13
24
  from __future__ import annotations
14
25
  import json
26
+ import os
27
+ import re
15
28
  import sys
29
+ import time
16
30
 
17
31
  OWNER = "agent:claude-code"
32
+ #: A session id names a file, so it is held to a shape that cannot leave the directory.
33
+ SESSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
34
+ #: An ask nobody collected — the session ended — is removed after this long.
35
+ PRUNE_SECONDS = 7 * 86400
36
+ #: A session's pending asks, newest kept: a record is asked about once, so more
37
+ #: than a few undelivered ones means the session is gone, not busy.
38
+ KEEP = 3
18
39
 
19
40
 
20
- def main() -> int:
21
- try:
22
- r = json.load(sys.stdin)
23
- except Exception:
24
- return 0
25
- if not r.get("recorded"):
26
- # A FAULT IS NOT A QUIET TURN. This returned 0 for every `recorded:
27
- # false`, and the recorder's reasons are two different kinds: "nothing
28
- # changed" is an answer about the work, while `IllegalTransition:
29
- # observed -> proposed` is the recorder failing. Measured 2026-09-07:
30
- # once `tools/corroborate.py` promoted a session's record, every later
31
- # turn of that session raised that error and said nothing — roughly
32
- # seventy-two turns in one session, and the only visible sign was a
33
- # ledger row that had stopped moving.
34
- #
35
- # The recorder marks which kind it is; this only has to stop hiding it.
36
- reason = r.get("reason") or ""
37
- if not r.get("fault"):
38
- return 0 # nothing changed, not watched, or explained
39
- print(json.dumps({"systemMessage":
40
- "Observatory could NOT record this turn: " + reason +
41
- "\n\nThe facts of this turn are not in the ledger. "
42
- "`store/raw/record-turn.json` holds the same reason, and "
43
- "`project-observatory full findings` raises it.",
44
- "suppressOutput": True}))
45
- return 0
46
- if r.get("hasWhy"):
47
- return 0 # already explained
41
+ def pending_dir() -> str:
42
+ """Where a session's undelivered ask waits; `prompt-why.py` computes the same."""
43
+ base = os.environ.get("XDG_STATE_HOME") or os.path.join(
44
+ os.path.expanduser("~"), ".local", "state")
45
+ return os.path.join(base, "project-observatory", "ask-why")
46
+
48
47
 
48
+ def message(r: dict, at_stop: bool = False) -> str:
49
+ """The ask. `at_stop` words it for a Stop notice, which follows the turn it is
50
+ about, rather than for the next prompt, which comes after it."""
49
51
  mid = r.get("memoryId")
50
52
  rev = r.get("revision")
51
53
  project = r.get("project", "this project")
@@ -53,11 +55,11 @@ def main() -> int:
53
55
  plural = "" if files == 1 else "s"
54
56
  unpushed = r.get("unpushed") or 0
55
57
  extra = f", {unpushed} unpushed commit(s)" if unpushed else ""
56
-
57
- message = (
58
- f"Observatory recorded the facts of this turn in {project}: "
58
+ text = (
59
+ f"Observatory recorded the facts of {'this' if at_stop else 'your previous'} turn in {project}: "
59
60
  f"{files} file{plural} changed, +{r.get('insertions', 0)}/-{r.get('deletions', 0)}"
60
- f"{extra}. Stored as {mid}@{rev}, state proposed, with no `why`.\n\n"
61
+ f"{extra}. Stored as {mid}@{rev}, state proposed, with no `why`."
62
+ + (" This record is asked about once." if r.get("ask") is True else "") + "\n\n"
61
63
  "The diff already says WHAT changed. Add WHY — one or two sentences a "
62
64
  "reader in six months could not reconstruct from the diff: the constraint "
63
65
  "that forced the shape, the alternative rejected, the trap avoided.\n\n"
@@ -66,6 +68,7 @@ def main() -> int:
66
68
  "If the change genuinely needs no explanation — a typo, a rename — say so "
67
69
  "and skip it. An empty `why` on a trivial edit is honest; an invented one "
68
70
  "is worse than nothing, because it will be read as true."
71
+ + ("" if at_stop else " Then answer the user's new message.")
69
72
  )
70
73
  # THE NUDGE AT THE MOMENT OF WORK. The agent is already inside this project;
71
74
  # rebuilding its graph or touching its note costs least right now. Thresholds
@@ -82,9 +85,111 @@ def main() -> int:
82
85
  f"work changed what the project IS (modules, scope, "
83
86
  f"stack), update the overview note too")
84
87
  if stale_bits:
85
- message += ("\n\nWhile you are here — this project's recorded knowledge "
86
- "is behind its code:\n - " + "\n - ".join(stale_bits))
87
- print(json.dumps({"systemMessage": message, "suppressOutput": True},
88
+ text += ("\n\nWhile you are here — this project's recorded knowledge "
89
+ "is behind its code:\n - " + "\n - ".join(stale_bits))
90
+ return text
91
+
92
+
93
+ def leave_for_next_prompt(session: str, r: dict, text: str) -> bool:
94
+ """Write the ask where `prompt-why.py` finds it. False when it could not be kept."""
95
+ if not isinstance(session, str) or not SESSION.fullmatch(session):
96
+ return False
97
+ try:
98
+ d = pending_dir()
99
+ os.makedirs(d, mode=0o700, exist_ok=True)
100
+ os.chmod(d, 0o700)
101
+ now = time.time()
102
+ for name in os.listdir(d): # sessions that never prompted again
103
+ p = os.path.join(d, name)
104
+ try:
105
+ if now - os.stat(p).st_mtime > PRUNE_SECONDS:
106
+ os.unlink(p)
107
+ except OSError:
108
+ pass
109
+ path = os.path.join(d, session + ".json")
110
+ asks = []
111
+ try:
112
+ with open(path, encoding="utf-8") as fh:
113
+ asks = [a for a in (json.load(fh).get("asks") or []) if isinstance(a, dict)]
114
+ except (OSError, ValueError, AttributeError):
115
+ asks = []
116
+ asks = [a for a in asks if a.get("memoryId") != r.get("memoryId")]
117
+ entry = {"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
118
+ "memoryId": r.get("memoryId"), "revision": r.get("revision"),
119
+ "context": text}
120
+ # Where `prompt-why.py` records the delivery: the engine's journal, named by
121
+ # the recorder. Only a file of that exact name is ever written there.
122
+ journal = r.get("journal")
123
+ if isinstance(journal, str) and os.path.basename(journal) == "why-asks.jsonl":
124
+ entry["journal"] = journal
125
+ asks.append(entry)
126
+ tmp = f"{path}.{os.getpid()}.tmp"
127
+ # Created owner-only by the umask, not by a raw `os.open`: this script runs
128
+ # from the plugin's own copy without the engine, so `osprivacy` (the one
129
+ # module allowed to pass open flags) is out of reach, and the hook is one
130
+ # single-threaded process, so the umask is its own.
131
+ mask = os.umask(0o077)
132
+ try:
133
+ with open(tmp, "w", encoding="utf-8") as fh:
134
+ json.dump({"asks": asks[-KEEP:]}, fh, ensure_ascii=False)
135
+ finally:
136
+ os.umask(mask)
137
+ os.chmod(tmp, 0o600)
138
+ os.replace(tmp, path)
139
+ return True
140
+ except Exception: # noqa: BLE001
141
+ return False
142
+
143
+
144
+ def main() -> int:
145
+ try:
146
+ r = json.load(sys.stdin)
147
+ except Exception:
148
+ return 0
149
+ if not isinstance(r, dict):
150
+ return 0
151
+ if not r.get("recorded"):
152
+ # A FAULT IS NOT A QUIET TURN. This returned 0 for every `recorded:
153
+ # false`, and the recorder's reasons are two different kinds: "nothing
154
+ # changed" is an answer about the work, while `IllegalTransition:
155
+ # observed -> proposed` is the recorder failing. Measured 2026-09-07:
156
+ # once `tools/corroborate.py` promoted a session's record, every later
157
+ # turn of that session raised that error and said nothing — roughly
158
+ # seventy-two turns in one session, and the only visible sign was a
159
+ # ledger row that had stopped moving.
160
+ #
161
+ # The recorder marks which kind it is; this only has to stop hiding it.
162
+ # A fault stays a Stop notice: it is for the person, and it is urgent.
163
+ reason = r.get("reason") or ""
164
+ if not r.get("fault"):
165
+ return 0 # nothing changed, not watched, or explained
166
+ print(json.dumps({"systemMessage":
167
+ "Observatory could NOT record this turn: " + reason +
168
+ "\n\nThe facts of this turn are not in the ledger. "
169
+ "`store/raw/record-turn.json` holds the same reason, and "
170
+ "`project-observatory full findings` raises it.",
171
+ "suppressOutput": True}))
172
+ return 0
173
+ if r.get("hasWhy"):
174
+ return 0 # already explained
175
+ if r.get("rationale"):
176
+ return 0 # the turn wrote its rationale; the record links that file
177
+ if "ask" not in r:
178
+ # AN OLDER ENGINE, which decides nothing about asking: it would send this
179
+ # every changed turn, and an every-turn ask injected into the model's context
180
+ # is worse than the notice it replaces. So it gets the old Stop notice.
181
+ print(json.dumps({"systemMessage": message(r, at_stop=True), "suppressOutput": True},
182
+ ensure_ascii=False))
183
+ return 0
184
+ if r.get("ask") is not True:
185
+ return 0 # this record was asked about already
186
+
187
+ text = message(r)
188
+ if leave_for_next_prompt(r.get("session"), r, text):
189
+ return 0
190
+ # THE OLD CHANNEL, as the fallback. A notice the model may not read is better
191
+ # than an ask that silently never happens.
192
+ print(json.dumps({"systemMessage": message(r, at_stop=True), "suppressOutput": True},
88
193
  ensure_ascii=False))
89
194
  return 0
90
195
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Optional Claude Code hooks: record changes in watched Git projects and request missing reasoning. SessionStart reports brief actionable context. Missing workspace, interpreter or watched project degrades quietly; hooks do not block a turn. Project names and paths stay in the private workspace.",
2
+ "description": "Optional Claude Code hooks: record changes in watched Git projects and request missing reasoning. SessionStart reports brief actionable context. Stop records the facts of a turn that changed something since the previous turn first, counting only what differs from the session's baseline (taken at SessionStart; files untracked before the session never count); then, within 2 seconds, it reads the last 4 MB of the session transcript once for this session's writing calls (Edit, Write, MultiEdit, NotebookEdit, and Bash writes through >, >>, tee or sed -i; failed calls, subagent calls and commands over 65,536 characters are skipped). It leaves a request for the why at most once per record, only when the turn changed tracked content, none while a revision's rationale link is still true (same repository, a regular file in the checkout with one hard link and no symbolic link, the exact heading line present once and the file unchanged or with only a new entry appended after it; any other edit asks again), and none when such a call targeted a decisions.md or an adr/ .md, .markdown or .txt file (outside vendored trees) with text carrying the entry's own heading (unique in the register) or, with no heading, most of its added lines, or wrote a decision: or stage: line into .task-pipeline/run.md. UserPromptSubmit hands that pending request to the model with the next prompt, once, within 4,000 characters, records the delivery, and prints nothing when none is pending. Missing workspace, interpreter or watched project degrades quietly; hooks do not block a turn or a prompt. Project names and paths stay in the private workspace.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -26,6 +26,18 @@
26
26
  }
27
27
  ]
28
28
  }
29
+ ],
30
+ "UserPromptSubmit": [
31
+ {
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "shell": "bash",
36
+ "command": "py=\"${OBSERVATORY_PYTHON:-}\"; [ -x \"$py\" ] || py=python3; \"$py\" \"${CLAUDE_PLUGIN_ROOT}/hooks/prompt-why.py\" 2>/dev/null; exit 0",
37
+ "timeout": 5
38
+ }
39
+ ]
40
+ }
29
41
  ]
30
42
  }
31
43
  }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """Hand the model the request for a `why` that the Stop hook left for this session.
3
+
4
+ A UserPromptSubmit hook, so it runs before EVERY prompt of every session, and the
5
+ whole design is about that cost:
6
+
7
+ - **Silent unless an ask is pending.** One `stat` of a per-session file and exit 0;
8
+ the file exists only after `ask-why.py` asked about a record, at most once per
9
+ record (`why_asks.py` in the engine).
10
+ - **Once.** The file is taken by an atomic rename before it is read, so two prompts
11
+ cannot both deliver it, and it is deleted after.
12
+ - **Never in the way of the prompt.** Every error — a payload that is not JSON, a
13
+ session id that is not a plain name, a torn file — is exit 0 with nothing on
14
+ stdout. It cannot block: it prints no `decision`, and it exits 0 on every path.
15
+ - **Delivery is recorded.** For each ask handed over it appends
16
+ `{"event": "delivered"}` to the engine's journal (`store/raw/why-asks.jsonl`, the
17
+ path the recorder named), because the answer rate is measured on delivered asks: a
18
+ headless session that never sends a second prompt never receives its ask, and must
19
+ not count as having ignored it.
20
+ - **Where the model reads it.** The ask goes out as
21
+ `hookSpecificOutput.additionalContext`, which Claude Code renders into the
22
+ model's input; the Stop `systemMessage` it replaces is a notice for the person
23
+ (`tests/test_ask_why_reach.py`).
24
+
25
+ It holds no secret: the pending file carries the ask's text — a project id, a
26
+ record id and a diff's size — and is written 0600 in a 0700 directory.
27
+ Standard library only; it does not need the engine.
28
+ """
29
+ from __future__ import annotations
30
+ import calendar
31
+ import json
32
+ import os
33
+ import re
34
+ import sys
35
+ import time
36
+
37
+ SESSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
38
+ #: An ask older than this is about a turn too far back to explain well; it is dropped.
39
+ STALE_SECONDS = 7 * 86400
40
+ #: The context a prompt can be handed, at most. One ask is about a thousand characters.
41
+ MAX_CHARS = 4000
42
+
43
+
44
+ def pending_dir() -> str:
45
+ """The directory `ask-why.py` writes; the two must agree."""
46
+ base = os.environ.get("XDG_STATE_HOME") or os.path.join(
47
+ os.path.expanduser("~"), ".local", "state")
48
+ return os.path.join(base, "project-observatory", "ask-why")
49
+
50
+
51
+ SEPARATOR = "\n\n---\n\n"
52
+
53
+
54
+ def _put_back(path: str, asks: list) -> None:
55
+ """Return undelivered asks to the session's pending file, beside any the Stop hook
56
+ wrote meanwhile. Owner-only, like the file `ask-why.py` writes."""
57
+ try:
58
+ try:
59
+ with open(path, encoding="utf-8") as fh:
60
+ current = [a for a in (json.load(fh).get("asks") or []) if isinstance(a, dict)]
61
+ except (OSError, ValueError, AttributeError):
62
+ current = []
63
+ ids = {a.get("memoryId") for a in current}
64
+ merged = [a for a in asks if a.get("memoryId") not in ids] + current
65
+ tmp = f"{path}.{os.getpid()}.back"
66
+ mask = os.umask(0o077)
67
+ try:
68
+ with open(tmp, "w", encoding="utf-8") as fh:
69
+ json.dump({"asks": merged}, fh, ensure_ascii=False)
70
+ finally:
71
+ os.umask(mask)
72
+ os.replace(tmp, path)
73
+ except Exception: # noqa: BLE001
74
+ pass # an ask that is not put back stays pending, unasked
75
+
76
+
77
+ def _record_delivery(ask: dict, session: str) -> None:
78
+ """Append a `delivered` row to the journal the recorder named. Never raises."""
79
+ journal = ask.get("journal")
80
+ if not isinstance(journal, str) or os.path.basename(journal) != "why-asks.jsonl" \
81
+ or os.path.islink(journal) or not os.path.isdir(os.path.dirname(journal) or "/nonexistent"):
82
+ return
83
+ row = {"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
84
+ "memoryId": ask.get("memoryId"), "event": "delivered", "session": session}
85
+ if not isinstance(row["memoryId"], str):
86
+ return
87
+ try:
88
+ with open(journal, "a", encoding="utf-8") as fh:
89
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
90
+ except OSError:
91
+ pass # an undelivered count is a floor, not a failed prompt
92
+
93
+
94
+ def _prune(directory: str) -> None:
95
+ """Remove pending asks older than STALE_SECONDS: sessions that ended without a
96
+ further prompt. Run only after a delivery, so a quiet prompt stays one `stat`."""
97
+ try:
98
+ now = time.time()
99
+ for name in os.listdir(directory):
100
+ path = os.path.join(directory, name)
101
+ try:
102
+ if now - os.stat(path).st_mtime > STALE_SECONDS:
103
+ os.unlink(path)
104
+ except OSError:
105
+ pass
106
+ except OSError:
107
+ pass
108
+
109
+
110
+ def _fresh(stamp) -> bool:
111
+ try:
112
+ when = calendar.timegm(time.strptime(str(stamp), "%Y-%m-%dT%H:%M:%SZ"))
113
+ except (ValueError, OverflowError):
114
+ return True # an odd stamp still delivers
115
+ return time.time() - when <= STALE_SECONDS
116
+
117
+
118
+ def main() -> int:
119
+ try:
120
+ payload = json.load(sys.stdin)
121
+ except Exception:
122
+ return 0
123
+ session = payload.get("session_id") if isinstance(payload, dict) else None
124
+ if not isinstance(session, str) or not SESSION.fullmatch(session):
125
+ return 0
126
+ path = os.path.join(pending_dir(), session + ".json")
127
+ if not os.path.isfile(path):
128
+ return 0 # the common case: nothing pending
129
+ taking = f"{path}.taking-{os.getpid()}"
130
+ try:
131
+ os.replace(path, taking) # one prompt takes it, not two
132
+ except OSError:
133
+ return 0
134
+ try:
135
+ with open(taking, encoding="utf-8") as fh:
136
+ doc = json.load(fh)
137
+ except Exception:
138
+ doc = {}
139
+ finally:
140
+ try:
141
+ os.unlink(taking)
142
+ except OSError:
143
+ pass
144
+ _prune(os.path.dirname(path))
145
+ asks = doc.get("asks") if isinstance(doc, dict) else None
146
+ chosen = [a for a in (asks or [])
147
+ if isinstance(a, dict) and isinstance(a.get("context"), str)
148
+ and a["context"].strip() and _fresh(a.get("at"))]
149
+ if not chosen:
150
+ return 0
151
+ # THE CAP, honestly counted. Only asks that fit inside MAX_CHARS whole are handed
152
+ # over and marked delivered; the rest go back for the next prompt. The first ask
153
+ # always goes (cut at the cap if it alone is longer: its record id and the call
154
+ # to make come first).
155
+ sent, rest, used = [], [], 0
156
+ for a in chosen:
157
+ cost = len(a["context"]) + (len(SEPARATOR) if sent else 0)
158
+ if sent and used + cost > MAX_CHARS:
159
+ rest.append(a)
160
+ continue
161
+ sent.append(a)
162
+ used += cost
163
+ if rest:
164
+ _put_back(path, rest)
165
+ context = SEPARATOR.join(a["context"] for a in sent)[:MAX_CHARS]
166
+ for a in sent:
167
+ _record_delivery(a, session)
168
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
169
+ "additionalContext": context}},
170
+ ensure_ascii=False))
171
+ return 0
172
+
173
+
174
+ if __name__ == "__main__":
175
+ try:
176
+ raise SystemExit(main())
177
+ except SystemExit:
178
+ raise
179
+ except Exception:
180
+ raise SystemExit(0)
@@ -52,29 +52,45 @@ py="${OBSERVATORY_PYTHON:-$root/.venv/bin/python}" # set by `full agent instal
52
52
  [ -x "$py" ] || py="$(command -v python3 2>/dev/null)"
53
53
  [ -n "$py" ] || exit 0 # no interpreter: silent
54
54
 
55
- read_field() { # read_field <key>
55
+ # ONE PARSE of the payload, not one interpreter start per field: four starts cost
56
+ # 0.1-0.4 s of every turn's Stop before the recorder ran. A value holding a line
57
+ # break is dropped rather than split across the fields that follow it.
58
+ read_fields() { # stop_hook_active, cwd, session_id, transcript_path
56
59
  [ -n "$payload" ] || return 0
57
60
  printf '%s' "$payload" | "$py" -c '
58
61
  import json, sys
59
62
  try:
60
63
  d = json.load(sys.stdin)
61
64
  except Exception:
62
- sys.exit(0)
63
- print(d.get(sys.argv[1]) or "")
64
- ' "$1" 2>/dev/null
65
+ d = {}
66
+ if not isinstance(d, dict):
67
+ d = {}
68
+ for k in ("stop_hook_active", "cwd", "session_id", "transcript_path"):
69
+ v = d.get(k)
70
+ v = "" if v is None or v is False else str(v)
71
+ print("" if ("\n" in v or "\r" in v) else v)
72
+ ' 2>/dev/null
65
73
  }
74
+ fields="$(read_fields)"
75
+ active=""; cwd=""; session=""; transcript=""
76
+ { IFS= read -r active; IFS= read -r cwd; IFS= read -r session; IFS= read -r transcript; } <<EOF_FIELDS || true
77
+ $fields
78
+ EOF_FIELDS
79
+
80
+ [ "$active" = "True" ] && exit 0
81
+ [ "$active" = "true" ] && exit 0
82
+
83
+ [ -n "$cwd" ] || cwd="${CLAUDE_PROJECT_DIR:-$PWD}"
84
+ # `transcript` is the session's own transcript, so a register another session is
85
+ # editing in the same checkout does not silence this session's ask. Passed only to a
86
+ # recorder that reads it: an older engine's argument parser would refuse the flag
87
+ # and record nothing.
88
+ extra=()
89
+ if [ -n "$transcript" ] && [ -f "$transcript" ] && grep -q -- '--transcript' "$root/tools/record_turn.py" 2>/dev/null; then
90
+ extra=(--transcript "$transcript")
91
+ fi
66
92
 
67
-
68
-
69
-
70
- [ "$(read_field stop_hook_active)" = "True" ] && exit 0
71
- [ "$(read_field stop_hook_active)" = "true" ] && exit 0
72
-
73
- cwd="$(read_field cwd)"; [ -n "$cwd" ] || cwd="${CLAUDE_PROJECT_DIR:-$PWD}"
74
- session="$(read_field session_id)"
75
-
76
-
77
- result="$("$py" "$root/tools/record_turn.py" --cwd "$cwd" --session-id "$session" 2>/dev/null)"
93
+ result="$("$py" "$root/tools/record_turn.py" --cwd "$cwd" --session-id "$session" ${extra[@]+"${extra[@]}"} 2>/dev/null)"
78
94
  [ -n "$result" ] || exit 0
79
95
 
80
96
  printf '%s' "$result" | "$py" "${CLAUDE_PLUGIN_ROOT:-$(dirname "$0")/..}/hooks/ask-why.py" 2>/dev/null