@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.
Files changed (25) 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/hooks/hooks.json +3 -2
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  9. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -3
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +6 -3
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +13 -12
  12. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  13. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +5 -4
  14. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +9 -3
  15. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +99 -35
  16. package/payload/plugins/observatory-log/.claude-plugin/plugin.json +1 -1
  17. package/payload/plugins/observatory-log/hooks/ask-why.py +141 -36
  18. package/payload/plugins/observatory-log/hooks/hooks.json +13 -1
  19. package/payload/plugins/observatory-log/hooks/prompt-why.py +180 -0
  20. package/payload/plugins/observatory-log/hooks/record-turn.sh +31 -15
  21. package/payload/plugins/observatory-log/hooks/session-start.sh +10 -4
  22. package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +16 -5
  23. package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +83 -13
  24. package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +1 -1
  25. 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 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
@@ -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 (`updates.auto` false), install it with `full update --apply`.
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
- off="$("$py" -c "import json,sys
100
- try: d=json.load(open(sys.argv[1]))
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/config/settings.json" 2>/dev/null)"
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.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
- and printed its id. Correct that record rather than creating a second one:
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 printed>",
58
- expected_revision=<the revision it printed>,
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
  )