@passioncode-ai/passioncode 0.2.14 → 0.2.16
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/hooks/hooks.json +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +6 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +13 -12
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +9 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +99 -35
- 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
|
@@ -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
|
|
@@ -88,7 +88,8 @@ if [ -f "$root/tools/maintain.py" ]; then
|
|
|
88
88
|
else
|
|
89
89
|
# The bridge for engines from before 0.17.0, which carry no updater of their own:
|
|
90
90
|
# once a day, check; when a newer stable release exists and the person has not turned
|
|
91
|
-
# automatic updates off (`
|
|
91
|
+
# automatic updates off (the `auto-update` file says `off`, or `updates.auto` is false),
|
|
92
|
+
# install it with `full update --apply`.
|
|
92
93
|
po=""
|
|
93
94
|
[ -n "$venv" ] && [ -x "$venv/bin/project-observatory" ] && po="$venv/bin/project-observatory"
|
|
94
95
|
[ -n "$po" ] || po="$(command -v project-observatory 2>/dev/null)"
|
|
@@ -96,10 +97,15 @@ else
|
|
|
96
97
|
detach "$stamps/update-bridge.log" /bin/bash -c '
|
|
97
98
|
po="$1"; py="$2"
|
|
98
99
|
home="${OBSERVATORY_FULL_HOME:-${OBSERVATORY_HOME:-$HOME/.local/share/project-observatory-full}}"
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
# The switch (LC-16): the file `auto-update` in the home, where only `off` is off;
|
|
101
|
+
# without one, `updates.auto: false` in settings.json, as those releases wrote it.
|
|
102
|
+
off="$("$py" -c "import json,os,sys
|
|
103
|
+
f=os.path.join(sys.argv[1], \"auto-update\")
|
|
104
|
+
if os.path.isfile(f) and not os.path.islink(f):
|
|
105
|
+
print(1 if open(f, errors=\"replace\").read(64).strip().lower() == \"off\" else 0); sys.exit()
|
|
106
|
+
try: d=json.load(open(os.path.join(sys.argv[1], \"config\", \"settings.json\")))
|
|
101
107
|
except Exception: d={}
|
|
102
|
-
print(1 if (d.get(\"updates\") or {}).get(\"auto\") is False else 0)" "$home
|
|
108
|
+
print(1 if (d.get(\"updates\") or {}).get(\"auto\") is False else 0)" "$home" 2>/dev/null)"
|
|
103
109
|
[ "$off" = "1" ] && { echo "$(date -u +%FT%TZ) automatic updates are off"; exit 0; }
|
|
104
110
|
"$po" full update --check >/dev/null 2>&1; rc=$?
|
|
105
111
|
echo "$(date -u +%FT%TZ) check exit $rc"
|
|
@@ -15,7 +15,7 @@ description: >-
|
|
|
15
15
|
decision with different evidence.
|
|
16
16
|
license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
17
17
|
metadata:
|
|
18
|
-
version: "0.
|
|
18
|
+
version: "0.26.0"
|
|
19
19
|
compatibility: >-
|
|
20
20
|
Requires an initialized full Project Observatory installation to persist
|
|
21
21
|
records. Uses its MCP server when available in the current host; the bundled
|
|
@@ -48,14 +48,25 @@ and nothing in the ledger can tell it from a measured one.
|
|
|
48
48
|
|
|
49
49
|
## Recording it
|
|
50
50
|
|
|
51
|
-
The Observatory's Stop hook has already stored the facts as a `proposed` record
|
|
52
|
-
|
|
51
|
+
The Observatory's Stop hook has already stored the facts as a `proposed` record.
|
|
52
|
+
Its id arrives once, as context with the next prompt. There is no ask when, within
|
|
53
|
+
2 seconds of the record being written, the hook finds in the last 4 MB of this
|
|
54
|
+
session's transcript an Edit, Write, MultiEdit, NotebookEdit or Bash write (`>`,
|
|
55
|
+
`>>`, `tee`, `sed -i`) that targeted a `decisions.md` or an `adr/` `.md`,
|
|
56
|
+
`.markdown` or `.txt` file (outside vendored trees) with the entry's own
|
|
57
|
+
heading (unique in the register) or, with no heading, most of its added lines, or that wrote a `decision:` /
|
|
58
|
+
`stage:` line into `.task-pipeline/run.md`. Failed calls, subagent calls and
|
|
59
|
+
Bash commands over 65,536 characters are not counted. The record then links
|
|
60
|
+
that file, and keeps the link while the entry's exact heading line occurs once
|
|
61
|
+
and the file is unchanged, or has only a new entry appended after it; any other
|
|
62
|
+
edit to the file asks again.
|
|
63
|
+
Correct that record rather than creating a second one:
|
|
53
64
|
|
|
54
65
|
```
|
|
55
66
|
observatory_record(
|
|
56
67
|
owner="agent:claude-code",
|
|
57
|
-
memory_id="<the id the hook
|
|
58
|
-
expected_revision=<the revision it
|
|
68
|
+
memory_id="<the id the hook gave>",
|
|
69
|
+
expected_revision=<the revision it gave>,
|
|
59
70
|
statement="<the same claim, unchanged>",
|
|
60
71
|
why="<one or two sentences>"
|
|
61
72
|
)
|