@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.
- package/CHANGELOG.md +20 -0
- package/family.json +2 -2
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +3 -3
- package/payload/manifest.json +12 -12
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +8 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +9 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +4 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +7 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +33 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +12 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +16 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +32 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +18 -4
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
- package/payload/plugins/observatory-log/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/observatory-log/hooks/ask-why.py +141 -36
- package/payload/plugins/observatory-log/hooks/hooks.json +13 -1
- package/payload/plugins/observatory-log/hooks/prompt-why.py +180 -0
- package/payload/plugins/observatory-log/hooks/record-turn.sh +31 -15
- package/payload/plugins/observatory-log/hooks/session-start.sh +10 -4
- package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +16 -5
- package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +83 -13
- package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +1 -1
- 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
|
|
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.
|
|
8
|
+
version: "0.39.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
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 `
|
|
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
|
|
package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|