@passioncode-ai/passioncode 0.2.15 → 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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.16 - 2026-10-20
4
+
5
+ ### Changed
6
+
7
+ - **Observatory Log is pinned at `v0.26.0`** (plugin 0.26.0; it was `v0.18.0`). Its Stop hook records and asks about
8
+ what a turn changed, not what the working tree already held: files untracked before the session never count, and
9
+ the `why` is asked only for tracked content, once per record, with the session's next prompt (a new
10
+ `UserPromptSubmit` hook) instead of a Stop notice. The hook parses its payload once instead of starting Python
11
+ four times per turn (PC-05, audit F12). The tag's plugin version and the release version are the same, so
12
+ `family.json` still declares no `refVersions` (PC-04).
13
+
3
14
  ## 0.2.15 - 2026-10-20
4
15
 
5
16
  ### Changed
package/family.json CHANGED
@@ -22,7 +22,7 @@
22
22
  "name": "observatory-log",
23
23
  "displayName": "Observatory Log",
24
24
  "repo": "passioncode-ai/project-observatory-dashboard",
25
- "ref": "v0.18.0",
25
+ "ref": "v0.26.0",
26
26
  "kind": "plugin",
27
27
  "path": "observatory/engine/skill/plugins/observatory-log",
28
28
  "legacyPluginIds": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Set up PassionCode.ai on a computer with one command — the apps from their verified releases, their MCP servers for every coding agent and the PassionCode.ai skills — and check and repair it with the same command or `doctor`.",
5
5
  "bin": {
6
6
  "passioncode": "bin/passioncode.js"
@@ -23,7 +23,7 @@
23
23
  "name": "observatory-log",
24
24
  "displayName": "Observatory Log",
25
25
  "source": "./plugins/observatory-log",
26
- "version": "0.18.0",
26
+ "version": "0.26.0",
27
27
  "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.",
28
28
  "author": {
29
29
  "name": "PassionCode.ai",
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.2.15",
38
+ "version": "0.2.16",
39
39
  "description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
40
40
  "author": {
41
41
  "name": "PassionCode.ai",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
@@ -35,9 +35,9 @@
35
35
  "name": "observatory-log",
36
36
  "displayName": "Observatory Log",
37
37
  "repo": "passioncode-ai/project-observatory-dashboard",
38
- "ref": "v0.18.0",
39
- "commit": "4c68decefd213ccbb808a6f0ed5dfe72c1760d58",
40
- "version": "0.18.0",
38
+ "ref": "v0.26.0",
39
+ "commit": "83ae56822f219898d4fba78b988924149f0556b8",
40
+ "version": "0.26.0",
41
41
  "via": "clone of https://***@github.com/passioncode-ai/project-observatory-dashboard.git",
42
42
  "skills": [
43
43
  "explaining-changes",
@@ -50,7 +50,7 @@
50
50
  "legacyMarketplaces": [
51
51
  "observatory-log"
52
52
  ],
53
- "contentHash": "sha256:3e93bbc01672e5595f5ba95412b4cc7eed633f559a121ec3f9908efe12cf4f59",
53
+ "contentHash": "sha256:f610348628dd39b97aa6ee6f5265e6fe8d737856b29ea6f74213bf8278f1af73",
54
54
  "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.",
55
55
  "author": {
56
56
  "name": "PassionCode.ai",
@@ -62,9 +62,9 @@
62
62
  "name": "passioncode",
63
63
  "displayName": "PassionCode.ai",
64
64
  "repo": "passioncode-ai/passioncode",
65
- "ref": "v0.2.15",
65
+ "ref": "v0.2.16",
66
66
  "commit": null,
67
- "version": "0.2.15",
67
+ "version": "0.2.16",
68
68
  "via": "this repository",
69
69
  "skills": [
70
70
  "setting-up-passioncode",
@@ -72,7 +72,7 @@
72
72
  ],
73
73
  "legacyPluginIds": [],
74
74
  "legacyMarketplaces": [],
75
- "contentHash": "sha256:02ba7538f39282bfa74be9c24814737f64790a220764349e84502666b006b1e0",
75
+ "contentHash": "sha256:402f4ab121c4b1e9e81d23c1bf4cdc7178933e720621c51723cd32a937009719",
76
76
  "description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
77
77
  "author": {
78
78
  "name": "PassionCode.ai",
@@ -3,7 +3,7 @@
3
3
  "name": "observatory-log",
4
4
  "displayName": "Observatory Log",
5
5
  "description": "Records what changed in a watched project at the end of every turn, and asks for the one thing a diff cannot contain: why.",
6
- "version": "0.18.0",
6
+ "version": "0.26.0",
7
7
  "license": "AGPL-3.0-only OR LicenseRef-PassionCode-Commercial",
8
8
  "author": {
9
9
  "name": "PassionCode.ai",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Turn a record_turn.py result into the Stop hook's structured output.
2
+ """Turn a record_turn.py result into the request for a `why`, delivered with the next prompt.
3
3
 
4
4
  Standard library only, and silent on anything unexpected: a hook that reports
5
5
  its own confusion as a failure teaches the operator to disable hooks.
@@ -9,43 +9,45 @@ cooperation, so the only thing left to ask for is the `why` — and asking is
9
9
  worth more than a veto that costs a turn. An absent `why` stays visible in the
10
10
  ledger as a proposed record with a null field, which is accountability without
11
11
  obstruction.
12
+
13
+ **Once, where the model reads it.** This printed the ask as a Stop
14
+ `systemMessage` after every changed turn: measured over fourteen days of one
15
+ machine's transcripts, 3,593 asks for 34 answers. A Stop `systemMessage` is a
16
+ notice for the person, never rendered into the model's input, and a session is
17
+ ONE record revised per turn, so every ask after the first repeated the question.
18
+ Now the recorder decides (`ask` is false once a record was asked about, and when
19
+ the turn wrote its own rationale), and this hook leaves the ask in a private
20
+ per-session file that `prompt-why.py` hands to the model as the next prompt's
21
+ `additionalContext`, once. If that file cannot be written the ask falls back to
22
+ the old Stop notice: an ask is never lost to a full disk.
12
23
  """
13
24
  from __future__ import annotations
14
25
  import json
26
+ import os
27
+ import re
15
28
  import sys
29
+ import time
16
30
 
17
31
  OWNER = "agent:claude-code"
32
+ #: A session id names a file, so it is held to a shape that cannot leave the directory.
33
+ SESSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
34
+ #: An ask nobody collected — the session ended — is removed after this long.
35
+ PRUNE_SECONDS = 7 * 86400
36
+ #: A session's pending asks, newest kept: a record is asked about once, so more
37
+ #: than a few undelivered ones means the session is gone, not busy.
38
+ KEEP = 3
18
39
 
19
40
 
20
- def main() -> int:
21
- try:
22
- r = json.load(sys.stdin)
23
- except Exception:
24
- return 0
25
- if not r.get("recorded"):
26
- # A FAULT IS NOT A QUIET TURN. This returned 0 for every `recorded:
27
- # false`, and the recorder's reasons are two different kinds: "nothing
28
- # changed" is an answer about the work, while `IllegalTransition:
29
- # observed -> proposed` is the recorder failing. Measured 2026-09-07:
30
- # once `tools/corroborate.py` promoted a session's record, every later
31
- # turn of that session raised that error and said nothing — roughly
32
- # seventy-two turns in one session, and the only visible sign was a
33
- # ledger row that had stopped moving.
34
- #
35
- # The recorder marks which kind it is; this only has to stop hiding it.
36
- reason = r.get("reason") or ""
37
- if not r.get("fault"):
38
- return 0 # nothing changed, not watched, or explained
39
- print(json.dumps({"systemMessage":
40
- "Observatory could NOT record this turn: " + reason +
41
- "\n\nThe facts of this turn are not in the ledger. "
42
- "`store/raw/record-turn.json` holds the same reason, and "
43
- "`project-observatory full findings` raises it.",
44
- "suppressOutput": True}))
45
- return 0
46
- if r.get("hasWhy"):
47
- return 0 # already explained
41
+ def pending_dir() -> str:
42
+ """Where a session's undelivered ask waits; `prompt-why.py` computes the same."""
43
+ base = os.environ.get("XDG_STATE_HOME") or os.path.join(
44
+ os.path.expanduser("~"), ".local", "state")
45
+ return os.path.join(base, "project-observatory", "ask-why")
46
+
48
47
 
48
+ def message(r: dict, at_stop: bool = False) -> str:
49
+ """The ask. `at_stop` words it for a Stop notice, which follows the turn it is
50
+ about, rather than for the next prompt, which comes after it."""
49
51
  mid = r.get("memoryId")
50
52
  rev = r.get("revision")
51
53
  project = r.get("project", "this project")
@@ -53,11 +55,11 @@ def main() -> int:
53
55
  plural = "" if files == 1 else "s"
54
56
  unpushed = r.get("unpushed") or 0
55
57
  extra = f", {unpushed} unpushed commit(s)" if unpushed else ""
56
-
57
- message = (
58
- f"Observatory recorded the facts of this turn in {project}: "
58
+ text = (
59
+ f"Observatory recorded the facts of {'this' if at_stop else 'your previous'} turn in {project}: "
59
60
  f"{files} file{plural} changed, +{r.get('insertions', 0)}/-{r.get('deletions', 0)}"
60
- f"{extra}. Stored as {mid}@{rev}, state proposed, with no `why`.\n\n"
61
+ f"{extra}. Stored as {mid}@{rev}, state proposed, with no `why`."
62
+ + (" This record is asked about once." if r.get("ask") is True else "") + "\n\n"
61
63
  "The diff already says WHAT changed. Add WHY — one or two sentences a "
62
64
  "reader in six months could not reconstruct from the diff: the constraint "
63
65
  "that forced the shape, the alternative rejected, the trap avoided.\n\n"
@@ -66,6 +68,7 @@ def main() -> int:
66
68
  "If the change genuinely needs no explanation — a typo, a rename — say so "
67
69
  "and skip it. An empty `why` on a trivial edit is honest; an invented one "
68
70
  "is worse than nothing, because it will be read as true."
71
+ + ("" if at_stop else " Then answer the user's new message.")
69
72
  )
70
73
  # THE NUDGE AT THE MOMENT OF WORK. The agent is already inside this project;
71
74
  # rebuilding its graph or touching its note costs least right now. Thresholds
@@ -82,9 +85,111 @@ def main() -> int:
82
85
  f"work changed what the project IS (modules, scope, "
83
86
  f"stack), update the overview note too")
84
87
  if stale_bits:
85
- message += ("\n\nWhile you are here — this project's recorded knowledge "
86
- "is behind its code:\n - " + "\n - ".join(stale_bits))
87
- print(json.dumps({"systemMessage": message, "suppressOutput": True},
88
+ text += ("\n\nWhile you are here — this project's recorded knowledge "
89
+ "is behind its code:\n - " + "\n - ".join(stale_bits))
90
+ return text
91
+
92
+
93
+ def leave_for_next_prompt(session: str, r: dict, text: str) -> bool:
94
+ """Write the ask where `prompt-why.py` finds it. False when it could not be kept."""
95
+ if not isinstance(session, str) or not SESSION.fullmatch(session):
96
+ return False
97
+ try:
98
+ d = pending_dir()
99
+ os.makedirs(d, mode=0o700, exist_ok=True)
100
+ os.chmod(d, 0o700)
101
+ now = time.time()
102
+ for name in os.listdir(d): # sessions that never prompted again
103
+ p = os.path.join(d, name)
104
+ try:
105
+ if now - os.stat(p).st_mtime > PRUNE_SECONDS:
106
+ os.unlink(p)
107
+ except OSError:
108
+ pass
109
+ path = os.path.join(d, session + ".json")
110
+ asks = []
111
+ try:
112
+ with open(path, encoding="utf-8") as fh:
113
+ asks = [a for a in (json.load(fh).get("asks") or []) if isinstance(a, dict)]
114
+ except (OSError, ValueError, AttributeError):
115
+ asks = []
116
+ asks = [a for a in asks if a.get("memoryId") != r.get("memoryId")]
117
+ entry = {"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
118
+ "memoryId": r.get("memoryId"), "revision": r.get("revision"),
119
+ "context": text}
120
+ # Where `prompt-why.py` records the delivery: the engine's journal, named by
121
+ # the recorder. Only a file of that exact name is ever written there.
122
+ journal = r.get("journal")
123
+ if isinstance(journal, str) and os.path.basename(journal) == "why-asks.jsonl":
124
+ entry["journal"] = journal
125
+ asks.append(entry)
126
+ tmp = f"{path}.{os.getpid()}.tmp"
127
+ # Created owner-only by the umask, not by a raw `os.open`: this script runs
128
+ # from the plugin's own copy without the engine, so `osprivacy` (the one
129
+ # module allowed to pass open flags) is out of reach, and the hook is one
130
+ # single-threaded process, so the umask is its own.
131
+ mask = os.umask(0o077)
132
+ try:
133
+ with open(tmp, "w", encoding="utf-8") as fh:
134
+ json.dump({"asks": asks[-KEEP:]}, fh, ensure_ascii=False)
135
+ finally:
136
+ os.umask(mask)
137
+ os.chmod(tmp, 0o600)
138
+ os.replace(tmp, path)
139
+ return True
140
+ except Exception: # noqa: BLE001
141
+ return False
142
+
143
+
144
+ def main() -> int:
145
+ try:
146
+ r = json.load(sys.stdin)
147
+ except Exception:
148
+ return 0
149
+ if not isinstance(r, dict):
150
+ return 0
151
+ if not r.get("recorded"):
152
+ # A FAULT IS NOT A QUIET TURN. This returned 0 for every `recorded:
153
+ # false`, and the recorder's reasons are two different kinds: "nothing
154
+ # changed" is an answer about the work, while `IllegalTransition:
155
+ # observed -> proposed` is the recorder failing. Measured 2026-09-07:
156
+ # once `tools/corroborate.py` promoted a session's record, every later
157
+ # turn of that session raised that error and said nothing — roughly
158
+ # seventy-two turns in one session, and the only visible sign was a
159
+ # ledger row that had stopped moving.
160
+ #
161
+ # The recorder marks which kind it is; this only has to stop hiding it.
162
+ # A fault stays a Stop notice: it is for the person, and it is urgent.
163
+ reason = r.get("reason") or ""
164
+ if not r.get("fault"):
165
+ return 0 # nothing changed, not watched, or explained
166
+ print(json.dumps({"systemMessage":
167
+ "Observatory could NOT record this turn: " + reason +
168
+ "\n\nThe facts of this turn are not in the ledger. "
169
+ "`store/raw/record-turn.json` holds the same reason, and "
170
+ "`project-observatory full findings` raises it.",
171
+ "suppressOutput": True}))
172
+ return 0
173
+ if r.get("hasWhy"):
174
+ return 0 # already explained
175
+ if r.get("rationale"):
176
+ return 0 # the turn wrote its rationale; the record links that file
177
+ if "ask" not in r:
178
+ # AN OLDER ENGINE, which decides nothing about asking: it would send this
179
+ # every changed turn, and an every-turn ask injected into the model's context
180
+ # is worse than the notice it replaces. So it gets the old Stop notice.
181
+ print(json.dumps({"systemMessage": message(r, at_stop=True), "suppressOutput": True},
182
+ ensure_ascii=False))
183
+ return 0
184
+ if r.get("ask") is not True:
185
+ return 0 # this record was asked about already
186
+
187
+ text = message(r)
188
+ if leave_for_next_prompt(r.get("session"), r, text):
189
+ return 0
190
+ # THE OLD CHANNEL, as the fallback. A notice the model may not read is better
191
+ # than an ask that silently never happens.
192
+ print(json.dumps({"systemMessage": message(r, at_stop=True), "suppressOutput": True},
88
193
  ensure_ascii=False))
89
194
  return 0
90
195
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Optional Claude Code hooks: record changes in watched Git projects and request missing reasoning. SessionStart reports brief actionable context. Missing workspace, interpreter or watched project degrades quietly; hooks do not block a turn. Project names and paths stay in the private workspace.",
2
+ "description": "Optional Claude Code hooks: record changes in watched Git projects and request missing reasoning. SessionStart reports brief actionable context. Stop records the facts of a turn that changed something since the previous turn first, counting only what differs from the session's baseline (taken at SessionStart; files untracked before the session never count); then, within 2 seconds, it reads the last 4 MB of the session transcript once for this session's writing calls (Edit, Write, MultiEdit, NotebookEdit, and Bash writes through >, >>, tee or sed -i; failed calls, subagent calls and commands over 65,536 characters are skipped). It leaves a request for the why at most once per record, only when the turn changed tracked content, none while a revision's rationale link is still true (same repository, a regular file in the checkout with one hard link and no symbolic link, the exact heading line present once and the file unchanged or with only a new entry appended after it; any other edit asks again), and none when such a call targeted a decisions.md or an adr/ .md, .markdown or .txt file (outside vendored trees) with text carrying the entry's own heading (unique in the register) or, with no heading, most of its added lines, or wrote a decision: or stage: line into .task-pipeline/run.md. UserPromptSubmit hands that pending request to the model with the next prompt, once, within 4,000 characters, records the delivery, and prints nothing when none is pending. Missing workspace, interpreter or watched project degrades quietly; hooks do not block a turn or a prompt. Project names and paths stay in the private workspace.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -26,6 +26,18 @@
26
26
  }
27
27
  ]
28
28
  }
29
+ ],
30
+ "UserPromptSubmit": [
31
+ {
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "shell": "bash",
36
+ "command": "py=\"${OBSERVATORY_PYTHON:-}\"; [ -x \"$py\" ] || py=python3; \"$py\" \"${CLAUDE_PLUGIN_ROOT}/hooks/prompt-why.py\" 2>/dev/null; exit 0",
37
+ "timeout": 5
38
+ }
39
+ ]
40
+ }
29
41
  ]
30
42
  }
31
43
  }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """Hand the model the request for a `why` that the Stop hook left for this session.
3
+
4
+ A UserPromptSubmit hook, so it runs before EVERY prompt of every session, and the
5
+ whole design is about that cost:
6
+
7
+ - **Silent unless an ask is pending.** One `stat` of a per-session file and exit 0;
8
+ the file exists only after `ask-why.py` asked about a record, at most once per
9
+ record (`why_asks.py` in the engine).
10
+ - **Once.** The file is taken by an atomic rename before it is read, so two prompts
11
+ cannot both deliver it, and it is deleted after.
12
+ - **Never in the way of the prompt.** Every error — a payload that is not JSON, a
13
+ session id that is not a plain name, a torn file — is exit 0 with nothing on
14
+ stdout. It cannot block: it prints no `decision`, and it exits 0 on every path.
15
+ - **Delivery is recorded.** For each ask handed over it appends
16
+ `{"event": "delivered"}` to the engine's journal (`store/raw/why-asks.jsonl`, the
17
+ path the recorder named), because the answer rate is measured on delivered asks: a
18
+ headless session that never sends a second prompt never receives its ask, and must
19
+ not count as having ignored it.
20
+ - **Where the model reads it.** The ask goes out as
21
+ `hookSpecificOutput.additionalContext`, which Claude Code renders into the
22
+ model's input; the Stop `systemMessage` it replaces is a notice for the person
23
+ (`tests/test_ask_why_reach.py`).
24
+
25
+ It holds no secret: the pending file carries the ask's text — a project id, a
26
+ record id and a diff's size — and is written 0600 in a 0700 directory.
27
+ Standard library only; it does not need the engine.
28
+ """
29
+ from __future__ import annotations
30
+ import calendar
31
+ import json
32
+ import os
33
+ import re
34
+ import sys
35
+ import time
36
+
37
+ SESSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
38
+ #: An ask older than this is about a turn too far back to explain well; it is dropped.
39
+ STALE_SECONDS = 7 * 86400
40
+ #: The context a prompt can be handed, at most. One ask is about a thousand characters.
41
+ MAX_CHARS = 4000
42
+
43
+
44
+ def pending_dir() -> str:
45
+ """The directory `ask-why.py` writes; the two must agree."""
46
+ base = os.environ.get("XDG_STATE_HOME") or os.path.join(
47
+ os.path.expanduser("~"), ".local", "state")
48
+ return os.path.join(base, "project-observatory", "ask-why")
49
+
50
+
51
+ SEPARATOR = "\n\n---\n\n"
52
+
53
+
54
+ def _put_back(path: str, asks: list) -> None:
55
+ """Return undelivered asks to the session's pending file, beside any the Stop hook
56
+ wrote meanwhile. Owner-only, like the file `ask-why.py` writes."""
57
+ try:
58
+ try:
59
+ with open(path, encoding="utf-8") as fh:
60
+ current = [a for a in (json.load(fh).get("asks") or []) if isinstance(a, dict)]
61
+ except (OSError, ValueError, AttributeError):
62
+ current = []
63
+ ids = {a.get("memoryId") for a in current}
64
+ merged = [a for a in asks if a.get("memoryId") not in ids] + current
65
+ tmp = f"{path}.{os.getpid()}.back"
66
+ mask = os.umask(0o077)
67
+ try:
68
+ with open(tmp, "w", encoding="utf-8") as fh:
69
+ json.dump({"asks": merged}, fh, ensure_ascii=False)
70
+ finally:
71
+ os.umask(mask)
72
+ os.replace(tmp, path)
73
+ except Exception: # noqa: BLE001
74
+ pass # an ask that is not put back stays pending, unasked
75
+
76
+
77
+ def _record_delivery(ask: dict, session: str) -> None:
78
+ """Append a `delivered` row to the journal the recorder named. Never raises."""
79
+ journal = ask.get("journal")
80
+ if not isinstance(journal, str) or os.path.basename(journal) != "why-asks.jsonl" \
81
+ or os.path.islink(journal) or not os.path.isdir(os.path.dirname(journal) or "/nonexistent"):
82
+ return
83
+ row = {"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
84
+ "memoryId": ask.get("memoryId"), "event": "delivered", "session": session}
85
+ if not isinstance(row["memoryId"], str):
86
+ return
87
+ try:
88
+ with open(journal, "a", encoding="utf-8") as fh:
89
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
90
+ except OSError:
91
+ pass # an undelivered count is a floor, not a failed prompt
92
+
93
+
94
+ def _prune(directory: str) -> None:
95
+ """Remove pending asks older than STALE_SECONDS: sessions that ended without a
96
+ further prompt. Run only after a delivery, so a quiet prompt stays one `stat`."""
97
+ try:
98
+ now = time.time()
99
+ for name in os.listdir(directory):
100
+ path = os.path.join(directory, name)
101
+ try:
102
+ if now - os.stat(path).st_mtime > STALE_SECONDS:
103
+ os.unlink(path)
104
+ except OSError:
105
+ pass
106
+ except OSError:
107
+ pass
108
+
109
+
110
+ def _fresh(stamp) -> bool:
111
+ try:
112
+ when = calendar.timegm(time.strptime(str(stamp), "%Y-%m-%dT%H:%M:%SZ"))
113
+ except (ValueError, OverflowError):
114
+ return True # an odd stamp still delivers
115
+ return time.time() - when <= STALE_SECONDS
116
+
117
+
118
+ def main() -> int:
119
+ try:
120
+ payload = json.load(sys.stdin)
121
+ except Exception:
122
+ return 0
123
+ session = payload.get("session_id") if isinstance(payload, dict) else None
124
+ if not isinstance(session, str) or not SESSION.fullmatch(session):
125
+ return 0
126
+ path = os.path.join(pending_dir(), session + ".json")
127
+ if not os.path.isfile(path):
128
+ return 0 # the common case: nothing pending
129
+ taking = f"{path}.taking-{os.getpid()}"
130
+ try:
131
+ os.replace(path, taking) # one prompt takes it, not two
132
+ except OSError:
133
+ return 0
134
+ try:
135
+ with open(taking, encoding="utf-8") as fh:
136
+ doc = json.load(fh)
137
+ except Exception:
138
+ doc = {}
139
+ finally:
140
+ try:
141
+ os.unlink(taking)
142
+ except OSError:
143
+ pass
144
+ _prune(os.path.dirname(path))
145
+ asks = doc.get("asks") if isinstance(doc, dict) else None
146
+ chosen = [a for a in (asks or [])
147
+ if isinstance(a, dict) and isinstance(a.get("context"), str)
148
+ and a["context"].strip() and _fresh(a.get("at"))]
149
+ if not chosen:
150
+ return 0
151
+ # THE CAP, honestly counted. Only asks that fit inside MAX_CHARS whole are handed
152
+ # over and marked delivered; the rest go back for the next prompt. The first ask
153
+ # always goes (cut at the cap if it alone is longer: its record id and the call
154
+ # to make come first).
155
+ sent, rest, used = [], [], 0
156
+ for a in chosen:
157
+ cost = len(a["context"]) + (len(SEPARATOR) if sent else 0)
158
+ if sent and used + cost > MAX_CHARS:
159
+ rest.append(a)
160
+ continue
161
+ sent.append(a)
162
+ used += cost
163
+ if rest:
164
+ _put_back(path, rest)
165
+ context = SEPARATOR.join(a["context"] for a in sent)[:MAX_CHARS]
166
+ for a in sent:
167
+ _record_delivery(a, session)
168
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
169
+ "additionalContext": context}},
170
+ ensure_ascii=False))
171
+ return 0
172
+
173
+
174
+ if __name__ == "__main__":
175
+ try:
176
+ raise SystemExit(main())
177
+ except SystemExit:
178
+ raise
179
+ except Exception:
180
+ raise SystemExit(0)
@@ -52,29 +52,45 @@ py="${OBSERVATORY_PYTHON:-$root/.venv/bin/python}" # set by `full agent instal
52
52
  [ -x "$py" ] || py="$(command -v python3 2>/dev/null)"
53
53
  [ -n "$py" ] || exit 0 # no interpreter: silent
54
54
 
55
- read_field() { # read_field <key>
55
+ # ONE PARSE of the payload, not one interpreter start per field: four starts cost
56
+ # 0.1-0.4 s of every turn's Stop before the recorder ran. A value holding a line
57
+ # break is dropped rather than split across the fields that follow it.
58
+ read_fields() { # stop_hook_active, cwd, session_id, transcript_path
56
59
  [ -n "$payload" ] || return 0
57
60
  printf '%s' "$payload" | "$py" -c '
58
61
  import json, sys
59
62
  try:
60
63
  d = json.load(sys.stdin)
61
64
  except Exception:
62
- sys.exit(0)
63
- print(d.get(sys.argv[1]) or "")
64
- ' "$1" 2>/dev/null
65
+ d = {}
66
+ if not isinstance(d, dict):
67
+ d = {}
68
+ for k in ("stop_hook_active", "cwd", "session_id", "transcript_path"):
69
+ v = d.get(k)
70
+ v = "" if v is None or v is False else str(v)
71
+ print("" if ("\n" in v or "\r" in v) else v)
72
+ ' 2>/dev/null
65
73
  }
74
+ fields="$(read_fields)"
75
+ active=""; cwd=""; session=""; transcript=""
76
+ { IFS= read -r active; IFS= read -r cwd; IFS= read -r session; IFS= read -r transcript; } <<EOF_FIELDS || true
77
+ $fields
78
+ EOF_FIELDS
79
+
80
+ [ "$active" = "True" ] && exit 0
81
+ [ "$active" = "true" ] && exit 0
82
+
83
+ [ -n "$cwd" ] || cwd="${CLAUDE_PROJECT_DIR:-$PWD}"
84
+ # `transcript` is the session's own transcript, so a register another session is
85
+ # editing in the same checkout does not silence this session's ask. Passed only to a
86
+ # recorder that reads it: an older engine's argument parser would refuse the flag
87
+ # and record nothing.
88
+ extra=()
89
+ if [ -n "$transcript" ] && [ -f "$transcript" ] && grep -q -- '--transcript' "$root/tools/record_turn.py" 2>/dev/null; then
90
+ extra=(--transcript "$transcript")
91
+ fi
66
92
 
67
-
68
-
69
-
70
- [ "$(read_field stop_hook_active)" = "True" ] && exit 0
71
- [ "$(read_field stop_hook_active)" = "true" ] && exit 0
72
-
73
- cwd="$(read_field cwd)"; [ -n "$cwd" ] || cwd="${CLAUDE_PROJECT_DIR:-$PWD}"
74
- session="$(read_field session_id)"
75
-
76
-
77
- result="$("$py" "$root/tools/record_turn.py" --cwd "$cwd" --session-id "$session" 2>/dev/null)"
93
+ result="$("$py" "$root/tools/record_turn.py" --cwd "$cwd" --session-id "$session" ${extra[@]+"${extra[@]}"} 2>/dev/null)"
78
94
  [ -n "$result" ] || exit 0
79
95
 
80
96
  printf '%s' "$result" | "$py" "${CLAUDE_PLUGIN_ROOT:-$(dirname "$0")/..}/hooks/ask-why.py" 2>/dev/null
@@ -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
  )
@@ -10,7 +10,7 @@ description: >-
10
10
  provider permissions or choosing a project's authentication architecture.
11
11
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
12
12
  metadata:
13
- version: "0.18.0"
13
+ version: "0.26.0"
14
14
  compatibility: >-
15
15
  Requires an initialized full Project Observatory installation, Python 3.11+
16
16
  and local shell access on macOS or Linux. Provider operations additionally
@@ -30,7 +30,7 @@ names. Never ask the user to paste a credential into the conversation.
30
30
  2. Select the user's initialized `OBSERVATORY_HOME`. Run
31
31
  `project-observatory full doctor`. A missing workspace
32
32
  needs the documented onboarding before secret operations.
33
- 3. Run `"$OBSERVATORY_PYTHON" "$OBSERVATORY_ROOT/tools/skill_check.py" handling-secrets 0.18.0`
33
+ 3. Run `"$OBSERVATORY_PYTHON" "$OBSERVATORY_ROOT/tools/skill_check.py" handling-secrets 0.26.0`
34
34
  (`full agent install` records that interpreter; the stock macOS `python3` is too old).
35
35
  If stale, read the installed skill once and follow its compatible commands.
36
36
  Do not turn an unavailable version check into a retry loop.
@@ -53,8 +53,8 @@ for it. A name or note shaped like a credential is refused, never stored.
53
53
  | List slots | `vault.py list PROJECT ENV` |
54
54
  | Run a command using a slot | `use_secret.py run [--env ENV] [--vault-only] PROJECT NAME -- COMMAND ARGUMENTS` (flags before PROJECT) |
55
55
  | Receive a value from another local command | `use_secret.py pipe NAME -- COMMAND ARGUMENTS` |
56
- | Bind a slot to the one MCP server allowed to receive it as a header (the operator, once) | `vault.py bind PROJECT ENV NAME --header-for https://HOST[:PORT]` (a bare host means https); `--clear` removes it; both are journalled |
57
- | Give an HTTP MCP server its bearer through Claude Code's `headersHelper` | `use_secret.py header [--env ENV] [--name Authorization] [--scheme Bearer] PROJECT NAME`, written into the server's `headersHelper` entry, never run by hand |
56
+ | Bind a slot to the one MCP server allowed to receive it as a header (the operator, once) | `vault.py bind PROJECT ENV NAME --header-for https://HOST[:PORT]` (a bare host means https), or `http://127.0.0.1:PORT` / `http://[::1]:PORT` for a loopback server (`localhost` is refused); `--clear` removes it; both are journalled |
57
+ | Give an HTTP MCP server its bearer through the client's header helper | `use_secret.py header [--env ENV] [--name Authorization] [--scheme Bearer] PROJECT NAME`, written into Claude Code's `headersHelper` entry; for Codex's `http_headers_helper` add `--server-url URL`, since Codex exports no server URL. Never run by hand |
58
58
  | Populate a project's ignored environment file | `vault.py inject PROJECT ENV DIRECTORY` |
59
59
  | Record an exposure | `vault.py leak PROJECT ENV NAME --where "location and evidence, no value"` |
60
60
  | Replace a stored value | `vault.py rotate PROJECT ENV NAME`, replacement on stdin |
@@ -80,14 +80,16 @@ not a sandbox: a child process can encode a value, transmit it, or write it to
80
80
  another file. Only run the command authorized by the task. Do not claim this
81
81
  filter prevents every leak.
82
82
 
83
- **The header door prints a value, so it serves only Claude Code.** `use_secret.py
83
+ **The header door prints a value, so it serves only a client's header helper.** `use_secret.py
84
84
  header` writes one JSON object (`{"Authorization": "Bearer …"}`) for a
85
85
  `headersHelper`, which Claude Code merges into the MCP request rather than the
86
86
  conversation. It reads the vault only (never an env file or the environment), and
87
- refuses unless stdout is a program, `CLAUDE_CODE_MCP_SERVER_URL` names the scheme,
88
- host and port the slot is bound to, and the value has no control character. Never
89
- run it yourself, pipe it, or set `CLAUDE_CODE_MCP_SERVER_URL` to make it answer:
90
- that prints the value into your transcript. Each call is audited. Binding is the
87
+ refuses unless stdout is a program, `CLAUDE_CODE_MCP_SERVER_URL` (or `--server-url`,
88
+ for a client that exports none) names the scheme, host and port the slot is bound to,
89
+ and the value has no control character; two URLs that disagree are refused
90
+ (`url-conflict`). Never run it yourself, pipe it, or set `CLAUDE_CODE_MCP_SERVER_URL` or
91
+ `--server-url` to make it answer: that prints the value into your transcript. Each call
92
+ is audited, with the caller it claimed. Binding is the
91
93
  operator's decision; ask for it, do not run `bind` to make a call succeed.
92
94
 
93
95
  **Stdin carries one thing.** Never pipe a secret into `python3 -`, `node -` or
@@ -126,12 +128,77 @@ exposure and record it with `vault.py leak`. The design is
126
128
 
127
129
  ## Provider integrations
128
130
 
129
- Cloudflare and OpenRouter have separate tools, `cloudflare.py` and
130
- `openrouter.py`. Check their installed `--help` and non-secret inventory first.
131
+ **A provider's MCP tool can be the leak.** Some tools return a credential in their
132
+ answer by design, whatever you asked them for: DigitalOcean's `db-cluster-list`,
133
+ `db-cluster-get`, `db-cluster-*-user`, `docr-docker-credentials`, `doks-get-kubeconfig`,
134
+ `spaces-key-create`. The transcript keeps a tool result whole; it cannot be redacted
135
+ afterwards: a `db-cluster-list` call asked for names can return every cluster's
136
+ passwords. Do not call such a tool; get names from the provider CLI with an
137
+ explicit `--format`, and credentials through the door below. Record any sighting with
138
+ `vault.py leak` and rotate.
139
+
140
+ DigitalOcean managed databases go through `digitalocean.py` in the operator's `doctl`
141
+ context. `db list` and `db consumers CLUSTER` print metadata only; consumer discovery covers
142
+ App Platform bindings, not every possible consumer. `db credential CLUSTER USER --db NAME
143
+ --to vault:PROJECT/ENV/NAME` puts the connection URL in the vault on stdin.
144
+
145
+ DigitalOcean rotation verifies every selected `--spec-env APP:KEY` literal before any reset.
146
+ Its name is not evidence: a plain value must equal the selected database user's current
147
+ provider password, or be a database URL with that password, user, engine and the provider's
148
+ connection host/port. A `${…}` binding is never rewritten, at any level; `--db` must be one of
149
+ the cluster's databases, and `db credential` checks `--db` the same way before it reads a
150
+ password (a cluster the provider lists no databases for, and redis/valkey, refuse `--db` with
151
+ their own reason). Opaque encrypted values and unverified pool endpoints are refused;
152
+ migrate those consumers to App Platform database bindings before rotation. No command in
153
+ this flow prints the current password. Only the named consumers are moved, with downtime
154
+ between reset and ACTIVE; ACTIVE is not application health verification.
155
+
156
+ The door writes an owner-only receipt under `store/digitalocean-rotations/` before reset,
157
+ initially `reset: unknown`. A lost or malformed response, timeout or nonzero CLI result can
158
+ follow a committed reset: exit 5 means **unknown**, not “nothing changed”. Never repeat
159
+ `db rotate` to recover it. Use `db credential` to deliver the current value into the selected
160
+ vault slot, then reconcile each consumer without another reset. Exit 1 is a refusal before
161
+ reset; after an attempt, 2 means deployment unconfirmed, 3 vault delivery failed, 4 an app
162
+ update/redeploy or temporary spec cleanup failed, and 6 receipt update failed. The largest
163
+ code wins. Independent consumers continue after a confirmed reset despite another consumer's
164
+ failure. Provider/vault error bodies are withheld. A crash or failed final receipt write
165
+ leaves the earlier uncertain receipt; there is no automatic reconciliation, retry or claim
166
+ of a transaction across provider and vault. A failed temporary-file cleanup may leave its
167
+ owner-only spec file and is reported for local recovery.
168
+
169
+ Reset, app updates and redeploys each make one attempt; only read calls retry.
170
+ A lost/nonzero mutation response leaves that consumer `unknown`, even if the provider
171
+ already applied it. The receipt distinguishes this from `not_started` for a local
172
+ pre-call failure. Read current spec and deployment state before any retry; a blind
173
+ redeploy is not recovery for a literal. Independent peers still continue. Deployment
174
+ confirmation accepts a validated phase equal to `ACTIVE`, never a suffix or arbitrary
175
+ provider text; malformed metadata remains unconfirmed and its body is not printed.
176
+
177
+
178
+ Cloudflare, OpenRouter and Heroku have separate tools, `cloudflare.py`,
179
+ `openrouter.py` and `heroku.py`. Check their installed `--help` and non-secret inventory first.
180
+ A Heroku credential is `heroku.py issue --preset read|deploy|identity --vault
181
+ <project>/<env>/<NAME> [--team <team>] [--app <app>]`: an OAuth authorization with that
182
+ scope only, proved and delivered on stdin. Heroku cannot narrow one to a team or an app,
183
+ so prefer `read` and name the app. Team and account membership (`heroku.py member`,
184
+ `cloudflare.py member`) is a printed plan until `--apply`; apply only a change the task asked for.
131
185
  They require an explicitly configured integration and the user's own admin or
132
186
  provisioning credential. They do not make a new user inherit the author's
133
187
  accounts, budgets or tokens. Limit issuance and revocation to the task's scope.
134
188
 
189
+ A project's own OpenRouter keys come from its spend policy: `openrouter.py
190
+ issue-for-policy --policy FILE [--dry-run]` keeps their ceilings together within
191
+ the project limit and exits 1 with `OVER LIMIT by $X now ($A against the $L
192
+ limit); it ends after: …` when they are not. `rotate` is the one window past the
193
+ limit: both keys are live while it runs, and until the J-3 change a rotate that
194
+ fails can leave the successor `NAME-next` untracked or the predecessor uncounted —
195
+ delete at the provider the one the slot does not hold, then run
196
+ `issue-for-policy`. A pending row with no hash on an account past the listing is
197
+ cleared with `revoke NAME --forget-pending --reason "…"` once its key is gone. The door must
198
+ record the reason as a pre-action attestation before clearing the row; an
199
+ unwritable journal refuses the change. This is an operator assertion, not
200
+ provider verification. A separate completion event follows the ledger write.
201
+
135
202
  A project that must edit DNS in one zone (a custom domain, a CNAME to its host)
136
203
  gets `cloudflare.py issue --preset dns-edit --zone <zone> --vault <project>/<env>/<NAME>`:
137
204
  Zone Read and DNS Write on that zone only, verified against its records and
@@ -171,8 +238,11 @@ gets `cloudflare.py issue --preset email-send --account <slug> --vault
171
238
  group exists at account level only), verified by listing its suppressions.
172
239
  Adding an Email Routing rule in one zone — one address to a Worker — is
173
240
  `--preset email-routing --zone <zone>`, and deploying that Worker with its KV
174
- namespace is `--preset workers-edit --account <slug>`. Each is its own token in
175
- its own slot. When a preset's groups are unknown, `cloudflare.py groups
241
+ namespace is `--preset workers-edit --account <slug>`; putting a Worker in front of
242
+ a hostname (`example.com/*`) is the zone right `--preset workers-routes --zone <zone>`;
243
+ sending one hostname to another at the edge (a Single Redirect, `www.` to the apex) is
244
+ the zone right `--preset redirect-rules --zone <zone>`.
245
+ Each is its own token in its own slot. When a preset's groups are unknown, `cloudflare.py groups
176
246
  --account <slug> --match "<words>"` lists the catalogue's names and levels,
177
247
  read-only and without ids.
178
248
 
@@ -15,7 +15,7 @@ description: >-
15
15
  the repository.
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 a full Project Observatory installation with organizations.json
21
21
  configured; the observatory MCP server or its local CLI. Creating the resource
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "passioncode",
4
4
  "displayName": "PassionCode.ai",
5
- "version": "0.2.15",
5
+ "version": "0.2.16",
6
6
  "description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check at session start that keeps the PassionCode.ai skill set current.",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",